небольшие изменения
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user