небольшие изменения

This commit is contained in:
Jganenokk
2026-07-05 19:07:27 +07:00
parent 26f0094705
commit 9f9b0e0734
214 changed files with 27592 additions and 15353 deletions
@@ -11,13 +11,16 @@ import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/sheet_helpers.dart';
import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
import 'package:komet/l10n/app_localizations.dart';
const List<PillNavItem> _navItems = [
PillNavItem(icon: Symbols.image, label: 'Галерея'),
PillNavItem(icon: Symbols.description, label: 'Файл'),
PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'),
PillNavItem(icon: Symbols.bar_chart, label: 'Опрос'),
PillNavItem(icon: Symbols.person, label: 'Контакт'),
const int _navItemCount = 5;
List<PillNavItem> _buildNavItems(AppLocalizations l10n) => [
PillNavItem(icon: Symbols.image, label: l10n.attachSheetGallery),
PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile),
PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation),
PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll),
PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback),
];
Future<void> showAttachmentSheet(
@@ -71,6 +74,8 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
final GallerySource _source = GallerySource.create();
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final Map<String, PhotoEditState> _edits = {};
final Set<String> _tempFiles = {};
final Set<String> _sentFiles = {};
final TextEditingController _captionCtrl = TextEditingController();
final PageController _pageController = PageController();
@@ -101,6 +106,10 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_pageController.dispose();
_selected.dispose();
_captionCtrl.dispose();
for (final path in _tempFiles) {
if (_sentFiles.contains(path)) continue;
File(path).delete().then((_) {}, onError: (_) {});
}
super.dispose();
}
@@ -149,6 +158,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
},
initialCaption: _captionCtrl.text,
onCaptionChanged: (text) => _captionCtrl.text = text,
tempFiles: _tempFiles,
),
),
);
@@ -163,7 +173,10 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
}
void _onCameraTap() {
showCustomNotification(context, 'Камера скоро появится');
showCustomNotification(
context,
AppLocalizations.of(context)!.attachSheetCameraComingSoon,
);
}
void _sendSelection({GalleryItem? fallback}) {
@@ -175,6 +188,12 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
.map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working))
.toList();
final callback = widget.onSend;
if (callback != null) {
for (final photo in picked) {
final path = photo.editedFile?.path;
if (path != null) _sentFiles.add(path);
}
}
final caption = _captionCtrl.text.trim();
Navigator.of(context).pop();
callback?.call(picked, caption);
@@ -215,7 +234,9 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
Positioned(
right: 16,
bottom:
barReserve + 8 + MediaQuery.viewInsetsOf(context).bottom,
barReserve +
8 +
MediaQuery.viewInsetsOf(context).bottom,
child: AnimatedBuilder(
animation: Listenable.merge([
_selected,
@@ -267,6 +288,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
ColorScheme cs,
double bottomReserve,
) {
final l10n = AppLocalizations.of(context)!;
return PageView(
controller: _pageController,
children: [
@@ -277,27 +299,27 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
cs,
bottomReserve,
icon: Symbols.description,
title: 'Отправить файл',
subtitle: 'Документ, архив или любой другой файл',
buttonLabel: 'Выбрать файл',
title: l10n.attachSheetSendFileTitle,
subtitle: l10n.attachSheetSendFileSubtitle,
buttonLabel: l10n.attachSheetChooseFileButton,
onTap: widget.onPickFile,
),
_buildActionPage(
cs,
bottomReserve,
icon: Symbols.location_on,
title: 'Поделиться геопозицией',
subtitle: 'Отправить ваше текущее местоположение',
buttonLabel: 'Отправить геопозицию',
title: l10n.attachSheetShareLocationTitle,
subtitle: l10n.attachSheetShareLocationSubtitle,
buttonLabel: l10n.attachSheetSendLocationButton,
onTap: widget.onShareLocation,
),
_buildActionPage(
cs,
bottomReserve,
icon: Symbols.bar_chart,
title: 'Создать опрос',
subtitle: 'Вопрос с вариантами ответа',
buttonLabel: 'Создать опрос',
title: l10n.attachSheetCreatePoll,
subtitle: l10n.attachSheetCreatePollSubtitle,
buttonLabel: l10n.attachSheetCreatePoll,
onTap: widget.onCreatePoll,
),
_buildPlaceholderPage(cs, bottomReserve),
@@ -379,7 +401,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
return _buildMessage(
scrollController,
cs,
'Изображений не найдено',
AppLocalizations.of(context)!.attachSheetNoImagesFound,
bottomReserve,
);
}
@@ -420,7 +442,12 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hpad, spacing, hpad, bottomReserve + 6),
padding: EdgeInsets.fromLTRB(
hpad,
spacing,
hpad,
bottomReserve + 6,
),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
@@ -489,6 +516,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
}
Widget _buildLimitedBanner(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return InkWell(
onTap: () => _source.manageAccess().then((_) => _loadGallery()),
child: Container(
@@ -500,12 +528,12 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
const SizedBox(width: 10),
Expanded(
child: Text(
'Доступны не все фото',
l10n.attachSheetLimitedAccessInfo,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
Text(
'Изменить',
l10n.loginEdit,
style: TextStyle(
color: cs.primary,
fontSize: 13,
@@ -528,7 +556,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Раздел в разработке',
AppLocalizations.of(context)!.attachSheetSectionInProgress,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
],
@@ -542,6 +570,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
ColorScheme cs,
double bottomReserve,
) {
final l10n = AppLocalizations.of(context)!;
return _scrollableCenter(
scrollController,
bottomReserve,
@@ -553,13 +582,13 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Нет доступа к галерее',
l10n.attachSheetNoGalleryAccessTitle,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Разрешите доступ к фото, чтобы выбрать их отсюда',
l10n.attachSheetNoGalleryAccessSubtitle,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
@@ -569,12 +598,12 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
children: [
TextButton(
onPressed: _loadGallery,
child: const Text('Разрешить'),
child: Text(l10n.attachSheetAllow),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => _source.openSettings(),
child: const Text('Настройки'),
child: Text(l10n.attachSheetSettings),
),
],
),
@@ -648,7 +677,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_navDragAccumDx += dx;
final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp(
0.0,
(_navItems.length - 1).toDouble(),
(_navItemCount - 1).toDouble(),
);
_pageController.jumpTo(pageT * _pageController.position.viewportDimension);
}
@@ -656,7 +685,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
void _onPillDragEnd() {
if (!_navDragging) return;
_navDragging = false;
final target = _currentPageT().round().clamp(0, _navItems.length - 1);
final target = _currentPageT().round().clamp(0, _navItemCount - 1);
_pageController.animateToPage(
target,
duration: _navAnim,
@@ -691,12 +720,13 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
}
Widget _buildPillNav() {
final navItems = _buildNavItems(AppLocalizations.of(context)!);
return LayoutBuilder(
key: const ValueKey('nav'),
builder: (context, constraints) {
final geometry = PillNavGeometry.fromInnerWidth(
constraints.maxWidth - 4,
_navItems.length,
navItems.length,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -710,7 +740,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
builder: (context, _) {
final cs = Theme.of(context).colorScheme;
return SlidingPillNav(
items: _navItems,
items: navItems,
position: _currentPageT(),
geometry: geometry,
onTap: _onSectionTap,
@@ -725,6 +755,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
}
Widget _buildCaptionBar(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
key: const ValueKey('caption'),
height: SlidingPillNav.height,
@@ -747,7 +778,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: 'Добавить подпись...',
hintText: l10n.attachSheetAddCaptionHint,
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
@@ -808,7 +839,7 @@ class _CameraTile extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'Камера',
AppLocalizations.of(context)!.attachSheetCamera,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
],
@@ -0,0 +1,188 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart';
import '../../../../core/utils/format.dart';
import '../../../../models/attachment.dart';
import '../../formatted_message_text.dart';
enum MessageType { text, attachment, voice, control }
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
final Expando<({bool full, String text})> _clockTextCache = Expando();
({IconData icon, Color color}) messageStatusVisual(
String? status, {
required Color dimColor,
Color readColor = kReadReceiptBlue,
Color errorColor = Colors.redAccent,
}) {
switch (status) {
case 'sending':
case 'pending':
return (icon: Symbols.schedule, color: dimColor);
case null:
case 'sent':
return (icon: Symbols.check, color: dimColor);
case 'delivered':
return (icon: Symbols.done_all, color: dimColor);
case 'read':
return (icon: Symbols.done_all, color: readColor);
case 'error':
return (icon: Symbols.error, color: errorColor);
default:
return (icon: Symbols.check, color: dimColor);
}
}
class BubbleContext {
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;
final BuildContext context;
final ColorScheme cs;
final Color text;
final Color dim;
final BubbleShape shape;
final MessageType contentType;
final bool hasPhotoWithCaption;
final bool hasMultiplePhotosNoCaption;
final Map? reactionInfo;
final CachedMessage message;
final bool isMe;
final int myId;
final String chatType;
final String? overrideStatus;
final ValueListenable<int>? otherReadTime;
final ValueListenable<List<double>>? uploadProgress;
final void Function(StickerAttachment sticker)? onStickerTap;
BubbleContext({
required this.context,
required this.cs,
required this.text,
required this.shape,
required this.contentType,
required this.hasPhotoWithCaption,
required this.hasMultiplePhotosNoCaption,
required this.message,
required this.isMe,
required this.myId,
required this.chatType,
this.overrideStatus,
this.otherReadTime,
this.uploadProgress,
this.onStickerTap,
this.reactionInfo,
}) : dim = text.withValues(alpha: 0.7);
String get clockText {
final full = KometSettings.fullTimestamp.value;
final cached = _clockTextCache[message];
if (cached != null && cached.full == full) return cached.text;
final t = formatClock(
DateTime.fromMillisecondsSinceEpoch(message.time),
withSeconds: full,
);
_clockTextCache[message] = (full: full, text: t);
return t;
}
Color get systemTint => cs.onPrimaryContainer.withValues(alpha: 0.12);
Widget caption() {
final style = TextStyle(color: text, fontSize: 16, height: 1.3);
final ranges = message.formatRanges;
if (FormattedMessageText.isFormatted(message.text, ranges)) {
return FormattedMessageText(
text: message.text!,
ranges: ranges,
style: style,
);
}
return Text(message.text ?? '', style: style);
}
Widget meta() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(clockText, style: TextStyle(color: dim, fontSize: 11)),
if (isMe) ...[const SizedBox(width: 4), statusIcon()],
if (message.deleted) ...[const SizedBox(width: 4), deletedIcon()],
],
),
);
}
Widget compactTime() {
final bgColor = isMe
? Colors.black.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.5);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
clockText,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
if (message.deleted) ...[
const SizedBox(width: 3),
const Icon(Symbols.delete, size: 11, color: Colors.white),
],
],
),
);
}
Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim);
Widget statusIcon() {
final base = overrideStatus ?? message.status;
final rt = otherReadTime;
if (rt == null) return _statusIconFor(base);
return ValueListenableBuilder<int>(
valueListenable: rt,
builder: (context, readTime, _) =>
_statusIconFor(_readUpgradedStatus(base, readTime)),
);
}
String? _readUpgradedStatus(String? base, int readTime) {
if ((base == null || base == 'sent') &&
readTime > 0 &&
readTime >= message.time) {
return 'read';
}
return base;
}
Widget _statusIconFor(String? status) {
final v = messageStatusVisual(status, dimColor: dim);
return Icon(v.icon, size: 14, color: v.color);
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../core/utils/format.dart';
import '../../../../models/attachment.dart';
import 'bubble_context.dart';
class CallBubble extends StatelessWidget {
final BubbleContext ctx;
final CallAttachment call;
const CallBubble({super.key, required this.ctx, required this.call});
@override
Widget build(BuildContext context) {
final isMe = ctx.isMe;
final missed = call.isMissedOrFailed;
final accent = isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary;
final iconColor = missed ? ctx.cs.error : accent;
final IconData icon;
final String label;
if (call.isGroup) {
icon = call.isVideo ? Symbols.videocam : Symbols.groups;
label = call.isVideo ? 'Групповой видеозвонок' : 'Групповой звонок';
} else if (call.isVideo) {
icon = Symbols.videocam;
label = missed
? (isMe ? 'Отменённый видеозвонок' : 'Пропущенный видеозвонок')
: (isMe ? 'Исходящий видеозвонок' : 'Входящий видеозвонок');
} else {
icon = Symbols.call;
label = missed
? (isMe ? 'Отменённый звонок' : 'Пропущенный звонок')
: (isMe ? 'Исходящий звонок' : 'Входящий звонок');
}
final directionIcon = isMe ? Symbols.call_made : Symbols.call_received;
final subtitle = missed
? ctx.clockText
: '${ctx.clockText} · ${formatSecondsMmSs((call.durationMs / 1000).round())}';
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 16, 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: missed
? ctx.cs.error.withValues(alpha: 0.12)
: (isMe ? ctx.systemTint : ctx.cs.primaryContainer),
shape: BoxShape.circle,
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 10),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
directionIcon,
size: 13,
color: missed ? ctx.cs.error : ctx.dim,
),
const SizedBox(width: 3),
Text(
subtitle,
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../models/attachment.dart';
import 'bubble_context.dart';
Widget buildContactCard(
BubbleContext ctx, {
String? firstName,
String? lastName,
String? name,
String? photoUrl,
String? phoneNumber,
}) {
final isMe = ctx.isMe;
final first = firstName ?? '';
final last = lastName ?? '';
final hasFirstName = first.isNotEmpty;
final hasLastName = last.isNotEmpty;
final resolvedName = (hasFirstName || hasLastName)
? '${hasFirstName ? first : ''}${hasLastName ? ' $last' : ''}'.trim()
: (name ?? 'Contact');
final bgColor = isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(24),
),
child: photoUrl != null && photoUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(24),
child: CachedNetworkImage(
imageUrl: photoUrl,
fit: BoxFit.cover,
memCacheWidth: kAvatarThumbSize,
memCacheHeight: kAvatarThumbSize,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 24,
),
),
)
: Icon(
Symbols.person,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 24,
),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
resolvedName.isNotEmpty ? resolvedName : 'Contact',
style: TextStyle(
color: ctx.text,
fontSize: 15,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (phoneNumber != null) ...[
const SizedBox(height: 2),
Text(
phoneNumber,
style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2),
),
],
],
),
),
],
),
);
}
class ContactBubble extends StatelessWidget {
final BubbleContext ctx;
final ContactAttachment contact;
const ContactBubble({super.key, required this.ctx, required this.contact});
@override
Widget build(BuildContext context) {
return buildContactCard(
ctx,
firstName: contact.firstName,
lastName: contact.lastName,
name: contact.name,
photoUrl: contact.photoUrl ?? contact.baseUrl,
phoneNumber: contact.phoneNumber,
);
}
}
@@ -0,0 +1,194 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/main.dart';
import '../../../../core/utils/download_progress.dart';
import '../../../../core/utils/file_download.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../models/attachment.dart';
import '../../custom_notification.dart';
import 'bubble_context.dart';
class FileBubble extends StatelessWidget {
final BubbleContext ctx;
final FileAttachment file;
final bool fill;
const FileBubble({
super.key,
required this.ctx,
required this.file,
this.fill = false,
});
@override
Widget build(BuildContext context) {
final isMe = ctx.isMe;
final name = file.name ?? 'File';
final size = file.size ?? 0;
final sizeStr = formatBytes(size);
final fileId = file.fileId;
final cacheName = '${fileId}_$name';
final preview = file.preview;
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
final inner = Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (previewUrl.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
imageUrl: previewUrl,
width: 240,
height: 160,
fit: BoxFit.cover,
memCacheWidth: 480,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
const SizedBox(height: 8),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: isMe ? ctx.systemTint : ctx.cs.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Symbols.description,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
size: 20,
),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(
cacheName,
),
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
),
),
],
),
),
const SizedBox(width: 12),
ValueListenableBuilder<double?>(
valueListenable: MediaDownloadProgress.notifier(cacheName),
builder: (context, progress, _) {
final downloading = progress != null;
return GestureDetector(
onTap: downloading
? null
: () => _downloadFile(ctx.context, file, name),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: isMe
? ctx.systemTint
: ctx.cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: downloading
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
)
: Icon(
Symbols.download,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
size: 18,
),
),
);
},
),
],
),
ctx.meta(),
],
),
);
return fill ? inner : IntrinsicWidth(child: inner);
}
Future<void> _downloadFile(
BuildContext context,
FileAttachment file,
String name,
) async {
final fileId = file.fileId;
if (fileId == null) {
showCustomNotification(context, 'Не удалось определить файл');
return;
}
Haptics.tap();
final cacheName = '${fileId}_$name';
MediaDownloadProgress.set(cacheName, 0);
final result = await openCachedFile(
cacheName,
() => messagesModule.getFileUrl(
messageId: ctx.message.id,
chatId: ctx.message.chatId,
fileId: fileId,
),
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
);
MediaDownloadProgress.set(cacheName, null);
if (!context.mounted) return;
if (!result.ok) {
showCustomNotification(
context,
'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}',
);
}
}
}
@@ -0,0 +1,201 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../models/attachment.dart';
import 'bubble_context.dart';
import 'contact_bubble.dart';
import 'file_bubble.dart';
import 'photo_bubble.dart';
import 'sticker_bubble.dart';
Widget _forwardedHeader(
BubbleContext ctx,
ForwardedMessageAttachment forwarded,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ??
ContactCache.get(forwarded.originalSenderId) ??
forwarded.originalSenderId.toString();
final senderAvatar =
forwarded.originalSenderAvatar ??
ContactCache.getAvatar(forwarded.originalSenderId);
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8, right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(
senderAvatar,
maxWidth: 96,
maxHeight: 96,
),
backgroundColor: ctx.cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text(
displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?',
style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer),
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
displaySender,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
class ForwardedPhotoBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final List<PhotoAttachment> photos;
const ForwardedPhotoBubble({
super.key,
required this.ctx,
required this.forwarded,
required this.photos,
});
@override
Widget build(BuildContext context) {
final message = ctx.message;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_forwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
if (hasCaption) ...[
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
message.text ?? '',
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
),
),
const SizedBox(height: 6),
],
PhotoBubble(ctx: ctx, photos: photos),
],
);
}
}
class ForwardedGenericBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final List<MessageAttachment> attachments;
const ForwardedGenericBubble({
super.key,
required this.ctx,
required this.forwarded,
required this.attachments,
});
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_forwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
...attachments.map((a) {
if (a is FileAttachment) {
return FileBubble(ctx: ctx, file: a, fill: true);
}
if (a is StickerAttachment) {
return StickerBubble(ctx: ctx, sticker: a);
}
return const SizedBox.shrink();
}),
],
),
);
}
}
class ForwardedStickerBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final MessageAttachment sticker;
const ForwardedStickerBubble({
super.key,
required this.ctx,
required this.forwarded,
required this.sticker,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_forwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
StickerBubble(ctx: ctx, sticker: sticker),
],
);
}
}
class ForwardedContactBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
const ForwardedContactBubble({
super.key,
required this.ctx,
required this.forwarded,
});
@override
Widget build(BuildContext context) {
final contact = forwarded.originalContact!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_forwardedHeader(ctx, forwarded),
const SizedBox(height: 4),
buildContactCard(
ctx,
firstName: contact.firstName,
lastName: contact.lastName,
name: contact.name,
photoUrl: contact.photoUrl ?? contact.baseUrl,
phoneNumber: contact.phoneNumber,
),
],
);
}
}
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/link_opener.dart';
import '../../../../models/attachment.dart';
import 'bubble_context.dart';
class LocationBubble extends StatelessWidget {
final BubbleContext ctx;
final LocationAttachment location;
const LocationBubble({super.key, required this.ctx, required this.location});
@override
Widget build(BuildContext context) {
final isMe = ctx.isMe;
final lat = location.latitude;
final lon = location.longitude;
final coords = lat != null && lon != null
? '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}'
: null;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: lat == null || lon == null
? null
: () {
Haptics.tap();
openLocationOnMap(
ctx.context,
lat,
lon,
zoom: location.zoom,
);
},
child: Container(
width: 240,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08)
: ctx.cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: isMe ? ctx.systemTint : ctx.cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Symbols.location_on,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
size: 22,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
location.title ?? 'Геопозиция',
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
location.address ?? coords ?? 'Открыть на карте',
style: TextStyle(color: ctx.dim, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
ctx.meta(),
],
),
),
);
}
}
@@ -0,0 +1,420 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../models/attachment.dart';
import '../../photo_viewer.dart';
import 'bubble_context.dart';
class PhotoBubble extends StatelessWidget {
static const Radius _bigRadius = Radius.circular(
BubbleContext.bubbleBorderRadius,
);
static const Radius _smallRadius = Radius.circular(4);
static const Radius _photoRadius = Radius.circular(
BubbleContext.photoBorderRadius,
);
final BubbleContext ctx;
final List<PhotoAttachment> photos;
const PhotoBubble({super.key, required this.ctx, required this.photos});
@override
Widget build(BuildContext context) {
final message = ctx.message;
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: BubbleContext.compactTimePadding,
right: BubbleContext.compactTimePadding,
child: ctx.compactTime(),
),
],
);
}
if (count == 1) {
final photo = photos[0];
final pw = photo.width?.toDouble() ?? 200;
final photoWidth = pw.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
return SizedBox(
width: photoWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
photosWidget,
Padding(
padding: const EdgeInsets.only(
left: BubbleContext.captionPaddingHorizontal,
right: BubbleContext.captionPaddingRight,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: ctx.caption()),
ctx.meta(),
],
),
),
],
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
photosWidget,
Padding(
padding: const EdgeInsets.only(
left: BubbleContext.captionPaddingHorizontal,
right: BubbleContext.captionPaddingRight,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: ctx.caption()),
ctx.meta(),
],
),
),
],
);
}
Widget _buildSinglePhoto(BubbleContext ctx, PhotoAttachment photo) {
final width = photo.width?.toDouble() ?? 200;
final height = photo.height?.toDouble() ?? 200;
final constrainedWidth = width.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final constrainedHeight = height.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
final matchTop = ctx.hasPhotoWithCaption;
final matchBottom = !ctx.hasPhotoWithCaption;
final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = matchBottom
? (ctx.isMe ? _bigRadius : _smallRadius)
: _smallRadius;
final bottomR = matchBottom
? (ctx.isMe ? _smallRadius : _bigRadius)
: _smallRadius;
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
),
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
constrainedWidth,
constrainedHeight,
memWidth: (constrainedWidth * dpr).round(),
memHeight: (constrainedHeight * dpr).round(),
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, 0),
if (ctx.uploadProgress == null)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, photo),
),
),
],
),
);
}
Widget _buildPhotoImage(
BubbleContext ctx,
PhotoAttachment photo,
double width,
double height, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
if (localPath != null) {
return Image.file(
File(localPath),
width: width,
height: height,
fit: BoxFit.cover,
cacheWidth: memWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, width, height),
);
}
final imageUrl = photo.baseUrl ?? '';
if (imageUrl.isNotEmpty) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: BoxFit.cover,
memCacheWidth: memWidth,
memCacheHeight: memHeight,
fadeInDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
);
}
return _buildPhotoPlaceholder(ctx.cs, width, height);
}
Widget _buildUploadOverlay(
ValueListenable<List<double>> progress,
int index,
) {
return Positioned.fill(
child: ValueListenableBuilder<List<double>>(
valueListenable: progress,
builder: (context, values, _) {
final value = index < values.length ? values[index] : 1.0;
final indeterminate = value <= 0 || value >= 1.0;
return Container(
color: Colors.black.withValues(alpha: 0.4),
alignment: Alignment.center,
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 2.5,
value: indeterminate ? null : value,
color: Colors.white,
),
),
);
},
),
);
}
BorderRadius _multiPhotoCornerRadius({
required bool matchTop,
required bool matchBottom,
required bool isMe,
}) {
final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = matchBottom ? _smallRadius : _photoRadius;
final bottomR = matchBottom
? (isMe ? _smallRadius : _bigRadius)
: _photoRadius;
return BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
);
}
Widget _buildTwoPhotos(
BubbleContext ctx,
PhotoAttachment p1,
PhotoAttachment p2,
) {
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
final matchBottom =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
return ClipRRect(
borderRadius: _multiPhotoCornerRadius(
matchTop: matchTop,
matchBottom: matchBottom,
isMe: ctx.isMe,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildPhotoTile(ctx, p1, 0)),
const SizedBox(width: 2),
Expanded(child: _buildPhotoTile(ctx, p2, 1)),
],
),
);
}
Widget _buildPhotoGrid(BubbleContext ctx, List<PhotoAttachment> photos) {
final displayCount = photos.length > 4 ? 4 : photos.length;
final remaining = photos.length - 4;
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
final matchBottom =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
return ClipRRect(
borderRadius: _multiPhotoCornerRadius(
matchTop: matchTop,
matchBottom: matchBottom,
isMe: ctx.isMe,
),
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', i);
}
return _buildPhotoTile(ctx, photos[i], i);
}),
),
);
}
Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) {
final cachePx =
(BubbleContext.photoMaxSize /
2 *
MediaQuery.of(ctx.context).devicePixelRatio)
.round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
double.infinity,
double.infinity,
memWidth: cachePx,
memHeight: cachePx,
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, photo),
),
),
],
),
);
}
Widget _buildPhotoTileWithOverlay(
BubbleContext ctx,
PhotoAttachment photo,
String overlay,
int index,
) {
final cachePx =
(BubbleContext.photoMaxSize /
2 *
MediaQuery.of(ctx.context).devicePixelRatio)
.round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
double.infinity,
double.infinity,
memWidth: cachePx,
memHeight: cachePx,
),
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Text(
overlay,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
],
),
);
}
Widget _buildPhotoPlaceholder(
ColorScheme cs,
double w,
double h, {
VoidCallback? onRetry,
}) {
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),
),
);
}
void _openPhotoViewer(BuildContext context, PhotoAttachment photo) {
final url = photo.baseUrl ?? '';
if (url.isEmpty) return;
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoViewerScreen(baseUrl: url),
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../../../../models/attachment.dart';
import '../../poll_view.dart';
import 'bubble_context.dart';
class PollBubble extends StatelessWidget {
final BubbleContext ctx;
final PollAttachment poll;
const PollBubble({super.key, required this.ctx, required this.poll});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
PollView(
chatId: ctx.message.chatId,
messageId: ctx.message.id,
pollId: poll.pollId,
myId: ctx.myId,
fallbackTitle: poll.title ?? ctx.message.text,
textColor: ctx.text,
dimColor: ctx.dim,
accentColor: ctx.isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
ctx.meta(),
],
),
),
);
}
}
@@ -0,0 +1,145 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/link_opener.dart';
import '../../../../models/attachment.dart';
import '../../formatted_message_text.dart';
import 'bubble_context.dart';
class ShareBubble extends StatelessWidget {
final BubbleContext ctx;
final ShareAttachment share;
const ShareBubble({super.key, required this.ctx, required this.share});
@override
Widget build(BuildContext context) {
final isMe = ctx.isMe;
final message = ctx.message;
final hasText = message.text != null && message.text!.isNotEmpty;
final image = share.image;
final imageUrl = image?.baseUrl ?? image?.previewData ?? '';
final cardColor = isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08)
: ctx.cs.surfaceContainerHigh;
final host =
share.host ??
(share.url != null ? Uri.tryParse(share.url!)?.host : null);
final card = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: share.url == null
? null
: () {
Haptics.tap();
openExternalUrl(ctx.context, share.url!);
},
child: Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
width: 280,
height: 140,
fit: BoxFit.cover,
memCacheWidth: 560,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (host != null && host.isNotEmpty) ...[
Text(
host,
style: TextStyle(
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
],
if (share.title != null && share.title!.isNotEmpty)
Text(
share.title!,
style: TextStyle(
color: ctx.text,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (share.description != null &&
share.description!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
share.description!,
style: TextStyle(
color: ctx.dim,
fontSize: 13,
height: 1.25,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (hasText) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FormattedMessageText(
text: message.text!,
ranges: message.formatRanges,
style: TextStyle(
color: ctx.text,
fontSize: 16,
height: 1.3,
),
),
),
const SizedBox(height: 6),
],
card,
ctx.meta(),
],
),
),
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../models/attachment.dart';
import '../../sticker_image.dart';
import 'bubble_context.dart';
class StickerBubble extends StatelessWidget {
final BubbleContext ctx;
final MessageAttachment sticker;
const StickerBubble({super.key, required this.ctx, required this.sticker});
@override
Widget build(BuildContext context) {
final url = sticker.baseUrl ?? '';
final preview = sticker.previewData ?? '';
final staticUrl = url.isNotEmpty ? url : preview;
final lottieUrl = sticker is StickerAttachment
? (sticker as StickerAttachment).lottieUrl
: null;
Widget content = Stack(
children: [
SizedBox(
width: 150,
height: 150,
child: StickerImage(
url: staticUrl,
lottieUrl: lottieUrl,
size: 150,
memCacheWidth: 300,
),
),
Positioned(
bottom: BubbleContext.compactTimePadding,
right: BubbleContext.compactTimePadding,
child: _buildStickerMeta(),
),
],
);
final onTap = ctx.onStickerTap;
if (onTap != null && sticker is StickerAttachment) {
final s = sticker as StickerAttachment;
content = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(s),
child: content,
);
}
return content;
}
Widget _buildStickerMeta() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ctx.clockText,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
if (ctx.isMe) ...[
const SizedBox(width: 3),
_buildStickerStatusIcon(),
],
if (ctx.message.deleted) ...[
const SizedBox(width: 3),
const Icon(Symbols.delete, size: 12, color: Colors.white),
],
],
),
);
}
Widget _buildStickerStatusIcon() {
final status = ctx.overrideStatus ?? ctx.message.status;
final v = messageStatusVisual(status, dimColor: Colors.white);
return Icon(v.icon, size: 13, color: v.color);
}
}
@@ -0,0 +1,192 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/main.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../models/attachment.dart';
import '../../custom_notification.dart';
import '../../video_player_screen.dart';
import 'bubble_context.dart';
import 'video_note_bubble.dart';
class VideoBubble extends StatelessWidget {
final BubbleContext ctx;
final VideoAttachment video;
const VideoBubble({super.key, required this.ctx, required this.video});
@override
Widget build(BuildContext context) {
final message = ctx.message;
if (video.isNote) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
VideoNoteBubble(
attachment: video,
messageId: message.id,
chatId: message.chatId,
cs: ctx.cs,
),
const SizedBox(height: 6),
ctx.meta(),
],
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
final thumb = video.thumbnail;
final durationMs = video.duration;
final previewUrl = (thumb != null && thumb.isNotEmpty)
? thumb
: (video.baseUrl != null && video.baseUrl!.isNotEmpty)
? video.baseUrl!
: (video.previewData ?? '');
final w = video.width;
final h = video.height;
final width = (w?.toDouble() ?? 200.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final height = (h?.toDouble() ?? 150.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
Widget placeholder() => Container(
width: width,
height: height,
color: ctx.cs.surfaceContainerHighest,
child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant),
);
final preview = ClipRRect(
borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius),
child: Stack(
children: [
previewUrl.isEmpty
? placeholder()
: CachedNetworkImage(
imageUrl: previewUrl,
width: width,
height: height,
fit: BoxFit.cover,
memCacheWidth: (width * dpr).round(),
fadeInDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
errorWidget: (_, _, _) => placeholder(),
),
Positioned.fill(
child: Center(
child: Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
),
),
if (durationMs != null && durationMs > 0)
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(6),
),
child: Text(
formatSecondsMmSs((durationMs / 1000).round()),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _playVideo(ctx.context, video),
),
),
],
),
);
if (!hasCaption) {
return Stack(
children: [
preview,
Positioned(
bottom: BubbleContext.compactTimePadding,
right: BubbleContext.compactTimePadding,
child: ctx.compactTime(),
),
],
);
}
return SizedBox(
width: width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
preview,
Padding(
padding: const EdgeInsets.only(
left: BubbleContext.captionPaddingHorizontal,
right: BubbleContext.captionPaddingRight,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: ctx.caption()),
ctx.meta(),
],
),
),
],
),
);
}
Future<void> _playVideo(BuildContext context, VideoAttachment video) async {
final videoId = video.videoId;
final token = video.videoToken;
if (videoId == null || token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
final sources = await messagesModule.getVideoSources(
messageId: ctx.message.id,
chatId: ctx.message.chatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (sources.isEmpty) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => VideoPlayerScreen(sources: sources),
),
);
}
}
@@ -0,0 +1,201 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
import 'package:komet/main.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/utils/media_cache.dart';
import '../../../../models/attachment.dart';
class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
final String messageId;
final int chatId;
final ColorScheme cs;
const VideoNoteBubble({
super.key,
required this.attachment,
required this.messageId,
required this.chatId,
required this.cs,
});
@override
State<VideoNoteBubble> createState() => _VideoNoteBubbleState();
}
class _VideoNoteBubbleState extends State<VideoNoteBubble> {
static const double _size = 210;
VideoPlayerController? _controller;
bool _loading = false;
bool _error = false;
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
void _onTick() {
if (mounted) setState(() {});
}
static Uint8List? _previewBytes(String? data) {
if (data == null) return null;
const marker = 'base64,';
final idx = data.indexOf(marker);
if (idx < 0) return null;
try {
return base64Decode(data.substring(idx + marker.length));
} catch (_) {
return null;
}
}
Future<void> _toggle() async {
final existing = _controller;
if (existing != null) {
setState(
() => existing.value.isPlaying ? existing.pause() : existing.play(),
);
return;
}
if (_loading) return;
final a = widget.attachment;
final videoId = a.videoId;
final token = a.videoToken;
if (videoId == null || token == null) {
setState(() => _error = true);
return;
}
setState(() => _loading = true);
Haptics.tap();
try {
final cacheName = 'videonote_$videoId.mp4';
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await messagesModule.getVideoUrl(
messageId: widget.messageId,
chatId: widget.chatId,
token: token,
videoId: videoId,
);
if (url == null) throw Exception('no_url');
file = await MediaCache.getOrDownload(cacheName, url);
if (file == null) throw Exception('download');
}
if (!mounted) return;
final c = VideoPlayerController.file(file);
_controller = c;
await c.initialize();
if (!mounted) {
c.dispose();
return;
}
await c.setLooping(true);
c.addListener(_onTick);
c.play();
setState(() => _loading = false);
} catch (e) {
logger.w('VideoNoteBubble._toggle: $e');
if (mounted) {
setState(() {
_loading = false;
_error = true;
});
}
}
}
@override
Widget build(BuildContext context) {
final a = widget.attachment;
final c = _controller;
final ready = c != null && c.value.isInitialized;
final playing = ready && c.value.isPlaying;
final preview = _previewBytes(a.previewData);
double progress = 0;
if (ready && c.value.duration.inMilliseconds > 0) {
progress =
c.value.position.inMilliseconds / c.value.duration.inMilliseconds;
}
return GestureDetector(
onTap: _toggle,
child: SizedBox(
width: _size,
height: _size,
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: SizedBox(
width: _size,
height: _size,
child: ready
? FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: c.value.size.width,
height: c.value.size.height,
child: VideoPlayer(c),
),
)
: preview != null
? Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
)
: Container(color: widget.cs.surfaceContainerHighest),
),
),
if (ready)
SizedBox(
width: _size - 2,
height: _size - 2,
child: CircularProgressIndicator(
value: progress.clamp(0.0, 1.0),
strokeWidth: 3,
color: widget.cs.primary,
backgroundColor: Colors.white24,
),
),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
],
),
),
);
}
}
@@ -0,0 +1,515 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import 'package:komet/main.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/utils/media_cache.dart';
import '../../custom_notification.dart';
class VoiceMessageBubble extends StatefulWidget {
final int duration;
final String url;
final Color textColor;
final bool isMe;
final bool deleted;
final String? status;
final ValueListenable<int>? otherReadTime;
final int time;
final ColorScheme cs;
final String? waveData;
final int chatId;
final String messageId;
final int? audioId;
final String? preloadedText;
const VoiceMessageBubble({
super.key,
required this.duration,
required this.url,
required this.textColor,
required this.isMe,
this.deleted = false,
this.status,
this.otherReadTime,
required this.time,
required this.cs,
this.waveData,
required this.chatId,
required this.messageId,
this.audioId,
this.preloadedText,
});
@override
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
}
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isPlaying = false;
final ValueNotifier<double> _progress = ValueNotifier(0.0);
bool _transcriptionVisible = false;
String? _transcriptionText;
bool _transcriptionLoading = false;
OggOpusPlayer? _player;
bool _loadingAudio = false;
Timer? _ticker;
late final List<int> _amps = _parseWave(widget.waveData);
static List<int> _parseWave(String? data) {
if (data == null || data.isEmpty) return const [];
return data.codeUnits;
}
@override
void initState() {
super.initState();
_transcriptionText = widget.preloadedText;
}
@override
void dispose() {
_ticker?.cancel();
_player?.state.removeListener(_onPlayerState);
_player?.dispose();
_progress.dispose();
super.dispose();
}
Future<void> _togglePlay() async {
if (_loadingAudio) return;
if (_player != null) {
if (_isPlaying) {
_player!.pause();
} else {
if (widget.duration > 0 &&
_player!.currentPosition >= widget.duration - 0.05) {
_progress.value = 0;
}
_player!.play();
}
return;
}
final url = widget.url;
if (url.isEmpty) return;
setState(() => _loadingAudio = true);
try {
final name = '${widget.audioId ?? widget.messageId}.ogg';
final file = await MediaCache.getOrDownload(name, url);
if (!mounted) return;
if (file == null) {
showCustomNotification(context, 'Не удалось загрузить аудио');
return;
}
final player = OggOpusPlayer(file.path);
_player = player;
player.state.addListener(_onPlayerState);
_ticker = Timer.periodic(
const Duration(milliseconds: 60),
(_) => _onTick(),
);
player.play();
} catch (e) {
logger.w('VoiceBubble._togglePlay: $e');
if (mounted) showCustomNotification(context, 'Ошибка воспроизведения');
} finally {
if (mounted) setState(() => _loadingAudio = false);
}
}
void _onTick() {
final player = _player;
if (player == null || widget.duration <= 0) return;
final pos = player.currentPosition;
_progress.value = (pos / widget.duration).clamp(0.0, 1.0);
}
void _onPlayerState() {
final state = _player?.state.value;
if (!mounted) return;
final playing = state == PlayerState.playing;
if (playing != _isPlaying) setState(() => _isPlaying = playing);
if (state == PlayerState.ended) {
_progress.value = 1.0;
}
}
Widget _buildStatusIcon() {
final rt = widget.otherReadTime;
if (rt == null) return _statusIconFor(widget.status);
return ValueListenableBuilder<int>(
valueListenable: rt,
builder: (context, readTime, _) =>
_statusIconFor(_upgradedStatus(readTime)),
);
}
String? _upgradedStatus(int readTime) {
final base = widget.status;
if ((base == null || base == 'sent') &&
readTime > 0 &&
readTime >= widget.time) {
return 'read';
}
return base;
}
Widget _statusIconFor(String? status) {
IconData icon;
Color color;
if (status == null || status == 'sent') {
icon = Symbols.check;
color = Colors.white54;
} else {
switch (status) {
case 'sending':
case 'pending':
icon = Symbols.schedule;
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
case 'sent':
icon = Symbols.check;
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
case 'delivered':
icon = Symbols.done_all;
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
case 'read':
icon = Symbols.done_all;
color = kReadReceiptBlue;
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
}
}
return Icon(icon, size: 14, color: color);
}
@override
Widget build(BuildContext context) {
final waveInactiveColor = widget.isMe
? widget.cs.onPrimaryContainer.withValues(alpha: 0.35)
: widget.cs.surfaceContainerHighest;
final waveActiveColor = widget.isMe
? widget.cs.onPrimaryContainer.withValues(alpha: 0.7)
: widget.cs.primary;
return SizedBox(
width: 240,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: _togglePlay,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: widget.isMe
? widget.cs.onPrimaryContainer.withValues(alpha: 0.12)
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: _loadingAudio
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
),
)
: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
size: 18,
),
),
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 26,
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, _) => CustomPaint(
size: Size.infinite,
painter: _WaveformPainter(
amps: _amps,
progress: progress,
active: waveActiveColor,
inactive: waveInactiveColor,
),
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _requestTranscription,
child: SizedBox(
width: 20,
height: 32,
child: Center(
child: _transcriptionLoading
? SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: widget.textColor.withValues(alpha: 0.6),
),
)
: Text(
'Т',
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Center(
child: Text(
formatSecondsMmSs(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topLeft,
child: _transcriptionVisible
? Text(
_transcriptionText ?? '',
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.8),
fontSize: 12,
height: 1.3,
),
maxLines: 10,
overflow: TextOverflow.ellipsis,
)
: const SizedBox.shrink(),
),
),
if (!_transcriptionVisible) ...[
Text(
formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
if (widget.deleted) ...[
const SizedBox(width: 2),
Icon(
Symbols.delete,
size: 13,
color: widget.textColor.withValues(alpha: 0.6),
),
],
],
],
),
if (_transcriptionVisible) ...[
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
if (widget.deleted) ...[
const SizedBox(width: 2),
Icon(
Symbols.delete,
size: 13,
color: widget.textColor.withValues(alpha: 0.6),
),
],
],
),
],
],
),
);
}
Future<void> _requestTranscription() async {
if (widget.audioId == null) return;
if (_transcriptionVisible && _transcriptionText != null) {
setState(() {
_transcriptionVisible = false;
});
return;
}
if (TranscriptionCache.has(widget.messageId)) {
final cached = TranscriptionCache.get(widget.messageId)!;
setState(() {
_transcriptionText = cached.text ?? 'не удалось распознать текст';
_transcriptionVisible = true;
});
return;
}
setState(() {
_transcriptionLoading = true;
});
try {
final result = await messagesModule.requestTranscription(
widget.chatId,
int.tryParse(widget.messageId) ?? 0,
widget.audioId!,
);
TranscriptionCache.put(widget.messageId, result);
if (!mounted) return;
setState(() {
_transcriptionLoading = false;
if (result.status == 1) {
_transcriptionText = (result.text == null || result.text!.isEmpty)
? 'не удалось распознать текст'
: result.text;
_transcriptionVisible = true;
} else if (result.status == 0) {
_transcriptionText = 'транскрибация...';
_transcriptionVisible = true;
}
});
} catch (e) {
logger.w('VoiceBubble._requestTranscription: $e');
if (!mounted) return;
setState(() {
_transcriptionLoading = false;
_transcriptionText = 'ошибка транскрибации';
_transcriptionVisible = true;
});
}
}
}
class _WaveformPainter extends CustomPainter {
final List<int> amps;
final double progress;
final Color active;
final Color inactive;
const _WaveformPainter({
required this.amps,
required this.progress,
required this.active,
required this.inactive,
});
@override
void paint(Canvas canvas, Size size) {
final center = size.height / 2;
if (amps.isEmpty) {
final track = Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(0, center),
Offset(size.width, center),
track..color = inactive,
);
if (progress > 0) {
canvas.drawLine(
Offset(0, center),
Offset(size.width * progress.clamp(0.0, 1.0), center),
track..color = active,
);
}
return;
}
final n = amps.length;
var maxAmp = 1;
for (final a in amps) {
if (a > maxAmp) maxAmp = a;
}
final slot = size.width / n;
final barW = (slot * 0.55).clamp(1.0, 3.0);
final paint = Paint();
for (var i = 0; i < n; i++) {
final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height);
final x = i * slot + (slot - barW) / 2;
paint.color = ((i + 0.5) / n) <= progress ? active : inactive;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x, center - h / 2, barW, h),
Radius.circular(barW / 2),
),
paint,
);
}
}
@override
bool shouldRepaint(_WaveformPainter old) =>
old.progress != progress ||
old.active != active ||
old.inactive != inactive ||
!identical(old.amps, amps);
}
@@ -9,7 +9,8 @@ import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
const Color _kAccent = Color(0xFF2F8FFF);
import '../../../core/config/app_colors.dart';
const Color _kBar = Color(0xFF1E1E1E);
class MediaPreviewScreen extends StatefulWidget {
@@ -22,6 +23,7 @@ class MediaPreviewScreen extends StatefulWidget {
final ValueChanged<PhotoEditState>? onEditChanged;
final String initialCaption;
final ValueChanged<String>? onCaptionChanged;
final Set<String> tempFiles;
const MediaPreviewScreen({
super.key,
@@ -29,6 +31,7 @@ class MediaPreviewScreen extends StatefulWidget {
required this.selectedIds,
required this.onToggleSelection,
required this.onSend,
required this.tempFiles,
this.title,
this.editState,
this.onEditChanged,
@@ -51,9 +54,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
@override
void initState() {
super.initState();
_caption.addListener(
() => widget.onCaptionChanged?.call(_caption.text),
);
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
_cropState = widget.editState?.cropState;
_cropSource = widget.editState?.cropSource;
_resolveWorkingFile();
@@ -104,14 +105,14 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
void _disposeTemp(File? file, Set<String> keep) {
if (file == null || keep.contains(file.path)) return;
if (!file.uri.pathSegments.last.startsWith('komet_')) return;
if (!widget.tempFiles.remove(file.path)) return;
file.delete().then((_) {}, onError: (_) {});
}
Future<void> _openCrop() async {
if (_workingFile == null) return;
final source =
_cropSource ??= widget.item.localFile ?? await widget.item.originFile();
final source = _cropSource ??=
widget.item.localFile ?? await widget.item.originFile();
if (source == null || !mounted) return;
final result = await _pushEditor<CropResult>(
PhotoCropEditor(source: source, initialState: _cropState),
@@ -119,6 +120,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
if (result != null && mounted) {
final old = _workingFile;
_cropState = result.state;
widget.tempFiles.add(result.file.path);
setState(() => _workingFile = result.file);
_reportEdit();
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
@@ -135,17 +137,14 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
return;
}
final result = await _pushEditor<File>(
PhotoDrawEditor(
source: file,
imageWidth: dims.$1,
imageHeight: dims.$2,
),
PhotoDrawEditor(source: file, imageWidth: dims.$1, imageHeight: dims.$2),
);
if (result != null && mounted) {
final oldWorking = _workingFile;
final oldCropSource = _cropSource;
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _workingFile = result);
_reportEdit();
_disposeTemp(oldWorking, {result.path});
@@ -162,6 +161,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
final oldCropSource = _cropSource;
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _workingFile = result);
_reportEdit();
_disposeTemp(oldWorking, {result.path});
@@ -334,7 +334,7 @@ class _SelectionToggle extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? _kAccent : Colors.transparent,
color: isSelected ? kEditorAccent : Colors.transparent,
border: Border.all(color: Colors.white, width: 2),
),
child: isSelected
@@ -444,7 +444,7 @@ class _FileToggleState extends State<_FileToggle> {
builder: (context, t, _) {
final color = Color.lerp(
Colors.white54,
Color.lerp(Colors.white, _kAccent, 0.4),
Color.lerp(Colors.white, kEditorAccent, 0.4),
t,
);
return Icon(Symbols.description, color: color, size: 24);
@@ -462,7 +462,7 @@ class _SendButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
color: _kAccent,
color: kEditorAccent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
+133 -129
View File
@@ -6,12 +6,14 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:komet/core/utils/image_utils.dart';
import 'package:komet/core/media/raster.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import '../../../core/config/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../small_spinner.dart';
const Color _kPanel = Color(0xFF0A0A0A);
class CropState {
@@ -329,7 +331,10 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(context, 'Не удалось применить');
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyFailed,
);
return;
}
Navigator.of(context).pop(CropResult(file, state));
@@ -374,23 +379,7 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
Paint()..filterQuality = FilterQuality.high,
);
final picture = recorder.endRecording();
final rendered = await picture.toImage(pxW, pxH);
picture.dispose();
final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
rendered.dispose();
if (bd == null) return null;
final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), pxW, pxH);
if (jpeg == null) return null;
final dir = await getTemporaryDirectory();
final out = File(
p.join(
dir.path,
'komet_crop_${DateTime.now().microsecondsSinceEpoch}.jpg',
),
);
await out.writeAsBytes(jpeg);
return out;
return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop');
} catch (_) {
return null;
}
@@ -447,6 +436,7 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
}
Widget _buildTools() {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
@@ -455,9 +445,9 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
onPressed: _flip,
icon: Icon(
Symbols.flip,
color: _flipH ? const Color(0xFF2F8FFF) : Colors.white,
color: _flipH ? kEditorAccent : Colors.white,
),
tooltip: 'Отразить',
tooltip: l10n.photoEditorFlipTooltip,
),
Expanded(
child: ValueListenableBuilder<int>(
@@ -477,7 +467,7 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
Symbols.rotate_90_degrees_ccw,
color: Colors.white,
),
tooltip: 'Повернуть',
tooltip: l10n.photoEditorRotateTooltip,
),
],
),
@@ -485,6 +475,7 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
}
Widget _buildActions() {
final l10n = AppLocalizations.of(context)!;
return Container(
color: _kPanel,
padding: const EdgeInsets.symmetric(vertical: 6),
@@ -493,24 +484,24 @@ class _PhotoCropEditorState extends State<PhotoCropEditor> {
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'ОТМЕНА',
style: TextStyle(color: Colors.white, fontSize: 15),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
TextButton(
onPressed: _reset,
child: const Text(
'СБРОС',
style: TextStyle(color: Colors.white, fontSize: 15),
child: Text(
l10n.photoEditorReset,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
TextButton(
onPressed: _baking ? null : _done,
child: Text(
'ГОТОВО',
l10n.photoEditorDone,
style: TextStyle(
color: _baking ? Colors.white38 : const Color(0xFF2F8FFF),
color: _baking ? Colors.white38 : kEditorAccent,
fontSize: 15,
fontWeight: FontWeight.w600,
),
@@ -647,7 +638,7 @@ class _RulerPainter extends CustomPainter {
Offset(cx, baseY - 18),
Offset(cx, baseY + 2),
Paint()
..color = const Color(0xFF2F8FFF)
..color = kEditorAccent
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
@@ -898,6 +889,7 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
}
Future<void> _addText() async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final String? text;
try {
@@ -905,26 +897,29 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Текст', style: TextStyle(color: Colors.white)),
title: Text(
l10n.photoEditorTextDialogTitle,
style: const TextStyle(color: Colors.white),
),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
cursorColor: Colors.white,
decoration: const InputDecoration(
hintText: 'Введите текст',
hintStyle: TextStyle(color: Colors.white38),
decoration: InputDecoration(
hintText: l10n.photoEditorTextDialogHint,
hintStyle: const TextStyle(color: Colors.white38),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
child: Text(l10n.spoofDialogCancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('ОК'),
child: Text(l10n.photoEditorOk),
),
],
),
@@ -958,7 +953,10 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(context, 'Не удалось применить изменения');
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyChangesFailed,
);
return;
}
Navigator.of(context).pop(file);
@@ -995,29 +993,7 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
image.dispose();
codec.dispose();
final rendered = await picture.toImage(outW, outH);
picture.dispose();
final byteData = await rendered.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
rendered.dispose();
if (byteData == null) return null;
final jpeg = await encodeRgbaToJpeg(
byteData.buffer.asUint8List(),
outW,
outH,
);
if (jpeg == null) return null;
final dir = await getTemporaryDirectory();
final out = File(
p.join(
dir.path,
'komet_edit_${DateTime.now().microsecondsSinceEpoch}.jpg',
),
);
await out.writeAsBytes(jpeg);
return out;
return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit');
} catch (_) {
return null;
}
@@ -1037,15 +1013,7 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
],
),
if (_tab == _EditTab.draw) _buildSideSlider(),
if (_baking)
const Positioned.fill(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
),
if (_baking) const BusyOverlay(),
],
),
);
@@ -1068,7 +1036,7 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
TextButton(
onPressed: _marks.isEmpty ? null : _clearAll,
child: Text(
'Очистить всё',
AppLocalizations.of(context)!.photoEditorClearAll,
style: TextStyle(
color: _marks.isEmpty ? Colors.white24 : Colors.white,
fontSize: 15,
@@ -1244,9 +1212,9 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
TextButton.icon(
onPressed: _addText,
icon: const Icon(Symbols.add, color: Colors.white),
label: const Text(
'Добавить текст',
style: TextStyle(color: Colors.white, fontSize: 15),
label: Text(
AppLocalizations.of(context)!.photoEditorAddText,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
const Spacer(),
@@ -1271,9 +1239,9 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
colors: [
Color(0xFFFF3B30),
Color(0xFFFFCC00),
Color(0xFF34C759),
kOnlineGreen,
Color(0xFF00C7BE),
Color(0xFF2F8FFF),
kEditorAccent,
Color(0xFFAF52DE),
Color(0xFFFF3B30),
],
@@ -1356,6 +1324,7 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
}
Widget _buildTabs() {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 48,
child: Row(
@@ -1368,9 +1337,13 @@ class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTab('РИСУНОК', _EditTab.draw),
_buildTab('СТИКЕРЫ', _EditTab.stickers, disabled: true),
_buildTab('ТЕКСТ', _EditTab.text),
_buildTab(l10n.photoEditorTabDraw, _EditTab.draw),
_buildTab(
l10n.photoEditorTabStickers,
_EditTab.stickers,
disabled: true,
),
_buildTab(l10n.photoEditorTabText, _EditTab.text),
],
),
),
@@ -1703,7 +1676,7 @@ class _SelectionPainter extends CustomPainter {
_dashedLine(canvas, bl, tl, border);
final fill = Paint()
..color = const Color(0xFF2F8FFF)
..color = kEditorAccent
..style = PaintingStyle.fill;
final ring = Paint()
..color = Colors.white
@@ -1888,8 +1861,6 @@ class _ColorPickerState extends State<_ColorPicker> {
}
}
const Color _kAccent = Color(0xFF2F8FFF);
enum BlurMode { off, radial, linear }
enum _Tab { adjust, blur, curves }
@@ -2320,24 +2291,15 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
}
final picture = recorder.endRecording();
final rendered = await picture.toImage(outW, outH);
picture.dispose();
if (curved != img) curved.dispose();
final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
rendered.dispose();
if (bd == null) return null;
final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), outW, outH);
if (jpeg == null) return null;
final dir = await getTemporaryDirectory();
final out = File(
p.join(
dir.path,
'komet_adj_${DateTime.now().microsecondsSinceEpoch}.jpg',
),
return await rasterPictureToJpegFile(
picture,
outW,
outH,
prefix: 'adj',
onPictureDisposed: () {
if (curved != img) curved.dispose();
},
);
await out.writeAsBytes(jpeg);
return out;
} catch (_) {
return null;
}
@@ -2354,7 +2316,10 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(context, 'Не удалось применить');
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyFailed,
);
return;
}
Navigator.of(context).pop(file);
@@ -2374,15 +2339,7 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
_buildBottomBar(),
],
),
if (_baking)
const Positioned.fill(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
),
if (_baking) const BusyOverlay(),
],
),
),
@@ -2516,7 +2473,13 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
}
Widget _buildCurves() {
const labels = ['Все', 'Красный', 'Зелёный', 'Синий'];
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.photoEditorChannelAll,
l10n.photoEditorChannelRed,
l10n.photoEditorChannelGreen,
l10n.photoEditorChannelBlue,
];
return SizedBox(
height: 110,
child: Row(
@@ -2573,23 +2536,54 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
return ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_slider('Улучшение', _enhance, 0, 1, (v) => _enhance = v),
_slider('Экспозиция', _exposure, -1, 1, (v) => _exposure = v),
_slider('Контраст', _contrast, -1, 1, (v) => _contrast = v),
_slider(
'Насыщенность',
l10n.photoEditorEnhance,
_enhance,
0,
1,
(v) => _enhance = v,
),
_slider(
l10n.photoEditorExposure,
_exposure,
-1,
1,
(v) => _exposure = v,
),
_slider(
l10n.photoEditorContrast,
_contrast,
-1,
1,
(v) => _contrast = v,
),
_slider(
l10n.photoEditorSaturation,
_saturation,
-1,
1,
(v) => _saturation = v,
),
_slider('Тёплость', _warmth, -1, 1, (v) => _warmth = v),
_slider('Виньетка', _vignette, 0, 1, (v) => _vignette = v),
_slider(
l10n.photoEditorWarmth,
_warmth,
-1,
1,
(v) => _warmth = v,
),
_slider(
l10n.photoEditorVignette,
_vignette,
0,
1,
(v) => _vignette = v,
),
],
),
);
@@ -2640,14 +2634,23 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
}
Widget _buildBlurOptions() {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 110,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_blurOption('Откл.', Symbols.block, BlurMode.off),
_blurOption('Радиальное', Symbols.blur_circular, BlurMode.radial),
_blurOption('Линейное', Symbols.blur_linear, BlurMode.linear),
_blurOption(l10n.photoEditorBlurOff, Symbols.block, BlurMode.off),
_blurOption(
l10n.photoEditorBlurRadial,
Symbols.blur_circular,
BlurMode.radial,
),
_blurOption(
l10n.photoEditorBlurLinear,
Symbols.blur_linear,
BlurMode.linear,
),
],
),
);
@@ -2661,12 +2664,12 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: selected ? _kAccent : Colors.white, size: 30),
Icon(icon, color: selected ? kEditorAccent : Colors.white, size: 30),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
color: selected ? _kAccent : Colors.white70,
color: selected ? kEditorAccent : Colors.white70,
fontSize: 12,
),
),
@@ -2676,6 +2679,7 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
}
Widget _buildBottomBar() {
final l10n = AppLocalizations.of(context)!;
return Container(
color: _kPanel,
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -2683,9 +2687,9 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'ОТМЕНА',
style: TextStyle(color: Colors.white, fontSize: 15),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
const Spacer(),
@@ -2698,9 +2702,9 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
TextButton(
onPressed: _baking ? null : _done,
child: Text(
'ГОТОВО',
l10n.photoEditorDone,
style: TextStyle(
color: _baking ? Colors.white38 : _kAccent,
color: _baking ? Colors.white38 : kEditorAccent,
fontSize: 15,
fontWeight: FontWeight.w600,
),
@@ -2716,7 +2720,7 @@ class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
return IconButton(
onPressed: disabled ? null : () => setState(() => _tab = tab),
icon: Icon(icon),
color: selected ? _kAccent : Colors.white,
color: selected ? kEditorAccent : Colors.white,
disabledColor: Colors.white24,
);
}