Update Qlyra to 1.0.13 with messaging, media and plugin improvements
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -22,6 +22,7 @@ import 'package:qlyra/frontend/widgets/attachment/photo_editor.dart';
|
||||
import 'package:qlyra/frontend/widgets/attachment/photo_hero.dart';
|
||||
import 'package:qlyra/frontend/widgets/attachment/video_edit.dart';
|
||||
import 'package:qlyra/frontend/widgets/attachment/video_preview_screen.dart';
|
||||
import 'package:qlyra/frontend/widgets/chat_menu_overlay.dart';
|
||||
import 'package:qlyra/frontend/widgets/custom_notification.dart';
|
||||
import 'package:qlyra/frontend/widgets/sheet_helpers.dart';
|
||||
import 'package:qlyra/frontend/widgets/sliding_pill_nav.dart';
|
||||
@@ -39,10 +40,14 @@ List<PillNavItem> _buildNavItems(AppLocalizations l10n) => [
|
||||
PillNavItem(icon: Symbols.person, label: l10n.attachSheetContact),
|
||||
];
|
||||
|
||||
typedef PickedPhotosCallback =
|
||||
void Function(List<PickedPhoto> photos, String caption);
|
||||
|
||||
Future<void> showAttachmentSheet(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
void Function(List<PickedPhoto> photos, String caption)? onSend,
|
||||
PickedPhotosCallback? onSend,
|
||||
PickedPhotosCallback? onSendSeparately,
|
||||
VoidCallback? onPickFile,
|
||||
VoidCallback? onShareLocation,
|
||||
VoidCallback? onCreatePoll,
|
||||
@@ -57,6 +62,7 @@ Future<void> showAttachmentSheet(
|
||||
builder: (_) => AttachmentSheet(
|
||||
title: title,
|
||||
onSend: onSend,
|
||||
onSendSeparately: onSendSeparately,
|
||||
onPickFile: onPickFile,
|
||||
onShareLocation: onShareLocation,
|
||||
onCreatePoll: onCreatePoll,
|
||||
@@ -67,7 +73,8 @@ Future<void> showAttachmentSheet(
|
||||
|
||||
class AttachmentSheet extends StatefulWidget {
|
||||
final String? title;
|
||||
final void Function(List<PickedPhoto> photos, String caption)? onSend;
|
||||
final PickedPhotosCallback? onSend;
|
||||
final PickedPhotosCallback? onSendSeparately;
|
||||
final VoidCallback? onPickFile;
|
||||
final VoidCallback? onShareLocation;
|
||||
final VoidCallback? onCreatePoll;
|
||||
@@ -77,6 +84,7 @@ class AttachmentSheet extends StatefulWidget {
|
||||
super.key,
|
||||
this.title,
|
||||
this.onSend,
|
||||
this.onSendSeparately,
|
||||
this.onPickFile,
|
||||
this.onShareLocation,
|
||||
this.onCreatePoll,
|
||||
@@ -379,7 +387,10 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
return ok;
|
||||
}
|
||||
|
||||
Future<void> _sendSelection({GalleryItem? fallback}) async {
|
||||
Future<void> _sendSelection({
|
||||
GalleryItem? fallback,
|
||||
bool separately = false,
|
||||
}) async {
|
||||
if (_exporting) return;
|
||||
final ids = _selected.value;
|
||||
var chosen = _items.where((it) => ids.contains(it.id)).toList();
|
||||
@@ -396,7 +407,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final callback = widget.onSend;
|
||||
final callback = separately ? widget.onSendSeparately : widget.onSend;
|
||||
if (callback != null) {
|
||||
for (final photo in picked) {
|
||||
final path = photo.editedFile?.path;
|
||||
@@ -429,7 +440,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
const SheetGrabber(),
|
||||
_buildGrabberBar(cs),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -875,6 +886,44 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrabberBar(ColorScheme cs) {
|
||||
if (widget.onSendSeparately == null) return const SheetGrabber();
|
||||
return SheetGrabberBar(
|
||||
action: AnimatedBuilder(
|
||||
animation: Listenable.merge([_selected, _pageController]),
|
||||
builder: (context, child) {
|
||||
final galleryT = (1 - _currentPageT()).clamp(0.0, 1.0);
|
||||
if (_selected.value.isEmpty || galleryT == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Opacity(
|
||||
opacity: galleryT,
|
||||
child: IgnorePointer(ignoring: galleryT < 0.5, child: child),
|
||||
);
|
||||
},
|
||||
child: _GalleryMenuButton(cs: cs, onTap: _openGalleryMenu),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openGalleryMenu(BuildContext anchorContext) {
|
||||
if (widget.onSendSeparately == null) return;
|
||||
final box = anchorContext.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
showChatMenu(
|
||||
context: context,
|
||||
anchorRect: box.localToGlobal(Offset.zero) & box.size,
|
||||
compact: true,
|
||||
items: [
|
||||
ChatMenuItem(
|
||||
icon: Symbols.arrow_split,
|
||||
label: AppLocalizations.of(context)!.attachSheetSendSeparately,
|
||||
onTap: () => _sendSelection(separately: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(ColorScheme cs) {
|
||||
return Material(
|
||||
color: cs.primary,
|
||||
@@ -1044,6 +1093,36 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryMenuButton extends StatelessWidget {
|
||||
final ColorScheme cs;
|
||||
final void Function(BuildContext anchorContext) onTap;
|
||||
|
||||
const _GalleryMenuButton({required this.cs, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: AppLocalizations.of(context)!.attachSheetMoreActions,
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Icon(
|
||||
Symbols.more_horiz,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlivePage extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ class BubblePresentation {
|
||||
class BubbleContext {
|
||||
static const double photoMaxSize = 280.0;
|
||||
static const double photoMinSize = 100.0;
|
||||
static const double captionedMediaMinWidth = 200.0;
|
||||
static const double photoBorderRadius = 12.0;
|
||||
static const double bubbleBorderRadius = 20.0;
|
||||
static const double captionPaddingHorizontal = 10.0;
|
||||
|
||||
@@ -11,8 +11,12 @@ import '../../../../core/utils/file_download.dart';
|
||||
import '../../../../core/utils/media_cache.dart';
|
||||
import '../../../../core/utils/format.dart';
|
||||
import '../../../../core/utils/haptics.dart';
|
||||
import '../../../../core/media/audio_file_track.dart';
|
||||
import '../../../../core/media/audio_playback_controller.dart';
|
||||
import '../../../../core/media/media_playback.dart';
|
||||
import '../../../../core/crypto/chat_crypto_service.dart';
|
||||
import '../../../../core/crypto/encrypted_photo_cache.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../custom_notification.dart';
|
||||
import '../../decrypted_photo.dart';
|
||||
@@ -43,10 +47,12 @@ class FileBubble extends StatelessWidget {
|
||||
final sizeStr = formatBytes(size);
|
||||
final fileId = file.fileId;
|
||||
final cacheName = '${fileId}_$name';
|
||||
final audioFile = downloadKindForName(name) == DownloadKind.audio;
|
||||
|
||||
final preview = file.preview;
|
||||
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
|
||||
final previewWidget = _preview(
|
||||
name: name,
|
||||
cacheName: cacheName,
|
||||
previewUrl: previewUrl,
|
||||
encrypted: fileId != null && _isEncryptedImage(name),
|
||||
@@ -72,7 +78,7 @@ class FileBubble extends StatelessWidget {
|
||||
),
|
||||
child: ctx.uploadProgress == null
|
||||
? Icon(
|
||||
Symbols.description,
|
||||
audioFile ? Symbols.audio_file : Symbols.description,
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer
|
||||
: ctx.cs.primary,
|
||||
@@ -165,19 +171,50 @@ class FileBubble extends StatelessWidget {
|
||||
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: MediaCache.presence(cacheName),
|
||||
builder: (context, cached, _) => circle(
|
||||
Icon(
|
||||
cached ? Symbols.check : Symbols.download,
|
||||
color: iconColor,
|
||||
size: 18,
|
||||
),
|
||||
() => _downloadFile(ctx.context, file, name),
|
||||
),
|
||||
builder: (context, cached, _) {
|
||||
if (!audioFile) {
|
||||
return circle(
|
||||
Icon(
|
||||
cached ? Symbols.check : Symbols.download,
|
||||
color: iconColor,
|
||||
size: 18,
|
||||
),
|
||||
() => _downloadFile(ctx.context, file, name),
|
||||
);
|
||||
}
|
||||
Widget button(IconData icon) => circle(
|
||||
Icon(icon, color: iconColor, size: 18, fill: 1),
|
||||
() => _playAudioFile(ctx.context, file, name),
|
||||
);
|
||||
return ValueListenableBuilder<AudioFileTrack?>(
|
||||
valueListenable: MediaPlayback.instance.audioFile,
|
||||
builder: (context, track, _) {
|
||||
if (!cached) return button(Symbols.download);
|
||||
if (track?.cacheName != cacheName ||
|
||||
!AudioPlaybackController.isInitialized) {
|
||||
return button(Symbols.play_arrow);
|
||||
}
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable:
|
||||
AudioPlaybackController.instance.playing,
|
||||
builder: (context, playing, _) => button(
|
||||
playing ? Symbols.pause : Symbols.play_arrow,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (audioFile)
|
||||
_AudioFilePlaybackControl(
|
||||
cacheName: cacheName,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
textColor: ctx.dim,
|
||||
),
|
||||
ctx.meta(),
|
||||
],
|
||||
),
|
||||
@@ -191,9 +228,26 @@ class FileBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static const Set<String> _coverExtensions = {
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
};
|
||||
|
||||
static bool _isViewableImage(String name) =>
|
||||
name.toLowerCase().endsWith('.png');
|
||||
|
||||
static bool _hasCover(String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return _coverExtensions.contains(name.substring(dot).toLowerCase());
|
||||
}
|
||||
|
||||
bool _isEncryptedImage(String name) =>
|
||||
_isViewableImage(name) &&
|
||||
ChatCryptoService.instance.isEnabled(
|
||||
@@ -202,6 +256,7 @@ class FileBubble extends StatelessWidget {
|
||||
);
|
||||
|
||||
Widget? _preview({
|
||||
required String name,
|
||||
required String cacheName,
|
||||
required String previewUrl,
|
||||
required bool encrypted,
|
||||
@@ -216,7 +271,7 @@ class FileBubble extends StatelessWidget {
|
||||
builder: (view) => _encryptedPreview(view, previewUrl),
|
||||
);
|
||||
}
|
||||
if (previewUrl.isEmpty) return null;
|
||||
if (previewUrl.isEmpty || !_hasCover(name)) return null;
|
||||
return _networkPreview(previewUrl);
|
||||
}
|
||||
|
||||
@@ -255,11 +310,16 @@ class FileBubble extends StatelessWidget {
|
||||
Widget _networkPreview(String url) => _framed(
|
||||
CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: _previewWidth,
|
||||
height: _previewHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 480,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
imageBuilder: (context, image) => Image(
|
||||
image: image,
|
||||
width: _previewWidth,
|
||||
height: _previewHeight,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
placeholder: (_, _) =>
|
||||
const SizedBox(width: _previewWidth, height: _previewHeight),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
@@ -294,6 +354,9 @@ class FileBubble extends StatelessWidget {
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(10), child: child),
|
||||
);
|
||||
|
||||
String? get _thumbnailUrl =>
|
||||
file.preview?.baseUrl ?? file.preview?.previewData ?? file.previewData;
|
||||
|
||||
Future<String?> _fileUrl() {
|
||||
final fileId = file.fileId;
|
||||
if (fileId == null) return Future.value(null);
|
||||
@@ -340,13 +403,10 @@ class FileBubble extends StatelessWidget {
|
||||
await DownloadHistory.record(
|
||||
DownloadMetadata(
|
||||
cacheName: cacheName,
|
||||
name: kind == DownloadKind.file ? name : '',
|
||||
name: name,
|
||||
kind: kind,
|
||||
sourceName: ctx.chatName ?? '',
|
||||
thumbnailUrl:
|
||||
file.preview?.baseUrl ??
|
||||
file.preview?.previewData ??
|
||||
file.previewData,
|
||||
thumbnailUrl: _thumbnailUrl,
|
||||
expectedSize: file.size ?? 0,
|
||||
chatId: ctx.message.chatId,
|
||||
messageId: ctx.message.id,
|
||||
@@ -433,13 +493,10 @@ class FileBubble extends StatelessWidget {
|
||||
},
|
||||
download: DownloadMetadata(
|
||||
cacheName: cacheName,
|
||||
name: kind == DownloadKind.file ? name : '',
|
||||
name: name,
|
||||
kind: kind,
|
||||
sourceName: ctx.chatName ?? '',
|
||||
thumbnailUrl:
|
||||
file.preview?.baseUrl ??
|
||||
file.preview?.previewData ??
|
||||
file.previewData,
|
||||
thumbnailUrl: _thumbnailUrl,
|
||||
expectedSize: file.size ?? 0,
|
||||
chatId: ctx.message.chatId,
|
||||
messageId: ctx.message.id,
|
||||
@@ -454,4 +511,174 @@ class FileBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playAudioFile(
|
||||
BuildContext context,
|
||||
FileAttachment file,
|
||||
String name,
|
||||
) async {
|
||||
final fileId = file.fileId;
|
||||
if (fileId == null) {
|
||||
showCustomNotification(context, 'Не удалось определить файл');
|
||||
return;
|
||||
}
|
||||
Haptics.tap();
|
||||
final cacheName = '${fileId}_$name';
|
||||
final playback = MediaPlayback.instance;
|
||||
if (playback.audioFile.value?.cacheName == cacheName &&
|
||||
AudioPlaybackController.isInitialized) {
|
||||
await AudioPlaybackController.instance.toggle();
|
||||
return;
|
||||
}
|
||||
final cached = (await MediaCache.existing(cacheName)) != null;
|
||||
if (!cached) MediaDownloadProgress.set(cacheName, 0);
|
||||
final result = await ensureCachedFile(
|
||||
cacheName,
|
||||
_fileUrl,
|
||||
onProgress: (value) => MediaDownloadProgress.set(cacheName, value),
|
||||
onReady: () {
|
||||
if (!cached) MediaDownloadProgress.set(cacheName, null);
|
||||
},
|
||||
download: DownloadMetadata(
|
||||
cacheName: cacheName,
|
||||
name: name,
|
||||
kind: DownloadKind.audio,
|
||||
sourceName: ctx.chatName ?? '',
|
||||
thumbnailUrl: _thumbnailUrl,
|
||||
expectedSize: file.size ?? 0,
|
||||
chatId: ctx.message.chatId,
|
||||
messageId: ctx.message.id,
|
||||
messageTime: ctx.message.time,
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (!result.ok || result.path == null) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Ошибка загрузки: ${result.error ?? 'не удалось загрузить'}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await playback.activateAudioFile(
|
||||
AudioFileTrack(
|
||||
cacheName: cacheName,
|
||||
path: result.path!,
|
||||
name: name,
|
||||
sourceName: ctx.chatName ?? '',
|
||||
chatId: ctx.message.chatId,
|
||||
messageId: ctx.message.id,
|
||||
messageTime: ctx.message.time,
|
||||
thumbnailUrl: _thumbnailUrl,
|
||||
),
|
||||
notificationChannelName: l10n.audioPlaybackChannel,
|
||||
);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
showCustomNotification(context, l10n.audioPlaybackFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioFilePlaybackControl extends StatelessWidget {
|
||||
const _AudioFilePlaybackControl({
|
||||
required this.cacheName,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
final String cacheName;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AudioFileTrack?>(
|
||||
valueListenable: MediaPlayback.instance.audioFile,
|
||||
builder: (context, track, _) =>
|
||||
track?.cacheName != cacheName ||
|
||||
!AudioPlaybackController.isInitialized
|
||||
? const SizedBox.shrink()
|
||||
: _AudioFileScrubber(color: color, textColor: textColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioFileScrubber extends StatefulWidget {
|
||||
const _AudioFileScrubber({required this.color, required this.textColor});
|
||||
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
State<_AudioFileScrubber> createState() => _AudioFileScrubberState();
|
||||
}
|
||||
|
||||
class _AudioFileScrubberState extends State<_AudioFileScrubber> {
|
||||
double? _dragMilliseconds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audio = AudioPlaybackController.instance;
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([audio.position, audio.duration]),
|
||||
builder: (context, _) {
|
||||
final total = audio.duration.value.inMilliseconds;
|
||||
final elapsed = _dragMilliseconds != null
|
||||
? _dragMilliseconds!.round()
|
||||
: audio.position.value.inMilliseconds.clamp(
|
||||
0,
|
||||
total > 0 ? total : 0,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatSecondsMmSs(elapsed ~/ 1000),
|
||||
style: TextStyle(color: widget.textColor, fontSize: 10),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: widget.color,
|
||||
inactiveTrackColor: widget.color.withValues(alpha: 0.2),
|
||||
thumbColor: widget.color,
|
||||
overlayColor: widget.color.withValues(alpha: 0.12),
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 5,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 12,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: total > 0 ? total.toDouble() : 1,
|
||||
value: total > 0 ? elapsed.toDouble() : 0,
|
||||
onChanged: total > 0
|
||||
? (next) => setState(() => _dragMilliseconds = next)
|
||||
: null,
|
||||
onChangeEnd: total > 0
|
||||
? (next) {
|
||||
setState(() => _dragMilliseconds = null);
|
||||
audio.seek(Duration(milliseconds: next.round()));
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatSecondsMmSs(audio.duration.value.inSeconds),
|
||||
style: TextStyle(color: widget.textColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../../core/media/preview_image.dart';
|
||||
import '../../../../core/utils/format.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
import '../photo_hero.dart';
|
||||
import '../../text_with_meta.dart';
|
||||
import 'bubble_context.dart';
|
||||
import 'video_bubble.dart';
|
||||
|
||||
class PhotoBubble extends StatelessWidget {
|
||||
static const Radius _bigRadius = Radius.circular(
|
||||
@@ -21,24 +25,63 @@ class PhotoBubble extends StatelessWidget {
|
||||
);
|
||||
|
||||
final BubbleContext ctx;
|
||||
final List<PhotoAttachment> photos;
|
||||
final List<MessageAttachment> media;
|
||||
final bool hasContentAbove;
|
||||
|
||||
const PhotoBubble({
|
||||
super.key,
|
||||
required this.ctx,
|
||||
required this.photos,
|
||||
required this.media,
|
||||
this.hasContentAbove = false,
|
||||
});
|
||||
|
||||
static double layoutWidth(List<PhotoAttachment> photos) {
|
||||
if (photos.length != 1) return BubbleContext.photoMaxSize;
|
||||
return _displaySize(photos.single).width;
|
||||
// #***! альбом собирает фото и обычные видео, кружки живут отдельно
|
||||
static bool isAlbumMedia(MessageAttachment item) =>
|
||||
item is PhotoAttachment || (item is VideoAttachment && !item.isNote);
|
||||
|
||||
static int? _intrinsicWidth(MessageAttachment item) => switch (item) {
|
||||
PhotoAttachment(:final width) => width,
|
||||
VideoAttachment(:final width) => width,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static int? _intrinsicHeight(MessageAttachment item) => switch (item) {
|
||||
PhotoAttachment(:final height) => height,
|
||||
VideoAttachment(:final height) => height,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static String? _localPathOf(MessageAttachment item) => switch (item) {
|
||||
PhotoAttachment(:final localPath) => localPath,
|
||||
VideoAttachment(:final localPath) => localPath,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// #***! у видео обложка отдельным полем, baseUrl это уже сам файл
|
||||
static String _previewUrlOf(MessageAttachment item) {
|
||||
if (item is VideoAttachment) {
|
||||
final thumb = item.thumbnail;
|
||||
if (thumb != null && thumb.isNotEmpty) return thumb;
|
||||
return item.baseUrl ?? '';
|
||||
}
|
||||
if (item is PhotoAttachment) return item.baseUrl ?? '';
|
||||
return '';
|
||||
}
|
||||
|
||||
static Size _displaySize(PhotoAttachment photo) {
|
||||
final width = photo.width?.toDouble() ?? 200;
|
||||
final height = photo.height?.toDouble() ?? 200;
|
||||
static double layoutWidth(
|
||||
List<MessageAttachment> media, {
|
||||
bool hasCaption = false,
|
||||
}) {
|
||||
if (media.length != 1) return BubbleContext.photoMaxSize;
|
||||
return _displaySize(media.single, hasCaption: hasCaption).width;
|
||||
}
|
||||
|
||||
static Size _displaySize(MessageAttachment item, {bool hasCaption = false}) {
|
||||
final minWidth = hasCaption
|
||||
? BubbleContext.captionedMediaMinWidth
|
||||
: BubbleContext.photoMinSize;
|
||||
final width = _intrinsicWidth(item)?.toDouble() ?? 200;
|
||||
final height = _intrinsicHeight(item)?.toDouble() ?? 200;
|
||||
|
||||
final downScale = math.min(
|
||||
1.0,
|
||||
@@ -53,7 +96,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
final upScale = math.max(
|
||||
1.0,
|
||||
math.max(
|
||||
BubbleContext.photoMinSize / displayWidth,
|
||||
minWidth / displayWidth,
|
||||
BubbleContext.photoMinSize / displayHeight,
|
||||
),
|
||||
);
|
||||
@@ -61,10 +104,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
displayHeight *= upScale;
|
||||
|
||||
return Size(
|
||||
displayWidth.clamp(
|
||||
BubbleContext.photoMinSize,
|
||||
BubbleContext.photoMaxSize,
|
||||
),
|
||||
displayWidth.clamp(minWidth, BubbleContext.photoMaxSize),
|
||||
displayHeight.clamp(
|
||||
BubbleContext.photoMinSize,
|
||||
BubbleContext.photoMaxSize,
|
||||
@@ -77,22 +117,22 @@ class PhotoBubble extends StatelessWidget {
|
||||
final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false;
|
||||
final resolvedCaption = hasMessageCaption ? ctx.caption() : null;
|
||||
final hasCaption = resolvedCaption != null;
|
||||
final count = photos.length;
|
||||
final count = media.length;
|
||||
|
||||
Widget photosWidget;
|
||||
if (count == 1) {
|
||||
photosWidget = _buildSinglePhoto(
|
||||
ctx,
|
||||
photos[0],
|
||||
media[0],
|
||||
hasCaption: hasCaption,
|
||||
hasContentAbove: hasContentAbove,
|
||||
);
|
||||
} else if (count == 2) {
|
||||
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
|
||||
photosWidget = _buildTwoPhotos(ctx, media[0], media[1]);
|
||||
} else if (count == 3) {
|
||||
photosWidget = _buildThreePhotos(ctx, photos);
|
||||
photosWidget = _buildThreePhotos(ctx, media);
|
||||
} else {
|
||||
photosWidget = _buildPhotoGrid(ctx, photos);
|
||||
photosWidget = _buildPhotoGrid(ctx, media);
|
||||
}
|
||||
|
||||
if (!hasCaption) {
|
||||
@@ -110,7 +150,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
if (count == 1) {
|
||||
return SizedBox(
|
||||
width: layoutWidth(photos),
|
||||
width: layoutWidth(media, hasCaption: true),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -123,12 +163,10 @@ class PhotoBubble extends StatelessWidget {
|
||||
top: BubbleContext.captionPaddingTop,
|
||||
bottom: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: resolvedCaption),
|
||||
ctx.meta(),
|
||||
],
|
||||
child: TextWithMeta(
|
||||
text: resolvedCaption,
|
||||
meta: ctx.meta(),
|
||||
fillWidth: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -148,12 +186,10 @@ class PhotoBubble extends StatelessWidget {
|
||||
top: BubbleContext.captionPaddingTop,
|
||||
bottom: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: resolvedCaption),
|
||||
ctx.meta(),
|
||||
],
|
||||
child: TextWithMeta(
|
||||
text: resolvedCaption,
|
||||
meta: ctx.meta(),
|
||||
fillWidth: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -162,11 +198,11 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildSinglePhoto(
|
||||
BubbleContext ctx,
|
||||
PhotoAttachment photo, {
|
||||
MessageAttachment photo, {
|
||||
required bool hasCaption,
|
||||
required bool hasContentAbove,
|
||||
}) {
|
||||
final size = _displaySize(photo);
|
||||
final size = _displaySize(photo, hasCaption: hasCaption);
|
||||
final constrainedWidth = size.width;
|
||||
final constrainedHeight = size.height;
|
||||
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||||
@@ -203,6 +239,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
memWidth: memWidth,
|
||||
memHeight: memHeight,
|
||||
),
|
||||
..._videoBadges(photo, compact: false),
|
||||
if (ctx.uploadProgress != null)
|
||||
_buildUploadOverlay(ctx.uploadProgress!, 0),
|
||||
if (ctx.uploadProgress == null)
|
||||
@@ -210,7 +247,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
child: Builder(
|
||||
builder: (tileContext) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(
|
||||
onTap: () => _openMedia(
|
||||
ctx.context,
|
||||
0,
|
||||
tileContext: tileContext,
|
||||
@@ -228,13 +265,13 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildPhotoImage(
|
||||
BubbleContext ctx,
|
||||
PhotoAttachment photo,
|
||||
MessageAttachment photo,
|
||||
double width,
|
||||
double height, {
|
||||
required int memWidth,
|
||||
required int memHeight,
|
||||
}) {
|
||||
final localPath = photo.localPath;
|
||||
final localPath = _localPathOf(photo);
|
||||
if (localPath != null) {
|
||||
return Image.file(
|
||||
File(localPath),
|
||||
@@ -247,8 +284,9 @@ class PhotoBubble extends StatelessWidget {
|
||||
_buildPhotoPlaceholder(ctx.cs, width, height),
|
||||
);
|
||||
}
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
if (imageUrl.isNotEmpty) {
|
||||
final embedded = dataUriImage(photo, photo.previewData);
|
||||
final imageUrl = _previewUrlOf(photo);
|
||||
if (imageUrl.isNotEmpty && !imageUrl.startsWith('data:')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
@@ -258,12 +296,72 @@ class PhotoBubble extends StatelessWidget {
|
||||
memCacheHeight: memHeight,
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
|
||||
errorWidget: (_, _, _) => embedded == null
|
||||
? _buildPhotoPlaceholder(ctx.cs, width, height)
|
||||
: Image(
|
||||
image: embedded,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (embedded != null) {
|
||||
return Image(
|
||||
image: embedded,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, width, height),
|
||||
);
|
||||
}
|
||||
return _buildPhotoPlaceholder(ctx.cs, width, height);
|
||||
}
|
||||
|
||||
// #***! плитка видео отличается от фото только кружком плеера и длительностью
|
||||
List<Widget> _videoBadges(MessageAttachment item, {required bool compact}) {
|
||||
if (item is! VideoAttachment) return const [];
|
||||
final side = compact ? 36.0 : 48.0;
|
||||
final durationMs = item.duration;
|
||||
return [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: side,
|
||||
height: side,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: side * 0.625,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildUploadOverlay(
|
||||
ValueListenable<List<double>> progress,
|
||||
int index,
|
||||
@@ -312,8 +410,8 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildTwoPhotos(
|
||||
BubbleContext ctx,
|
||||
PhotoAttachment p1,
|
||||
PhotoAttachment p2,
|
||||
MessageAttachment p1,
|
||||
MessageAttachment p2,
|
||||
) {
|
||||
final matchTop =
|
||||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
|
||||
@@ -337,7 +435,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreePhotos(BubbleContext ctx, List<PhotoAttachment> photos) {
|
||||
Widget _buildThreePhotos(BubbleContext ctx, List<MessageAttachment> photos) {
|
||||
final matchTop =
|
||||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
|
||||
final matchBottom =
|
||||
@@ -370,7 +468,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoGrid(BubbleContext ctx, List<PhotoAttachment> photos) {
|
||||
Widget _buildPhotoGrid(BubbleContext ctx, List<MessageAttachment> photos) {
|
||||
final displayCount = photos.length > 4 ? 4 : photos.length;
|
||||
final remaining = photos.length - 4;
|
||||
|
||||
@@ -409,7 +507,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildGridTile(
|
||||
BubbleContext ctx,
|
||||
List<PhotoAttachment> photos,
|
||||
List<MessageAttachment> photos,
|
||||
int index,
|
||||
int remaining,
|
||||
) {
|
||||
@@ -424,10 +522,13 @@ class PhotoBubble extends StatelessWidget {
|
||||
return _buildPhotoTile(ctx, photos[index], index);
|
||||
}
|
||||
|
||||
Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) =>
|
||||
AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index));
|
||||
Widget _buildPhotoTile(
|
||||
BubbleContext ctx,
|
||||
MessageAttachment photo,
|
||||
int index,
|
||||
) => AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index));
|
||||
|
||||
Widget _buildFillTile(BubbleContext ctx, PhotoAttachment photo, int index) {
|
||||
Widget _buildFillTile(BubbleContext ctx, MessageAttachment photo, int index) {
|
||||
final cachePx =
|
||||
(BubbleContext.photoMaxSize /
|
||||
2 *
|
||||
@@ -443,6 +544,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
memWidth: cachePx,
|
||||
memHeight: cachePx,
|
||||
),
|
||||
..._videoBadges(photo, compact: true),
|
||||
if (ctx.uploadProgress != null)
|
||||
_buildUploadOverlay(ctx.uploadProgress!, index),
|
||||
if (ctx.uploadProgress == null)
|
||||
@@ -456,7 +558,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
child: Builder(
|
||||
builder: (tileContext) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(
|
||||
onTap: () => _openMedia(
|
||||
ctx.context,
|
||||
index,
|
||||
tileContext: tileContext,
|
||||
@@ -471,7 +573,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildPhotoTileWithOverlay(
|
||||
BubbleContext ctx,
|
||||
PhotoAttachment photo,
|
||||
MessageAttachment photo,
|
||||
String overlay,
|
||||
int index,
|
||||
) {
|
||||
@@ -541,11 +643,11 @@ class PhotoBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
static ImageProvider? _photoProvider(
|
||||
PhotoAttachment photo, {
|
||||
MessageAttachment photo, {
|
||||
required int memWidth,
|
||||
required int memHeight,
|
||||
}) {
|
||||
final localPath = photo.localPath;
|
||||
final localPath = _localPathOf(photo);
|
||||
if (localPath != null) {
|
||||
return ResizeImage.resizeIfNeeded(
|
||||
memWidth,
|
||||
@@ -553,8 +655,10 @@ class PhotoBubble extends StatelessWidget {
|
||||
FileImage(File(localPath)),
|
||||
);
|
||||
}
|
||||
final url = photo.baseUrl ?? '';
|
||||
if (url.isEmpty) return null;
|
||||
final url = _previewUrlOf(photo);
|
||||
if (url.isEmpty || url.startsWith('data:')) {
|
||||
return dataUriImage(photo, photo.previewData);
|
||||
}
|
||||
return ResizeImage.resizeIfNeeded(
|
||||
memWidth,
|
||||
memHeight,
|
||||
@@ -562,14 +666,15 @@ class PhotoBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static Size? _photoSize(PhotoAttachment photo) {
|
||||
final width = photo.width ?? 0;
|
||||
final height = photo.height ?? 0;
|
||||
static Size? _photoSize(MessageAttachment photo) {
|
||||
final width = _intrinsicWidth(photo) ?? 0;
|
||||
final height = _intrinsicHeight(photo) ?? 0;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return Size(width.toDouble(), height.toDouble());
|
||||
}
|
||||
|
||||
void _openPhotoViewer(
|
||||
// #***! просмотрщик листает только фото, видео из альбома уходит в плеер
|
||||
void _openMedia(
|
||||
BuildContext context,
|
||||
int index, {
|
||||
required BuildContext tileContext,
|
||||
@@ -577,7 +682,13 @@ class PhotoBubble extends StatelessWidget {
|
||||
required int memWidth,
|
||||
required int memHeight,
|
||||
}) {
|
||||
final photo = photos[index];
|
||||
final photo = media[index];
|
||||
if (photo is VideoAttachment) {
|
||||
openVideoPlayer(ctx, photo);
|
||||
return;
|
||||
}
|
||||
final photos = media.whereType<PhotoAttachment>().toList();
|
||||
final photoIndex = photos.indexOf(photo as PhotoAttachment);
|
||||
final hero = PhotoHeroController(
|
||||
origin: () => photoHeroRectOf(tileContext),
|
||||
image: _photoProvider(photo, memWidth: memWidth, memHeight: memHeight),
|
||||
@@ -589,7 +700,7 @@ class PhotoBubble extends StatelessWidget {
|
||||
hero: hero,
|
||||
builder: (_) => PhotoViewerScreen(
|
||||
photos: photos,
|
||||
initialIndex: index,
|
||||
initialIndex: photoIndex < 0 ? 0 : photoIndex,
|
||||
chatId: ctx.chatId,
|
||||
message: ctx.message,
|
||||
actions: ctx.photoActions,
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../../models/attachment.dart';
|
||||
import '../../custom_notification.dart';
|
||||
import '../../upload_progress_ring.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
import '../../text_with_meta.dart';
|
||||
import 'bubble_context.dart';
|
||||
import 'video_note_bubble.dart';
|
||||
|
||||
@@ -19,9 +20,11 @@ class VideoBubble extends StatelessWidget {
|
||||
|
||||
const VideoBubble({super.key, required this.ctx, required this.video});
|
||||
|
||||
static double layoutWidth(VideoAttachment video) {
|
||||
static double layoutWidth(VideoAttachment video, {bool hasCaption = false}) {
|
||||
return (video.width?.toDouble() ?? 200.0).clamp(
|
||||
BubbleContext.photoMinSize,
|
||||
hasCaption
|
||||
? BubbleContext.captionedMediaMinWidth
|
||||
: BubbleContext.photoMinSize,
|
||||
BubbleContext.photoMaxSize,
|
||||
);
|
||||
}
|
||||
@@ -57,7 +60,7 @@ class VideoBubble extends StatelessWidget {
|
||||
: (video.previewData ?? '');
|
||||
|
||||
final h = video.height;
|
||||
final width = layoutWidth(video);
|
||||
final width = layoutWidth(video, hasCaption: hasCaption);
|
||||
final height = (h?.toDouble() ?? 150.0).clamp(
|
||||
BubbleContext.photoMinSize,
|
||||
BubbleContext.photoMaxSize,
|
||||
@@ -197,12 +200,10 @@ class VideoBubble extends StatelessWidget {
|
||||
top: BubbleContext.captionPaddingTop,
|
||||
bottom: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: resolvedCaption),
|
||||
ctx.meta(),
|
||||
],
|
||||
child: TextWithMeta(
|
||||
text: resolvedCaption,
|
||||
meta: ctx.meta(),
|
||||
fillWidth: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -210,39 +211,44 @@ class VideoBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
Future<void> _playVideo(BuildContext context, VideoAttachment video) =>
|
||||
openVideoPlayer(ctx, video);
|
||||
}
|
||||
|
||||
final sources = await messagesModule.getVideoSources(
|
||||
messageId: ctx.sourceMessageId,
|
||||
chatId: ctx.sourceChatId,
|
||||
token: token,
|
||||
videoId: videoId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (sources.isEmpty) {
|
||||
showCustomNotification(context, 'Не удалось получить видео');
|
||||
return;
|
||||
}
|
||||
// #***! общий вход в плеер, зовут и одиночное видео и плитка альбома
|
||||
Future<void> openVideoPlayer(BubbleContext ctx, VideoAttachment video) async {
|
||||
final context = ctx.context;
|
||||
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.sourceMessageId,
|
||||
chatId: ctx.sourceChatId,
|
||||
token: token,
|
||||
videoId: videoId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (sources.isEmpty) {
|
||||
showCustomNotification(context, 'Не удалось получить видео');
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => PhotoViewerScreen.video(
|
||||
attachment: video,
|
||||
initialVideoSources: sources,
|
||||
chatId: ctx.message.chatId,
|
||||
message: ctx.message,
|
||||
actions: ctx.photoActions,
|
||||
sourceName: ctx.chatName,
|
||||
),
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => PhotoViewerScreen.video(
|
||||
attachment: video,
|
||||
initialVideoSources: sources,
|
||||
chatId: ctx.message.chatId,
|
||||
message: ctx.message,
|
||||
actions: ctx.photoActions,
|
||||
sourceName: ctx.chatName,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:qlyra/core/config/app_fonts.dart';
|
||||
import 'package:qlyra/core/config/app_shape.dart';
|
||||
import 'package:qlyra/core/media/clipboard/pasted_attachment.dart';
|
||||
import 'package:qlyra/core/utils/format.dart';
|
||||
import 'package:qlyra/frontend/widgets/sheet_helpers.dart';
|
||||
import 'package:qlyra/l10n/app_localizations.dart';
|
||||
|
||||
Future<String?> showPastePreviewSheet(
|
||||
BuildContext context, {
|
||||
required List<PastedAttachment> items,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => _PastePreviewSheet(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
class _PastePreviewSheet extends StatefulWidget {
|
||||
const _PastePreviewSheet({required this.items});
|
||||
|
||||
final List<PastedAttachment> items;
|
||||
|
||||
@override
|
||||
State<_PastePreviewSheet> createState() => _PastePreviewSheetState();
|
||||
}
|
||||
|
||||
class _PastePreviewSheetState extends State<_PastePreviewSheet> {
|
||||
final TextEditingController _caption = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_caption.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _captionAllowed => widget.items.every((it) => it.isMedia);
|
||||
|
||||
String _title(AppLocalizations l10n) {
|
||||
if (widget.items.length > 1) {
|
||||
return l10n.pasteAttachTitleMany(widget.items.length);
|
||||
}
|
||||
return switch (widget.items.first.kind) {
|
||||
PastedAttachmentKind.image => l10n.pasteAttachTitleImage,
|
||||
PastedAttachmentKind.video => l10n.pasteAttachTitleVideo,
|
||||
PastedAttachmentKind.file => l10n.pasteAttachTitleFile,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final single = widget.items.length == 1 ? widget.items.first : null;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Align(alignment: Alignment.center, child: SheetGrabber()),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_title(l10n),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: displayFontOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child:
|
||||
single != null && single.kind == PastedAttachmentKind.image
|
||||
? _SingleImagePreview(item: single)
|
||||
: _AttachmentList(items: widget.items),
|
||||
),
|
||||
),
|
||||
if (_captionAllowed) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _caption,
|
||||
autofocus: true,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.pasteAttachCaptionHint,
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppShape.buttonRadius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SheetButton(
|
||||
label: l10n.pasteAttachCancel,
|
||||
filled: false,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SheetButton(
|
||||
label: l10n.pasteAttachSend,
|
||||
filled: true,
|
||||
onTap: () =>
|
||||
Navigator.of(context).pop(_caption.text.trim()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleImagePreview extends StatelessWidget {
|
||||
const _SingleImagePreview({required this.item});
|
||||
|
||||
final PastedAttachment item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: AppShape.cardRadius,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 280),
|
||||
color: cs.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
item.file,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => _AttachmentTile(item: item),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentList extends StatelessWidget {
|
||||
const _AttachmentList({required this.items});
|
||||
|
||||
final List<PastedAttachment> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
_AttachmentTile(item: items[i]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentTile extends StatelessWidget {
|
||||
const _AttachmentTile({required this.item});
|
||||
|
||||
final PastedAttachment item;
|
||||
|
||||
IconData get _icon => switch (item.kind) {
|
||||
PastedAttachmentKind.image => Symbols.image,
|
||||
PastedAttachmentKind.video => Symbols.movie,
|
||||
PastedAttachmentKind.file => Symbols.description,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: AppShape.buttonRadius,
|
||||
),
|
||||
child: item.kind == PastedAttachmentKind.image
|
||||
? ClipRRect(
|
||||
borderRadius: AppShape.buttonRadius,
|
||||
child: Image.file(
|
||||
item.file,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Icon(_icon, size: 22, color: cs.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Icon(_icon, size: 22, color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatBytes(item.size),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,70 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:qlyra/core/config/chat_wallpaper_themes.dart';
|
||||
import 'package:qlyra/core/config/app_colors.dart';
|
||||
import 'package:qlyra/core/storage/chat_wallpaper_store.dart';
|
||||
import 'package:qlyra/core/utils/image_utils.dart';
|
||||
import 'package:qlyra/frontend/screens/profile/custom_gradient_editor_screen.dart';
|
||||
import 'chat_wallpaper_view.dart';
|
||||
import 'mesh_gradient_background.dart';
|
||||
import 'custom_notification.dart';
|
||||
import '../../core/config/app_fonts.dart';
|
||||
|
||||
enum WallpaperPickType { none, theme, gallery }
|
||||
enum WallpaperPickType { none, theme, gallery, gradient }
|
||||
|
||||
class WallpaperPick {
|
||||
final WallpaperPickType type;
|
||||
final ChatWallpaperTheme? theme;
|
||||
final List<Color>? gradientColors;
|
||||
final bool gradientAnimated;
|
||||
final double gradientRotation;
|
||||
|
||||
const WallpaperPick.none() : type = WallpaperPickType.none, theme = null;
|
||||
const WallpaperPick.none()
|
||||
: type = WallpaperPickType.none,
|
||||
theme = null,
|
||||
gradientColors = null,
|
||||
gradientAnimated = true,
|
||||
gradientRotation = 0;
|
||||
const WallpaperPick.gallery()
|
||||
: type = WallpaperPickType.gallery,
|
||||
theme = null;
|
||||
const WallpaperPick.theme(this.theme) : type = WallpaperPickType.theme;
|
||||
theme = null,
|
||||
gradientColors = null,
|
||||
gradientAnimated = true,
|
||||
gradientRotation = 0;
|
||||
const WallpaperPick.theme(this.theme)
|
||||
: type = WallpaperPickType.theme,
|
||||
gradientColors = null,
|
||||
gradientAnimated = true,
|
||||
gradientRotation = 0;
|
||||
const WallpaperPick.gradient(
|
||||
this.gradientColors, {
|
||||
this.gradientAnimated = false,
|
||||
this.gradientRotation = 0,
|
||||
}) : type = WallpaperPickType.gradient,
|
||||
theme = null;
|
||||
}
|
||||
|
||||
// #***! путь, а не байты: withData грузит файл в java-кучу и валит процесс на OOM
|
||||
Future<Uint8List?> pickWallpaperBytes(BuildContext context) async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image);
|
||||
final path = result?.files.firstOrNull?.path;
|
||||
if (path == null) return null;
|
||||
if (await File(path).length() > kMaxWallpaperBytes) {
|
||||
if (context.mounted) {
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 16 МБ)');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final bytes = await compressWallpaperFile(path);
|
||||
if (bytes == null && context.mounted) {
|
||||
showCustomNotification(context, 'Не удалось обработать изображение');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Future<WallpaperPick?> showChatWallpaperSheet(
|
||||
@@ -73,6 +118,34 @@ class _ChatWallpaperGalleryScreenState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openGradientEditor() async {
|
||||
final current = widget.current;
|
||||
final result = await Navigator.of(context).push<CustomGradientResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CustomGradientEditorScreen(
|
||||
initialColors: current?.isGradient == true
|
||||
? current!.gradientColors
|
||||
: null,
|
||||
initialAnimated: current?.isGradient == true
|
||||
? current!.gradientAnimated
|
||||
: false,
|
||||
initialRotation: current?.isGradient == true
|
||||
? current!.gradientRotation
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
WallpaperPick.gradient(
|
||||
result.colors,
|
||||
gradientAnimated: result.animated,
|
||||
gradientRotation: result.rotation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -167,6 +240,12 @@ class _ChatWallpaperGalleryScreenState
|
||||
_keepsImage = false;
|
||||
}),
|
||||
),
|
||||
_CustomGradientTile(
|
||||
current: widget.current?.isGradient == true
|
||||
? widget.current
|
||||
: null,
|
||||
onTap: _openGradientEditor,
|
||||
),
|
||||
for (final theme in kChatWallpaperThemes)
|
||||
_ThemeTile(
|
||||
theme: theme,
|
||||
@@ -423,6 +502,36 @@ class _CurrentImageTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomGradientTile extends StatelessWidget {
|
||||
final ChatWallpaper? current;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CustomGradientTile({required this.current, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final colors = current?.gradientColors;
|
||||
return _TileFrame(
|
||||
selected: false,
|
||||
onTap: onTap,
|
||||
label: 'Своя',
|
||||
child: colors != null && colors.isNotEmpty
|
||||
? MeshGradientBackground(
|
||||
colors: colors,
|
||||
animate: false,
|
||||
rotation: current?.gradientRotation ?? 0,
|
||||
)
|
||||
: ColoredBox(
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(Symbols.palette, color: cs.onSurface, size: 30),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeTile extends StatelessWidget {
|
||||
final ChatWallpaperTheme theme;
|
||||
final bool selected;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
import 'package:qlyra/core/config/chat_wallpaper_themes.dart';
|
||||
import 'package:qlyra/core/storage/chat_wallpaper_store.dart';
|
||||
import 'package:qlyra/frontend/widgets/mesh_gradient_background.dart';
|
||||
|
||||
class ChatWallpaperView extends StatelessWidget {
|
||||
final ChatWallpaper wallpaper;
|
||||
@@ -26,6 +27,15 @@ class ChatWallpaperView extends StatelessWidget {
|
||||
offsetX: wallpaper.offsetX,
|
||||
);
|
||||
}
|
||||
if (wallpaper.isGradient) {
|
||||
final colors = wallpaper.gradientColors;
|
||||
if (colors == null || colors.isEmpty) return const SizedBox.shrink();
|
||||
return MeshGradientBackground(
|
||||
colors: colors,
|
||||
animate: wallpaper.gradientAnimated,
|
||||
rotation: wallpaper.gradientRotation,
|
||||
);
|
||||
}
|
||||
final theme = chatWallpaperThemeById(wallpaper.themeId);
|
||||
if (theme == null) return const SizedBox.shrink();
|
||||
return theme.buildBackground();
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ColorWheelPicker extends StatefulWidget {
|
||||
final Color color;
|
||||
final ValueChanged<Color> onChanged;
|
||||
|
||||
const ColorWheelPicker({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ColorWheelPicker> createState() => _ColorWheelPickerState();
|
||||
}
|
||||
|
||||
class _ColorWheelPickerState extends State<ColorWheelPicker> {
|
||||
late HSVColor _hsv;
|
||||
late Color _lastEmitted;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hsv = HSVColor.fromColor(widget.color);
|
||||
_lastEmitted = widget.color;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ColorWheelPicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.color != _lastEmitted) {
|
||||
_hsv = HSVColor.fromColor(widget.color);
|
||||
_lastEmitted = widget.color;
|
||||
}
|
||||
}
|
||||
|
||||
void _emit(HSVColor hsv) {
|
||||
setState(() => _hsv = hsv);
|
||||
final color = hsv.toColor();
|
||||
_lastEmitted = color;
|
||||
widget.onChanged(color);
|
||||
}
|
||||
|
||||
void _handleWheel(Offset local, double size) {
|
||||
final radius = size / 2;
|
||||
final dx = local.dx - radius;
|
||||
final dy = local.dy - radius;
|
||||
final sat = (math.sqrt(dx * dx + dy * dy) / radius).clamp(0.0, 1.0);
|
||||
var hue = math.atan2(dy, dx) * 180 / math.pi;
|
||||
if (hue < 0) hue += 360;
|
||||
_emit(_hsv.withHue(hue).withSaturation(sat));
|
||||
}
|
||||
|
||||
void _handleBrightness(double localY, double height) {
|
||||
final value = (1 - localY / height).clamp(0.0, 1.0);
|
||||
_emit(_hsv.withValue(value));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const barWidth = 28.0;
|
||||
const spacing = 16.0;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wheelSize = math.min(
|
||||
260.0,
|
||||
constraints.maxWidth - barWidth - spacing,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (d) => _handleWheel(d.localPosition, wheelSize),
|
||||
onPanUpdate: (d) => _handleWheel(d.localPosition, wheelSize),
|
||||
child: SizedBox(
|
||||
width: wheelSize,
|
||||
height: wheelSize,
|
||||
child: CustomPaint(painter: _WheelPainter(hsv: _hsv)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: spacing),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (d) =>
|
||||
_handleBrightness(d.localPosition.dy, wheelSize),
|
||||
onPanUpdate: (d) =>
|
||||
_handleBrightness(d.localPosition.dy, wheelSize),
|
||||
child: SizedBox(
|
||||
width: barWidth,
|
||||
height: wheelSize,
|
||||
child: CustomPaint(painter: _BrightnessBarPainter(hsv: _hsv)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WheelPainter extends CustomPainter {
|
||||
final HSVColor hsv;
|
||||
|
||||
const _WheelPainter({required this.hsv});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
final hueShader = SweepGradient(
|
||||
colors: [
|
||||
for (var i = 0; i <= 360; i += 30)
|
||||
HSVColor.fromAHSV(1, (i % 360).toDouble(), 1, 1).toColor(),
|
||||
],
|
||||
stops: [for (var i = 0; i <= 360; i += 30) i / 360],
|
||||
).createShader(rect);
|
||||
canvas.drawCircle(center, radius, Paint()..shader = hueShader);
|
||||
|
||||
final satShader = RadialGradient(
|
||||
colors: [Colors.white, Colors.white.withValues(alpha: 0)],
|
||||
).createShader(rect);
|
||||
canvas.drawCircle(center, radius, Paint()..shader = satShader);
|
||||
|
||||
final angle = hsv.hue * math.pi / 180;
|
||||
final thumb = Offset(
|
||||
center.dx + hsv.saturation * radius * math.cos(angle),
|
||||
center.dy + hsv.saturation * radius * math.sin(angle),
|
||||
);
|
||||
canvas.drawShadow(
|
||||
Path()..addOval(Rect.fromCircle(center: thumb, radius: 13)),
|
||||
Colors.black,
|
||||
2,
|
||||
false,
|
||||
);
|
||||
canvas.drawCircle(thumb, 13, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(thumb, 10, Paint()..color = hsv.withValue(1).toColor());
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_WheelPainter oldDelegate) =>
|
||||
oldDelegate.hsv.hue != hsv.hue ||
|
||||
oldDelegate.hsv.saturation != hsv.saturation;
|
||||
}
|
||||
|
||||
class _BrightnessBarPainter extends CustomPainter {
|
||||
final HSVColor hsv;
|
||||
|
||||
const _BrightnessBarPainter({required this.hsv});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
Radius.circular(size.width / 2),
|
||||
);
|
||||
final top = hsv.withValue(1).toColor();
|
||||
final gradient = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [top, Colors.black],
|
||||
).createShader(Offset.zero & size);
|
||||
canvas.drawRRect(rrect, Paint()..shader = gradient);
|
||||
|
||||
final thumbY = (1 - hsv.value) * size.height;
|
||||
final thumb = Offset(size.width / 2, thumbY.clamp(0.0, size.height));
|
||||
canvas.drawShadow(
|
||||
Path()..addOval(Rect.fromCircle(center: thumb, radius: 12)),
|
||||
Colors.black,
|
||||
2,
|
||||
false,
|
||||
);
|
||||
canvas.drawCircle(thumb, 12, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(thumb, 9, Paint()..color = hsv.toColor());
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_BrightnessBarPainter oldDelegate) =>
|
||||
oldDelegate.hsv != hsv;
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import '../commands/command_registry.dart';
|
||||
import '../commands/slash_command.dart';
|
||||
|
||||
class CommandSuggestionsPanel extends StatelessWidget {
|
||||
final List<SlashCommand> commands;
|
||||
final List<SlashCommand>? commands;
|
||||
final double maxHeight;
|
||||
final ValueChanged<SlashCommand>? onSelected;
|
||||
|
||||
const CommandSuggestionsPanel({
|
||||
super.key,
|
||||
this.commands = kSlashCommands,
|
||||
this.commands,
|
||||
this.maxHeight = 220,
|
||||
this.onSelected,
|
||||
});
|
||||
@@ -18,7 +18,9 @@ class CommandSuggestionsPanel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final visible = commands.where((c) => !c.hidden).toList(growable: false);
|
||||
final visible = (commands ?? allSlashCommands)
|
||||
.where((c) => !c.hidden)
|
||||
.toList(growable: false);
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -9,14 +10,14 @@ import '../../core/config/app_shape.dart';
|
||||
class InfoActionSheetItem {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? body;
|
||||
final Color? titleColor;
|
||||
final Color? iconColor;
|
||||
|
||||
const InfoActionSheetItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.body,
|
||||
this.titleColor,
|
||||
this.iconColor,
|
||||
});
|
||||
@@ -26,6 +27,7 @@ Future<bool> showInfoActionSheet(
|
||||
BuildContext context, {
|
||||
String? headerEmoji,
|
||||
IconData? headerIcon,
|
||||
bool headerGlow = false,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
List<InfoActionSheetItem> items = const [],
|
||||
@@ -54,6 +56,7 @@ Future<bool> showInfoActionSheet(
|
||||
builder: (ctx) => _InfoActionSheet(
|
||||
headerEmoji: headerEmoji,
|
||||
headerIcon: headerIcon,
|
||||
headerGlow: headerGlow,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
items: items,
|
||||
@@ -73,6 +76,7 @@ Future<bool> showInfoActionSheet(
|
||||
class _InfoActionSheet extends StatefulWidget {
|
||||
final String? headerEmoji;
|
||||
final IconData? headerIcon;
|
||||
final bool headerGlow;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<InfoActionSheetItem> items;
|
||||
@@ -82,6 +86,7 @@ class _InfoActionSheet extends StatefulWidget {
|
||||
const _InfoActionSheet({
|
||||
this.headerEmoji,
|
||||
this.headerIcon,
|
||||
this.headerGlow = false,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.items = const [],
|
||||
@@ -204,15 +209,32 @@ class _InfoActionSheetState extends State<_InfoActionSheet> {
|
||||
|
||||
Widget _buildHeader(ColorScheme cs) {
|
||||
if (widget.headerEmoji != null) {
|
||||
final emoji = Text(
|
||||
widget.headerEmoji!,
|
||||
style: const TextStyle(fontSize: 72, height: 1.0),
|
||||
);
|
||||
return Center(child: widget.headerGlow ? _GlowHalo(child: emoji) : emoji);
|
||||
}
|
||||
if (!widget.headerGlow) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.headerEmoji!,
|
||||
style: const TextStyle(fontSize: 72, height: 1.0),
|
||||
child: Icon(
|
||||
widget.headerIcon,
|
||||
size: 72,
|
||||
color: cs.primary,
|
||||
weight: 400,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Icon(widget.headerIcon, size: 72, color: cs.primary, weight: 400),
|
||||
child: _GlowHalo(
|
||||
child: Icon(
|
||||
widget.headerIcon,
|
||||
size: 104,
|
||||
fill: 1,
|
||||
weight: 300,
|
||||
color: Color.lerp(cs.surface, cs.primary, 0.14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,23 +255,32 @@ class _InfoActionSheetState extends State<_InfoActionSheet> {
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
color: titleColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
),
|
||||
style: item.body == null
|
||||
? TextStyle(
|
||||
color: item.titleColor ?? cs.onSurfaceVariant,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.35,
|
||||
)
|
||||
: TextStyle(
|
||||
color: titleColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.body,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.35,
|
||||
if (item.body != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.body!,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -257,3 +288,47 @@ class _InfoActionSheetState extends State<_InfoActionSheet> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlowHalo extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _GlowHalo({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: 208,
|
||||
height: 184,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blob(cs.primary, const Offset(-36, -12)),
|
||||
_blob(cs.tertiary, const Offset(34, -24)),
|
||||
_blob(cs.secondary, const Offset(6, 30)),
|
||||
],
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _blob(Color color, Offset offset) => Transform.translate(
|
||||
offset: offset,
|
||||
child: Container(
|
||||
width: 112,
|
||||
height: 112,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,12 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
|
||||
static const Duration _duration = Duration(milliseconds: 1900);
|
||||
|
||||
static const List<String> _greetings = [
|
||||
'С большой силой приходит большая ответственность',
|
||||
'All in your hands',
|
||||
'Добро пожаловать в Qlyra!',
|
||||
'Аварийный выход на высоте 30 тысяч футов. Иллюзия безопасности.',
|
||||
'Иногда забавные вещи могут быть уголовно наказуемы',
|
||||
'Если вы видите это сообщение, значит меня уже нет в живых.',
|
||||
'Где был Гондор когда...',
|
||||
'Вы нашли пасхалку!',
|
||||
];
|
||||
|
||||
late final String _greeting;
|
||||
@@ -123,8 +126,8 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 360),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (_, __, ___) => const AdaptiveShell(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
pageBuilder: (_, _, _) => const AdaptiveShell(),
|
||||
transitionsBuilder: (_, animation, _, child) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animation,
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../backend/modules/chats.dart';
|
||||
import '../../backend/modules/links.dart';
|
||||
import '../../core/cache/info_cache.dart';
|
||||
import '../../core/links/max_link.dart';
|
||||
import '../../core/links/profile_link.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/share_origin.dart';
|
||||
import '../../main.dart';
|
||||
@@ -115,13 +115,10 @@ Future<int?> _botIdOf(ResolvedLink? resolved) async {
|
||||
}
|
||||
|
||||
Future<bool> _shareOwnLink(BuildContext context) async {
|
||||
final myId = await currentAccountId();
|
||||
if (myId == 0) return false;
|
||||
final info = await ContactInfoFetch.get(myId);
|
||||
final link = await ownProfileLink();
|
||||
if (!context.mounted) return true;
|
||||
|
||||
final link = (info?.raw['link'] as String?)?.trim();
|
||||
if (link == null || link.isEmpty) {
|
||||
if (link == null) {
|
||||
showCustomNotification(context, 'У профиля нет публичной ссылки');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../backend/modules/messages.dart';
|
||||
import '../../core/media/audio_file_track.dart';
|
||||
import '../../core/media/audio_playback_controller.dart';
|
||||
import '../../core/media/media_playback.dart';
|
||||
import '../../core/utils/format.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
@@ -51,12 +53,73 @@ class MediaPlaybackPill extends StatelessWidget {
|
||||
margin: margin,
|
||||
),
|
||||
);
|
||||
case PlaybackKind.audioFile:
|
||||
return ValueListenableBuilder<AudioFileTrack?>(
|
||||
valueListenable: playback.audioFile,
|
||||
builder: (context, track, _) => track == null
|
||||
? const SizedBox.shrink()
|
||||
: _AudioFilePill(
|
||||
track: track,
|
||||
borderRadius: borderRadius,
|
||||
margin: margin,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioFilePill extends StatelessWidget {
|
||||
const _AudioFilePill({
|
||||
required this.track,
|
||||
required this.borderRadius,
|
||||
required this.margin,
|
||||
});
|
||||
|
||||
final AudioFileTrack track;
|
||||
final BorderRadius? borderRadius;
|
||||
final EdgeInsets margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audio = AudioPlaybackController.instance;
|
||||
final source = track.sourceName.trim();
|
||||
return _PillSurface(
|
||||
borderRadius: borderRadius,
|
||||
margin: margin,
|
||||
tick: Listenable.merge([
|
||||
audio.playing,
|
||||
audio.position,
|
||||
audio.duration,
|
||||
audio.processingState,
|
||||
]),
|
||||
isPlaying: () => audio.playing.value,
|
||||
progress: () {
|
||||
final total = audio.duration.value.inMilliseconds;
|
||||
return total > 0
|
||||
? (audio.position.value.inMilliseconds / total).clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
},
|
||||
label: source.isEmpty ? track.name : '${track.name} · $source',
|
||||
onToggle: audio.toggle,
|
||||
onClose: MediaPlayback.instance.closeAudioFile,
|
||||
onOpen: () {
|
||||
final chatId = track.chatId;
|
||||
final messageId = track.messageId;
|
||||
final messageTime = track.messageTime;
|
||||
if (chatId == null || messageId == null || messageTime == null) return;
|
||||
openChatAtMessage(
|
||||
context,
|
||||
chatId,
|
||||
messageId: messageId,
|
||||
messageTime: messageTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VoicePill extends StatelessWidget {
|
||||
const _VoicePill({
|
||||
required this.track,
|
||||
@@ -93,6 +156,7 @@ class _VoicePill extends StatelessWidget {
|
||||
senderId: track.senderId,
|
||||
isMe: track.isMe,
|
||||
time: track.time,
|
||||
label: null,
|
||||
onToggle: track.audio.toggle,
|
||||
onSpeed: playback.cycleVoiceSpeed,
|
||||
onClose: playback.closeVoice,
|
||||
@@ -141,6 +205,7 @@ class _VideoNotePill extends StatelessWidget {
|
||||
senderId: track.senderId,
|
||||
isMe: track.isMe,
|
||||
time: track.time,
|
||||
label: null,
|
||||
onToggle: () => track.controller.value.isPlaying
|
||||
? track.controller.pause()
|
||||
: track.controller.play(),
|
||||
@@ -165,12 +230,13 @@ class _PillSurface extends StatelessWidget {
|
||||
required this.tick,
|
||||
required this.isPlaying,
|
||||
required this.progress,
|
||||
required this.speed,
|
||||
required this.senderId,
|
||||
required this.isMe,
|
||||
required this.time,
|
||||
this.speed,
|
||||
this.senderId,
|
||||
this.isMe,
|
||||
this.time,
|
||||
required this.label,
|
||||
required this.onToggle,
|
||||
required this.onSpeed,
|
||||
this.onSpeed,
|
||||
required this.onClose,
|
||||
required this.onOpen,
|
||||
});
|
||||
@@ -180,18 +246,20 @@ class _PillSurface extends StatelessWidget {
|
||||
final Listenable tick;
|
||||
final bool Function() isPlaying;
|
||||
final double Function() progress;
|
||||
final double speed;
|
||||
final int senderId;
|
||||
final bool isMe;
|
||||
final int time;
|
||||
final double? speed;
|
||||
final int? senderId;
|
||||
final bool? isMe;
|
||||
final int? time;
|
||||
final String? label;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onSpeed;
|
||||
final VoidCallback? onSpeed;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
String _speedLabel() {
|
||||
final rounded = speed.round();
|
||||
final text = speed == rounded ? '$rounded' : '$speed';
|
||||
final value = speed ?? 1;
|
||||
final rounded = value.round();
|
||||
final text = value == rounded ? '$rounded' : '$value';
|
||||
return '${text}X';
|
||||
}
|
||||
|
||||
@@ -201,10 +269,12 @@ class _PillSurface extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final radius =
|
||||
borderRadius ?? BorderRadius.circular(MediaPlaybackPill.height / 2);
|
||||
final author = isMe
|
||||
final author = isMe == true
|
||||
? l10n.playbackPillYou
|
||||
: (ContactCache.get(senderId) ?? '$senderId');
|
||||
final clock = formatClock(DateTime.fromMillisecondsSinceEpoch(time));
|
||||
: (ContactCache.get(senderId ?? 0) ?? '${senderId ?? ''}');
|
||||
final clock = time == null
|
||||
? ''
|
||||
: formatClock(DateTime.fromMillisecondsSinceEpoch(time!));
|
||||
|
||||
return Padding(
|
||||
padding: margin,
|
||||
@@ -234,7 +304,7 @@ class _PillSurface extends StatelessWidget {
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$author ${l10n.playbackPillAt} $clock',
|
||||
label ?? '$author ${l10n.playbackPillAt} $clock',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -243,7 +313,8 @@ class _PillSurface extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_SpeedChip(label: _speedLabel(), onTap: onSpeed),
|
||||
if (onSpeed != null)
|
||||
_SpeedChip(label: _speedLabel(), onTap: onSpeed!),
|
||||
_IconTap(
|
||||
icon: Symbols.close,
|
||||
color: cs.onSurfaceVariant,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
// #***! анимированный mesh-градиент, портировано с нативного generateGradient
|
||||
// Telegram (свёрл-дисторсия + радиальное смешение цветов по опорным точкам)
|
||||
class MeshGradient {
|
||||
static const String _asset = 'shaders/mesh_gradient.frag';
|
||||
|
||||
static ui.FragmentProgram? _program;
|
||||
static bool _loadAttempted = false;
|
||||
|
||||
static bool get isSupported => _program != null;
|
||||
|
||||
static Future<void> load() async {
|
||||
if (_loadAttempted) return;
|
||||
_loadAttempted = true;
|
||||
try {
|
||||
_program = await ui.FragmentProgram.fromAsset(_asset);
|
||||
} catch (_) {
|
||||
_program = null;
|
||||
}
|
||||
}
|
||||
|
||||
static ui.FragmentShader? newShader() => _program?.fragmentShader();
|
||||
}
|
||||
|
||||
class MeshGradientBackground extends StatefulWidget {
|
||||
final List<Color> colors;
|
||||
final bool animate;
|
||||
// #***! позиция опорных точек при animate == false, в шагах (0..8, дробная
|
||||
// часть — прогресс между соседними шагами); позволяет вручную повернуть
|
||||
// статичный градиент
|
||||
final double rotation;
|
||||
// #***! длительность одного шага смены опорных точек
|
||||
final Duration stepDuration;
|
||||
|
||||
const MeshGradientBackground({
|
||||
super.key,
|
||||
required this.colors,
|
||||
this.animate = true,
|
||||
this.rotation = 0,
|
||||
this.stepDuration = const Duration(milliseconds: 4200),
|
||||
});
|
||||
|
||||
@override
|
||||
State<MeshGradientBackground> createState() => _MeshGradientBackgroundState();
|
||||
}
|
||||
|
||||
class _MeshGradientBackgroundState extends State<MeshGradientBackground>
|
||||
with SingleTickerProviderStateMixin {
|
||||
ui.FragmentShader? _shader;
|
||||
Ticker? _ticker;
|
||||
int _phase = 0;
|
||||
double _progress = 0;
|
||||
Duration _lastTick = Duration.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shader = MeshGradient.newShader();
|
||||
if (_shader != null && widget.animate && widget.colors.length > 1) {
|
||||
_startTicker();
|
||||
}
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_lastTick = Duration.zero;
|
||||
_ticker = createTicker(_onTick)..start();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MeshGradientBackground oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final shouldAnimate = widget.animate && widget.colors.length > 1;
|
||||
if (shouldAnimate && _ticker == null) {
|
||||
_startTicker();
|
||||
} else if (!shouldAnimate && _ticker != null) {
|
||||
_ticker!.dispose();
|
||||
_ticker = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
final delta = elapsed - _lastTick;
|
||||
_lastTick = elapsed;
|
||||
final stepMs = widget.stepDuration.inMilliseconds;
|
||||
if (stepMs <= 0) return;
|
||||
final next = _progress + delta.inMilliseconds / stepMs;
|
||||
if (next >= 1) {
|
||||
_phase = (_phase + 1) % 8;
|
||||
_progress = next - next.floorToDouble();
|
||||
} else {
|
||||
_progress = next;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = widget.colors;
|
||||
if (colors.isEmpty) return const SizedBox.shrink();
|
||||
final shader = _shader;
|
||||
if (shader == null || colors.length == 1) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: colors.length > 1
|
||||
? LinearGradient(
|
||||
colors: colors,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
color: colors.length == 1 ? colors.first : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
int phase;
|
||||
double progress;
|
||||
if (widget.animate) {
|
||||
phase = _phase;
|
||||
progress = _progress;
|
||||
} else {
|
||||
final r = widget.rotation % 8;
|
||||
final normalized = r < 0 ? r + 8 : r;
|
||||
phase = normalized.floor();
|
||||
progress = normalized - phase;
|
||||
}
|
||||
return CustomPaint(
|
||||
painter: _MeshGradientPainter(
|
||||
shader: shader,
|
||||
colors: colors,
|
||||
phase: phase,
|
||||
progress: progress,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MeshGradientPainter extends CustomPainter {
|
||||
final ui.FragmentShader shader;
|
||||
final List<Color> colors;
|
||||
final int phase;
|
||||
final double progress;
|
||||
|
||||
_MeshGradientPainter({
|
||||
required this.shader,
|
||||
required this.colors,
|
||||
required this.phase,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (size.isEmpty) return;
|
||||
var i = 0;
|
||||
shader
|
||||
..setFloat(i++, size.width)
|
||||
..setFloat(i++, size.height)
|
||||
..setFloat(i++, colors.length.toDouble())
|
||||
..setFloat(i++, phase.toDouble())
|
||||
..setFloat(i++, progress);
|
||||
for (var c = 0; c < 6; c++) {
|
||||
final color = c < colors.length ? colors[c] : colors.last;
|
||||
shader
|
||||
..setFloat(i++, color.r)
|
||||
..setFloat(i++, color.g)
|
||||
..setFloat(i++, color.b)
|
||||
..setFloat(i++, color.a);
|
||||
}
|
||||
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MeshGradientPainter oldDelegate) =>
|
||||
oldDelegate.phase != phase ||
|
||||
oldDelegate.progress != progress ||
|
||||
!_sameColors(oldDelegate.colors, colors);
|
||||
|
||||
static bool _sameColors(List<Color> a, List<Color> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import '../../core/utils/webview_support.dart';
|
||||
import '../../core/config/app_link_preview.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'formatted_message_text.dart';
|
||||
import 'reply_preview.dart';
|
||||
import 'text_entity_actions.dart';
|
||||
import 'sending_clock_icon.dart';
|
||||
import 'photo_viewer.dart';
|
||||
@@ -43,6 +44,7 @@ import 'attachment/bubbles/video_bubble.dart';
|
||||
import 'attachment/bubbles/file_bubble.dart';
|
||||
import 'attachment/bubbles/forwarded_bubble.dart';
|
||||
import 'lottie_image.dart';
|
||||
import 'text_with_meta.dart';
|
||||
|
||||
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
||||
|
||||
@@ -60,138 +62,6 @@ class ReactionAnimationEvent {
|
||||
|
||||
typedef ReactionAnimojiResolver = Animoji? Function(String emoji);
|
||||
|
||||
class _TextWithMeta extends MultiChildRenderObjectWidget {
|
||||
_TextWithMeta({required Widget text, required Widget meta})
|
||||
: super(children: [text, meta]);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderTextWithMeta();
|
||||
}
|
||||
|
||||
class _TextWithMetaParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class _RenderTextWithMeta extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, _TextWithMetaParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _TextWithMetaParentData> {
|
||||
static const double _gap = 8;
|
||||
static const double _baselineNudge = 2;
|
||||
|
||||
RenderBox get _text => firstChild!;
|
||||
RenderBox get _meta => lastChild!;
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! _TextWithMetaParentData) {
|
||||
child.parentData = _TextWithMetaParentData();
|
||||
}
|
||||
}
|
||||
|
||||
RenderParagraph? _soleParagraph() {
|
||||
RenderParagraph? found;
|
||||
var seen = 0;
|
||||
void visit(RenderObject node) {
|
||||
if (node is RenderParagraph) {
|
||||
found = node;
|
||||
seen++;
|
||||
return;
|
||||
}
|
||||
node.visitChildren(visit);
|
||||
}
|
||||
|
||||
_text.visitChildren(visit);
|
||||
if (_text is RenderParagraph) {
|
||||
found = _text as RenderParagraph;
|
||||
seen = 1;
|
||||
}
|
||||
return seen == 1 ? found : null;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) =>
|
||||
_text.getMinIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) =>
|
||||
_text.getMaxIntrinsicWidth(height) +
|
||||
_gap +
|
||||
_meta.getMaxIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) =>
|
||||
_text.getMinIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) =>
|
||||
_text.getMaxIntrinsicHeight(width) + _meta.getMaxIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) =>
|
||||
BaselineOffset(_text.getDistanceToActualBaseline(baseline)).offset;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
_meta.layout(const BoxConstraints(), parentUsesSize: true);
|
||||
final metaSize = _meta.size;
|
||||
|
||||
_text.layout(constraints.loosen(), parentUsesSize: true);
|
||||
final textSize = _text.size;
|
||||
|
||||
final paragraph = _soleParagraph();
|
||||
final needed = _gap + metaSize.width;
|
||||
|
||||
double width;
|
||||
double height;
|
||||
var metaOnOwnLine = false;
|
||||
|
||||
if (paragraph != null) {
|
||||
final length = paragraph.text.toPlainText().length;
|
||||
final caret = paragraph.getOffsetForCaret(
|
||||
TextPosition(offset: length),
|
||||
Rect.zero,
|
||||
);
|
||||
final lastLine = caret.dx;
|
||||
final singleLine = caret.dy < 0.5;
|
||||
if (lastLine + needed <= textSize.width) {
|
||||
width = textSize.width;
|
||||
height = textSize.height;
|
||||
} else if (singleLine) {
|
||||
width = lastLine + needed;
|
||||
height = textSize.height;
|
||||
} else {
|
||||
width = textSize.width;
|
||||
height = textSize.height + metaSize.height;
|
||||
metaOnOwnLine = true;
|
||||
}
|
||||
} else {
|
||||
width = math.max(textSize.width, metaSize.width);
|
||||
height = textSize.height + metaSize.height;
|
||||
metaOnOwnLine = true;
|
||||
}
|
||||
|
||||
size = constraints.constrain(Size(width, height));
|
||||
|
||||
(_text.parentData! as _TextWithMetaParentData).offset = Offset.zero;
|
||||
(_meta.parentData! as _TextWithMetaParentData).offset = Offset(
|
||||
math.max(0, size.width - metaSize.width),
|
||||
metaOnOwnLine
|
||||
? size.height - metaSize.height
|
||||
: size.height - metaSize.height - _baselineNudge,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapIntrinsicWidth extends SingleChildRenderObjectWidget {
|
||||
final double cap;
|
||||
|
||||
@@ -687,23 +557,33 @@ class MessageBubble extends StatelessWidget {
|
||||
});
|
||||
|
||||
bool _computeHasPhotoWithCaption() {
|
||||
final attachments = _contentAttachments;
|
||||
if (attachments.isEmpty) return false;
|
||||
final hasPhoto = attachments.any((a) => a is PhotoAttachment);
|
||||
final hasCaption = _contentText?.isNotEmpty ?? false;
|
||||
return hasPhoto && hasCaption;
|
||||
if (!_rendersAlbum) return false;
|
||||
return _contentText?.isNotEmpty ?? false;
|
||||
}
|
||||
|
||||
bool _computeHasMultiplePhotosNoCaption() {
|
||||
final attachments = _contentAttachments;
|
||||
if (attachments.isEmpty) return false;
|
||||
final photoCount = attachments.whereType<PhotoAttachment>().length;
|
||||
final hasCaption = _contentText?.isNotEmpty ?? false;
|
||||
return photoCount >= 2 && !hasCaption;
|
||||
return _albumMedia.length >= 2 && !hasCaption;
|
||||
}
|
||||
|
||||
// #***! фото и видео идут одним альбомом, одиночное видео рисует VideoBubble
|
||||
List<MessageAttachment> get _albumMedia =>
|
||||
_contentAttachments.where(PhotoBubble.isAlbumMedia).toList();
|
||||
|
||||
bool get _rendersAlbum {
|
||||
final album = _albumMedia;
|
||||
if (album.length >= 2) return true;
|
||||
return album.length == 1 && album.single is PhotoAttachment;
|
||||
}
|
||||
|
||||
ForwardedMessageAttachment? get _forwarded => message.forwardedAttachment;
|
||||
|
||||
List<MessageAttachment> get _renderableAttachments =>
|
||||
message.attachments
|
||||
?.where((a) => a is! InlineKeyboardAttachment)
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
List<MessageAttachment> get _contentAttachments {
|
||||
final forwarded = _forwarded;
|
||||
if (forwarded != null) {
|
||||
@@ -715,10 +595,7 @@ class MessageBubble extends StatelessWidget {
|
||||
.toList() ??
|
||||
const [];
|
||||
}
|
||||
return message.attachments
|
||||
?.where((a) => a is! InlineKeyboardAttachment)
|
||||
.toList() ??
|
||||
const [];
|
||||
return _renderableAttachments;
|
||||
}
|
||||
|
||||
MessageAttachment? get _primaryAttachment {
|
||||
@@ -771,6 +648,20 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
bool get _isSticker => _primaryAttachment is StickerAttachment;
|
||||
|
||||
bool get _mediaDictatesWidth {
|
||||
final attachments = _contentAttachments;
|
||||
if (attachments.isEmpty) return false;
|
||||
if (attachments.any(
|
||||
(a) =>
|
||||
a is ContactAttachment || a is PollAttachment || a is ShareAttachment,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
if (attachments.any((a) => a is PhotoAttachment)) return true;
|
||||
final first = attachments.first;
|
||||
return first is VideoAttachment && !first.isNote;
|
||||
}
|
||||
|
||||
static const int _jumboAnimojiLimit = 4;
|
||||
|
||||
List<String>? get _jumboAnimojiUrls {
|
||||
@@ -1104,6 +995,23 @@ class MessageBubble extends StatelessWidget {
|
||||
? _buildSenderHeader(cs, padding == EdgeInsets.zero)
|
||||
: null;
|
||||
|
||||
final Widget? replyHeader = reply == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: padding == EdgeInsets.zero ? 8 : 0,
|
||||
right: padding == EdgeInsets.zero ? 8 : 0,
|
||||
bottom: 4,
|
||||
),
|
||||
child: _buildReplyQuote(
|
||||
context,
|
||||
cs,
|
||||
textColor,
|
||||
reply,
|
||||
maxBubbleWidth,
|
||||
),
|
||||
);
|
||||
|
||||
final Widget innerContent =
|
||||
contentType == MessageType.text &&
|
||||
jumboAnimoji == null &&
|
||||
@@ -1121,7 +1029,13 @@ class MessageBubble extends StatelessWidget {
|
||||
if (reply != null) ...[
|
||||
_CapIntrinsicWidth(
|
||||
cap: maxBubbleWidth * _replyWidthShare,
|
||||
child: _buildReplyQuote(context, cs, textColor, reply),
|
||||
child: _buildReplyQuote(
|
||||
context,
|
||||
cs,
|
||||
textColor,
|
||||
reply,
|
||||
maxBubbleWidth,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
@@ -1129,24 +1043,26 @@ class MessageBubble extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
: _mediaDictatesWidth && (senderHeader != null || replyHeader != null)
|
||||
? _HeaderAboveMatchWidth(
|
||||
content: contentWithReactions,
|
||||
header: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [?senderHeader, ?replyHeader],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
?senderHeader,
|
||||
if (reply == null)
|
||||
if (replyHeader == null)
|
||||
contentWithReactions
|
||||
else
|
||||
_HeaderAboveMatchWidth(
|
||||
content: contentWithReactions,
|
||||
header: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: padding == EdgeInsets.zero ? 8 : 0,
|
||||
right: padding == EdgeInsets.zero ? 8 : 0,
|
||||
bottom: 4,
|
||||
),
|
||||
child: _buildReplyQuote(context, cs, textColor, reply),
|
||||
),
|
||||
header: replyHeader,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1510,32 +1426,31 @@ class MessageBubble extends StatelessWidget {
|
||||
_contentType == MessageType.voice;
|
||||
final ctx = makeCtx(metaInFooter: carriesMeta);
|
||||
|
||||
final content = _buildContent(ctx);
|
||||
final footer = Padding(
|
||||
padding: inset,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ReactionsWrap(spacing: 4, runSpacing: 4, children: chips),
|
||||
),
|
||||
if (carriesMeta) ...[const SizedBox(width: 8), ctx.footerMeta()],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// #***! у медиа ширину диктует само медиа, реакции переносим по строкам
|
||||
// чтобы длинный ряд чипов не растягивал бабл шире картинки
|
||||
if (_mediaDictatesWidth) {
|
||||
return _StackMatchTopWidth(top: content, bottom: footer);
|
||||
}
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildContent(ctx),
|
||||
Padding(
|
||||
padding: inset,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ReactionsWrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: chips,
|
||||
),
|
||||
),
|
||||
if (carriesMeta) ...[
|
||||
const SizedBox(width: 8),
|
||||
ctx.footerMeta(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
children: [content, footer],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1894,7 +1809,7 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return _TextWithMeta(text: textWidget, meta: metaRow);
|
||||
return TextWithMeta(text: textWidget, meta: metaRow);
|
||||
}
|
||||
|
||||
Widget _buildReplyQuote(
|
||||
@@ -1902,6 +1817,7 @@ class MessageBubble extends StatelessWidget {
|
||||
ColorScheme cs,
|
||||
Color textColor,
|
||||
ReplyInfo reply,
|
||||
double maxBubbleWidth,
|
||||
) {
|
||||
final accent = _senderColor(reply.senderId);
|
||||
final name = reply.senderId == myId
|
||||
@@ -1931,6 +1847,36 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final preview = ReplyPreview.of(
|
||||
text: reply.text,
|
||||
attachments: reply.attachments,
|
||||
);
|
||||
|
||||
final Widget? body;
|
||||
if (preview.hasMedia) {
|
||||
final maxSide = math.max(
|
||||
72.0,
|
||||
math.min(150.0, maxBubbleWidth * _replyWidthShare - 24),
|
||||
);
|
||||
final size = preview.box(maxSide: maxSide);
|
||||
body = Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 1),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: preview.thumbnail(size: size, cs: cs),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (rawPreview.isNotEmpty) {
|
||||
body = _replyQuoteText(cs, textColor, preview.icon, rawPreview, quotedId);
|
||||
} else {
|
||||
body = null;
|
||||
}
|
||||
|
||||
final quote = Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -1952,30 +1898,7 @@ class MessageBubble extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (rawPreview.isNotEmpty)
|
||||
DecryptedContent(
|
||||
accountId: message.accountId,
|
||||
chatId: message.chatId,
|
||||
messageId: quotedId ?? '',
|
||||
cipherText: quotedId == null ? '' : rawPreview,
|
||||
builder: (decryption) => Text(
|
||||
decryption?.state == MessageDecryptionState.wrongKey
|
||||
? 'неверный ключ'
|
||||
: (decryption?.plaintext ?? rawPreview),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: decryption?.state == MessageDecryptionState.wrongKey
|
||||
? cs.error
|
||||
: textColor.withValues(alpha: 0.85),
|
||||
fontSize: 13,
|
||||
fontStyle:
|
||||
decryption?.state == MessageDecryptionState.wrongKey
|
||||
? FontStyle.italic
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
?body,
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1992,6 +1915,48 @@ class MessageBubble extends StatelessWidget {
|
||||
return quote;
|
||||
}
|
||||
|
||||
Widget _replyQuoteText(
|
||||
ColorScheme cs,
|
||||
Color textColor,
|
||||
IconData? icon,
|
||||
String rawPreview,
|
||||
String? quotedId,
|
||||
) {
|
||||
return DecryptedContent(
|
||||
accountId: message.accountId,
|
||||
chatId: message.chatId,
|
||||
messageId: quotedId ?? '',
|
||||
cipherText: quotedId == null ? '' : rawPreview,
|
||||
builder: (decryption) {
|
||||
final wrongKey = decryption?.state == MessageDecryptionState.wrongKey;
|
||||
final color = wrongKey ? cs.error : textColor.withValues(alpha: 0.85);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null && !wrongKey) ...[
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
wrongKey
|
||||
? 'неверный ключ'
|
||||
: (decryption?.plaintext ?? rawPreview),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontStyle: wrongKey ? FontStyle.italic : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForwardedInlineText(
|
||||
BubbleContext ctx,
|
||||
ForwardedMessageAttachment forwarded,
|
||||
@@ -2026,8 +1991,8 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildAttachmentContent(BubbleContext ctx) {
|
||||
final attachments = message.attachments;
|
||||
if (attachments == null || attachments.isEmpty) {
|
||||
final attachments = _renderableAttachments;
|
||||
if (attachments.isEmpty) {
|
||||
return _buildTextContent(ctx);
|
||||
}
|
||||
|
||||
@@ -2120,14 +2085,18 @@ class MessageBubble extends StatelessWidget {
|
||||
return ShareBubble(ctx: ctx, share: shares.first);
|
||||
}
|
||||
|
||||
final photos = attachments.whereType<PhotoAttachment>().toList();
|
||||
if (photos.isEmpty) {
|
||||
return _buildGenericAttachment(ctx, attachments.first);
|
||||
final album = attachments.where(PhotoBubble.isAlbumMedia).toList();
|
||||
if (album.length < 2 &&
|
||||
!(album.length == 1 && album.single is PhotoAttachment)) {
|
||||
return _buildGenericAttachment(
|
||||
ctx,
|
||||
album.isEmpty ? attachments.first : album.single,
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoBubble(
|
||||
ctx: ctx,
|
||||
photos: photos,
|
||||
media: album,
|
||||
hasContentAbove: hasContentAbove,
|
||||
);
|
||||
}
|
||||
@@ -2156,7 +2125,10 @@ class MessageBubble extends StatelessWidget {
|
||||
child: _buildVoiceAttachment(ctx, attachment as AudioAttachment),
|
||||
);
|
||||
default:
|
||||
return _buildTextContent(ctx);
|
||||
return Padding(
|
||||
padding: _paddingFor(MessageType.text, ctx.shape),
|
||||
child: _buildTextContent(ctx),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PasteMediaScope extends StatefulWidget {
|
||||
const PasteMediaScope({
|
||||
super.key,
|
||||
required this.onPaste,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Future<bool> Function()? onPaste;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<PasteMediaScope> createState() => _PasteMediaScopeState();
|
||||
}
|
||||
|
||||
class _PasteMediaScopeState extends State<PasteMediaScope> {
|
||||
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
|
||||
PasteTextIntent: _PasteMediaAction(_paste),
|
||||
};
|
||||
|
||||
Future<bool> _paste() async {
|
||||
final handler = widget.onPaste;
|
||||
if (handler == null) return false;
|
||||
final handled = await handler();
|
||||
return handled || !mounted;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.onPaste == null) return widget.child;
|
||||
return Actions(actions: _actions, child: widget.child);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasteMediaAction extends Action<PasteTextIntent> {
|
||||
_PasteMediaAction(this.onPaste);
|
||||
|
||||
final Future<bool> Function() onPaste;
|
||||
|
||||
@override
|
||||
bool get isActionEnabled => callingAction?.isActionEnabled ?? true;
|
||||
|
||||
@override
|
||||
bool consumesKey(PasteTextIntent intent) =>
|
||||
callingAction?.consumesKey(intent) ?? true;
|
||||
|
||||
@override
|
||||
Object? invoke(PasteTextIntent intent) {
|
||||
unawaited(_resolve(intent, callingAction));
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _resolve(
|
||||
PasteTextIntent intent,
|
||||
Action<PasteTextIntent>? fallback,
|
||||
) async {
|
||||
if (await onPaste()) return;
|
||||
fallback?.invoke(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:qlyra/core/media/clipboard/clipboard_media.dart';
|
||||
|
||||
class PasteMediaToolbar extends StatefulWidget {
|
||||
const PasteMediaToolbar({
|
||||
super.key,
|
||||
required this.anchors,
|
||||
required this.buttonItems,
|
||||
required this.pasteItem,
|
||||
});
|
||||
|
||||
final TextSelectionToolbarAnchors anchors;
|
||||
final List<ContextMenuButtonItem> buttonItems;
|
||||
final ContextMenuButtonItem pasteItem;
|
||||
|
||||
@override
|
||||
State<PasteMediaToolbar> createState() => _PasteMediaToolbarState();
|
||||
}
|
||||
|
||||
class _PasteMediaToolbarState extends State<PasteMediaToolbar> {
|
||||
bool _hasMedia = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_probeClipboard());
|
||||
}
|
||||
|
||||
Future<void> _probeClipboard() async {
|
||||
final hasMedia = await ClipboardMedia.hasMedia();
|
||||
if (!mounted || !hasMedia) return;
|
||||
setState(() => _hasMedia = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: widget.anchors,
|
||||
buttonItems: _hasMedia
|
||||
? widget.buttonItems
|
||||
: widget.buttonItems
|
||||
.where((item) => item != widget.pasteItem)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -14,6 +15,7 @@ import '../../core/cache/info_cache.dart';
|
||||
import '../../core/config/app_frost.dart';
|
||||
import '../../core/utils/download_history.dart';
|
||||
import '../../core/utils/format.dart';
|
||||
import '../../core/utils/image_format.dart';
|
||||
import '../../core/utils/media_cache.dart';
|
||||
import '../../core/utils/media_saver.dart';
|
||||
import '../../core/utils/save_file_as.dart';
|
||||
@@ -158,6 +160,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
final Map<String, Map<String, String>> _videoSourceCache = {};
|
||||
final Map<String, Future<Map<String, String>>> _videoSourceLoads = {};
|
||||
final TransformationController _heroTransform = TransformationController();
|
||||
final Map<String, TransformationController> _pageTransforms = {};
|
||||
int _pointers = 0;
|
||||
bool _zoomed = false;
|
||||
bool _swipeEnabled = true;
|
||||
bool _feedLoaded = false;
|
||||
bool _feedFailed = false;
|
||||
bool _loadingMore = false;
|
||||
@@ -170,6 +176,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_heroTransform.addListener(_syncHero);
|
||||
_heroTransform.addListener(_syncZoom);
|
||||
_items = _localItems();
|
||||
_index = widget.video == null
|
||||
? (_items.length - 1 - widget.initialIndex).clamp(0, _items.length - 1)
|
||||
@@ -190,10 +197,41 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
_heroTransform.value.getMaxScaleOnAxis() <= 1.01;
|
||||
}
|
||||
|
||||
TransformationController _transformFor(String id) {
|
||||
if (id == _heroId) return _heroTransform;
|
||||
return _pageTransforms.putIfAbsent(id, () {
|
||||
final transform = TransformationController();
|
||||
transform.addListener(_syncZoom);
|
||||
return transform;
|
||||
});
|
||||
}
|
||||
|
||||
void _syncZoom() {
|
||||
final zoomed = _transformFor(_current.id).value.getMaxScaleOnAxis() > 1.01;
|
||||
if (zoomed == _zoomed) return;
|
||||
_zoomed = zoomed;
|
||||
_syncSwipe();
|
||||
}
|
||||
|
||||
void _updatePointers(int delta) {
|
||||
final next = _pointers + delta;
|
||||
_pointers = next < 0 ? 0 : next;
|
||||
_syncSwipe();
|
||||
}
|
||||
|
||||
void _syncSwipe() {
|
||||
final enabled = _pointers < 2 && !_zoomed;
|
||||
if (enabled == _swipeEnabled) return;
|
||||
setState(() => _swipeEnabled = enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_heroTransform.dispose();
|
||||
for (final transform in _pageTransforms.values) {
|
||||
transform.dispose();
|
||||
}
|
||||
for (final session in _videoSessions.values) {
|
||||
session.dispose();
|
||||
}
|
||||
@@ -396,6 +434,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
setState(() => _index = index);
|
||||
_activateVideoSessions();
|
||||
_syncHero();
|
||||
_syncZoom();
|
||||
if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore());
|
||||
}
|
||||
|
||||
@@ -561,6 +600,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
Future<void> _saveAs() async {
|
||||
if (_saving) return;
|
||||
setState(() => _saving = true);
|
||||
SaveReadyImage? image;
|
||||
try {
|
||||
final item = _current;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
@@ -576,6 +616,12 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
final cacheName = _cacheNameFor(photo, url);
|
||||
if (url.isNotEmpty) download = _photoDownload(item, photo, cacheName);
|
||||
saveName = 'IMG_$now.jpg';
|
||||
if (file != null) {
|
||||
image = await prepareImageForSave(file);
|
||||
if (image != null) {
|
||||
saveName = withImageExtension(saveName, image.extension);
|
||||
}
|
||||
}
|
||||
} else if (video != null) {
|
||||
file = await _videoFileFor(item);
|
||||
final cacheName = _videoCacheName(item, video);
|
||||
@@ -592,7 +638,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
return;
|
||||
}
|
||||
final result = await saveFileAs(
|
||||
source: file,
|
||||
source: image?.file ?? file,
|
||||
fileName: saveName,
|
||||
dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs,
|
||||
);
|
||||
@@ -610,6 +656,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
} catch (_) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось сохранить файл');
|
||||
} finally {
|
||||
await image?.discard();
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
@@ -685,16 +732,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
autofocus: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PageView.builder(
|
||||
key: ValueKey(_pager),
|
||||
controller: _controller,
|
||||
reverse: true,
|
||||
itemCount: _items.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
itemBuilder: (_, i) => _buildPage(i),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: _buildPager()),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_chromeVisible,
|
||||
@@ -762,6 +800,36 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPager() {
|
||||
final media = MediaQuery.of(context);
|
||||
final deviceSlop = media.gestureSettings.touchSlop ?? kTouchSlop;
|
||||
final swipeSlop = deviceSlop > kPanSlop ? deviceSlop : kPanSlop;
|
||||
final swipeMedia = media.copyWith(
|
||||
gestureSettings: DeviceGestureSettings(touchSlop: swipeSlop),
|
||||
);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (_) => _updatePointers(1),
|
||||
onPointerUp: (_) => _updatePointers(-1),
|
||||
onPointerCancel: (_) => _updatePointers(-1),
|
||||
child: MediaQuery(
|
||||
data: swipeMedia,
|
||||
child: PageView.builder(
|
||||
key: ValueKey(_pager),
|
||||
controller: _controller,
|
||||
reverse: true,
|
||||
physics: _swipeEnabled ? null : const NeverScrollableScrollPhysics(),
|
||||
itemCount: _items.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
itemBuilder: (_, i) => MediaQuery(
|
||||
data: _zoomed ? media : swipeMedia,
|
||||
child: _buildPage(i),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(int i) {
|
||||
final item = _items[i];
|
||||
final video = item.video;
|
||||
@@ -781,7 +849,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
child: InteractiveViewer(
|
||||
minScale: 1,
|
||||
maxScale: 5,
|
||||
transformationController: isHero ? _heroTransform : null,
|
||||
transformationController: _transformFor(item.id),
|
||||
child: Center(
|
||||
child: RotatedBox(
|
||||
quarterTurns: _quarterTurns[item.id] ?? 0,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr/qr.dart';
|
||||
|
||||
class QrCodeView extends StatefulWidget {
|
||||
final String data;
|
||||
final double size;
|
||||
final Color moduleColor;
|
||||
final Widget? center;
|
||||
final double centerRatio;
|
||||
|
||||
const QrCodeView({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.size,
|
||||
required this.moduleColor,
|
||||
this.center,
|
||||
this.centerRatio = 0.24,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrCodeView> createState() => _QrCodeViewState();
|
||||
}
|
||||
|
||||
class _QrCodeViewState extends State<QrCodeView> {
|
||||
late QrImage _image;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_encode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QrCodeView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.data != widget.data) _encode();
|
||||
}
|
||||
|
||||
void _encode() {
|
||||
_image = QrImage(
|
||||
QrCode(
|
||||
payload: QrPayload.fromString(widget.data),
|
||||
errorCorrectLevel: QrErrorCorrectLevel.high,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final center = widget.center;
|
||||
return SizedBox.square(
|
||||
dimension: widget.size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size.square(widget.size),
|
||||
painter: _QrPainter(
|
||||
image: _image,
|
||||
color: widget.moduleColor,
|
||||
holeRatio: center == null ? 0 : widget.centerRatio,
|
||||
),
|
||||
),
|
||||
if (center != null)
|
||||
SizedBox.square(
|
||||
dimension: widget.size * widget.centerRatio,
|
||||
child: center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QrPainter extends CustomPainter {
|
||||
final QrImage image;
|
||||
final Color color;
|
||||
final double holeRatio;
|
||||
|
||||
const _QrPainter({
|
||||
required this.image,
|
||||
required this.color,
|
||||
required this.holeRatio,
|
||||
});
|
||||
|
||||
static const double _bleed = 0.3;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final count = image.moduleCount;
|
||||
final cell = size.shortestSide / count;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..isAntiAlias = true;
|
||||
|
||||
final side = count * cell;
|
||||
final holeRadius = holeRatio <= 0 ? 0.0 : side * holeRatio / 2 + cell;
|
||||
|
||||
bool isFinder(int row, int col) {
|
||||
final top = row < 7;
|
||||
final bottom = row >= count - 7;
|
||||
final left = col < 7;
|
||||
final right = col >= count - 7;
|
||||
return (top && left) || (top && right) || (bottom && left);
|
||||
}
|
||||
|
||||
bool isDark(int row, int col) {
|
||||
if (row < 0 || col < 0 || row >= count || col >= count) return false;
|
||||
if (isFinder(row, col)) return false;
|
||||
if (holeRadius > 0) {
|
||||
final dx = (col + 0.5) * cell - side / 2;
|
||||
final dy = (row + 0.5) * cell - side / 2;
|
||||
if (dx * dx + dy * dy <= holeRadius * holeRadius) return false;
|
||||
}
|
||||
return image.isDark(row, col);
|
||||
}
|
||||
|
||||
final radius = cell / 2;
|
||||
final path = Path()..fillType = PathFillType.nonZero;
|
||||
|
||||
for (var row = 0; row < count; row++) {
|
||||
for (var col = 0; col < count; col++) {
|
||||
if (!isDark(row, col)) continue;
|
||||
final up = isDark(row - 1, col);
|
||||
final down = isDark(row + 1, col);
|
||||
final left = isDark(row, col - 1);
|
||||
final right = isDark(row, col + 1);
|
||||
final rect = Rect.fromLTWH(
|
||||
col * cell - _bleed,
|
||||
row * cell - _bleed,
|
||||
cell + _bleed * 2,
|
||||
cell + _bleed * 2,
|
||||
);
|
||||
path.addRRect(
|
||||
RRect.fromRectAndCorners(
|
||||
rect,
|
||||
topLeft: Radius.circular(up || left ? 0 : radius),
|
||||
topRight: Radius.circular(up || right ? 0 : radius),
|
||||
bottomLeft: Radius.circular(down || left ? 0 : radius),
|
||||
bottomRight: Radius.circular(down || right ? 0 : radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
|
||||
_paintFinder(canvas, paint, cell, 0, 0);
|
||||
_paintFinder(canvas, paint, cell, 0, count - 7);
|
||||
_paintFinder(canvas, paint, cell, count - 7, 0);
|
||||
}
|
||||
|
||||
void _paintFinder(
|
||||
Canvas canvas,
|
||||
Paint paint,
|
||||
double cell,
|
||||
int row,
|
||||
int col,
|
||||
) {
|
||||
final outer = Rect.fromLTWH(col * cell, row * cell, cell * 7, cell * 7);
|
||||
final ring = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRRect(RRect.fromRectAndRadius(outer, Radius.circular(cell * 2)))
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
outer.deflate(cell),
|
||||
Radius.circular(cell * 1.4),
|
||||
),
|
||||
);
|
||||
canvas.drawPath(ring, paint);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
outer.deflate(cell * 2),
|
||||
Radius.circular(cell * 1.1),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_QrPainter oldDelegate) =>
|
||||
oldDelegate.image != image ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.holeRatio != holeRatio;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/media/preview_image.dart';
|
||||
import '../../models/attachment.dart';
|
||||
|
||||
class ReplyPreview {
|
||||
const ReplyPreview._({this.icon, this.media, this.round = false});
|
||||
|
||||
final IconData? icon;
|
||||
final MessageAttachment? media;
|
||||
final bool round;
|
||||
|
||||
bool get hasMedia => media != null;
|
||||
|
||||
static ReplyPreview of({String? text, List<MessageAttachment>? attachments}) {
|
||||
final list = attachments;
|
||||
if (list == null || list.isEmpty) return const ReplyPreview._();
|
||||
final attachment = list.first;
|
||||
final icon = _iconFor(attachment.type);
|
||||
final captioned = text != null && text.trim().isNotEmpty;
|
||||
if (captioned || !_hasThumbnail(attachment)) {
|
||||
return ReplyPreview._(icon: icon);
|
||||
}
|
||||
return ReplyPreview._(
|
||||
icon: icon,
|
||||
media: attachment,
|
||||
round: attachment is VideoAttachment && attachment.isNote,
|
||||
);
|
||||
}
|
||||
|
||||
Size box({required double maxSide, double minSide = 72}) {
|
||||
final attachment = media;
|
||||
if (round) return Size(maxSide, maxSide);
|
||||
final width = _sizeOf(attachment, horizontal: true);
|
||||
final height = _sizeOf(attachment, horizontal: false);
|
||||
if (width == null || height == null || width <= 0 || height <= 0) {
|
||||
return Size(maxSide, maxSide);
|
||||
}
|
||||
final ratio = width / height;
|
||||
if (ratio >= 1) {
|
||||
return Size(maxSide, (maxSide / ratio).clamp(minSide, maxSide));
|
||||
}
|
||||
return Size((maxSide * ratio).clamp(minSide, maxSide), maxSide);
|
||||
}
|
||||
|
||||
Widget thumbnail({
|
||||
required Size size,
|
||||
required ColorScheme cs,
|
||||
double radius = 8,
|
||||
}) {
|
||||
final attachment = media;
|
||||
if (attachment == null) return const SizedBox.shrink();
|
||||
final url = _networkThumbnail(attachment);
|
||||
final local = dataUriImage(attachment, attachment.previewData);
|
||||
|
||||
Widget fallback() => Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
icon ?? Symbols.image,
|
||||
size: size.shortestSide * 0.4,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
Widget localImage() => Image(
|
||||
image: local!,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => fallback(),
|
||||
);
|
||||
|
||||
final Widget image;
|
||||
if (url != null) {
|
||||
image = CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, _) => local == null ? fallback() : localImage(),
|
||||
errorWidget: (_, _, _) => local == null ? fallback() : localImage(),
|
||||
);
|
||||
} else if (local != null) {
|
||||
image = localImage();
|
||||
} else {
|
||||
image = fallback();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
round ? size.shortestSide / 2 : radius,
|
||||
),
|
||||
child: image,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _hasThumbnail(MessageAttachment attachment) {
|
||||
if (attachment is! PhotoAttachment && attachment is! VideoAttachment) {
|
||||
return false;
|
||||
}
|
||||
return _networkThumbnail(attachment) != null ||
|
||||
dataUriImage(attachment, attachment.previewData) != null;
|
||||
}
|
||||
|
||||
static String? _networkThumbnail(MessageAttachment attachment) {
|
||||
final candidates = <String?>[
|
||||
if (attachment is VideoAttachment) attachment.thumbnail,
|
||||
attachment.baseUrl,
|
||||
];
|
||||
for (final url in candidates) {
|
||||
if (url == null || url.isEmpty || url.startsWith('data:')) continue;
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _sizeOf(
|
||||
MessageAttachment? attachment, {
|
||||
required bool horizontal,
|
||||
}) {
|
||||
if (attachment is PhotoAttachment) {
|
||||
return horizontal ? attachment.width : attachment.height;
|
||||
}
|
||||
if (attachment is VideoAttachment) {
|
||||
return horizontal ? attachment.width : attachment.height;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static IconData? _iconFor(AttachmentType type) => switch (type) {
|
||||
AttachmentType.photo => Symbols.image,
|
||||
AttachmentType.video => Symbols.videocam,
|
||||
AttachmentType.audio => Symbols.mic,
|
||||
AttachmentType.file => Symbols.description,
|
||||
AttachmentType.sticker => Symbols.emoji_emotions,
|
||||
AttachmentType.contact => Symbols.person,
|
||||
AttachmentType.location => Symbols.location_on,
|
||||
AttachmentType.poll => Symbols.bar_chart,
|
||||
AttachmentType.call => Symbols.call,
|
||||
AttachmentType.share => Symbols.link,
|
||||
AttachmentType.forward => Symbols.forward,
|
||||
AttachmentType.unknown => Symbols.attach_file,
|
||||
AttachmentType.control || AttachmentType.inlineKeyboard => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/config/app_colors.dart';
|
||||
|
||||
/// Кружок выбора сообщения. Диаметр и отступ снизу подстраиваются под высоту
|
||||
/// строки: на мелком кегле строка ниже кружка, и без подгонки кружки соседних
|
||||
/// сообщений налезают друг на друга.
|
||||
class SelectionCheckCircle extends StatelessWidget {
|
||||
const SelectionCheckCircle({
|
||||
super.key,
|
||||
required this.selected,
|
||||
this.diameter = maxDiameter,
|
||||
});
|
||||
|
||||
static const double maxDiameter = 24;
|
||||
static const double minDiameter = 14;
|
||||
static const double preferredBottomInset = 10;
|
||||
static const double _verticalGap = 4;
|
||||
|
||||
final bool selected;
|
||||
final double diameter;
|
||||
|
||||
static double diameterFor(double rowHeight) {
|
||||
if (!rowHeight.isFinite) return maxDiameter;
|
||||
final fitted = (rowHeight - _verticalGap)
|
||||
.clamp(minDiameter, maxDiameter)
|
||||
.toDouble();
|
||||
return fitted < rowHeight ? fitted : rowHeight;
|
||||
}
|
||||
|
||||
static double bottomInsetFor(double rowHeight, double diameter) =>
|
||||
rowHeight.isFinite
|
||||
? ((rowHeight - diameter) / 2).clamp(0.0, preferredBottomInset).toDouble()
|
||||
: preferredBottomInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? cs.primary : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: selected ? cs.primary : cs.mutedText,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: selected
|
||||
? Icon(
|
||||
Symbols.check,
|
||||
size: diameter * 0.66,
|
||||
weight: 700,
|
||||
color: cs.onPrimary,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,31 @@ class SheetButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grabber row that keeps the pill centred while an action sits at the edge.
|
||||
class SheetGrabberBar extends StatelessWidget {
|
||||
static const double height = 34;
|
||||
static const double actionInset = 8;
|
||||
|
||||
final Widget action;
|
||||
|
||||
const SheetGrabberBar({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const SheetGrabber(margin: EdgeInsets.zero),
|
||||
Positioned(right: actionInset, child: action),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The little drag "grabber" pill shown at the top of a bottom sheet.
|
||||
class SheetGrabber extends StatelessWidget {
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class TextWithMeta extends MultiChildRenderObjectWidget {
|
||||
final bool fillWidth;
|
||||
|
||||
TextWithMeta({
|
||||
super.key,
|
||||
required Widget text,
|
||||
required Widget meta,
|
||||
this.fillWidth = false,
|
||||
}) : super(children: [text, meta]);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
RenderTextWithMeta(fillWidth);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
RenderTextWithMeta renderObject,
|
||||
) {
|
||||
renderObject.fillWidth = fillWidth;
|
||||
}
|
||||
}
|
||||
|
||||
class TextWithMetaParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class RenderTextWithMeta extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, TextWithMetaParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, TextWithMetaParentData> {
|
||||
RenderTextWithMeta(this._fillWidth);
|
||||
|
||||
static const double _gap = 8;
|
||||
static const double _baselineNudge = 2;
|
||||
|
||||
bool _fillWidth;
|
||||
set fillWidth(bool value) {
|
||||
if (value == _fillWidth) return;
|
||||
_fillWidth = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
RenderBox get _text => firstChild!;
|
||||
RenderBox get _meta => lastChild!;
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! TextWithMetaParentData) {
|
||||
child.parentData = TextWithMetaParentData();
|
||||
}
|
||||
}
|
||||
|
||||
RenderParagraph? _soleParagraph() {
|
||||
RenderParagraph? found;
|
||||
var seen = 0;
|
||||
void visit(RenderObject node) {
|
||||
if (node is RenderParagraph) {
|
||||
found = node;
|
||||
seen++;
|
||||
return;
|
||||
}
|
||||
node.visitChildren(visit);
|
||||
}
|
||||
|
||||
_text.visitChildren(visit);
|
||||
if (_text is RenderParagraph) {
|
||||
found = _text as RenderParagraph;
|
||||
seen = 1;
|
||||
}
|
||||
return seen == 1 ? found : null;
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) =>
|
||||
_text.getMinIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) =>
|
||||
_text.getMaxIntrinsicWidth(height) +
|
||||
_gap +
|
||||
_meta.getMaxIntrinsicWidth(height);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) =>
|
||||
_text.getMinIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) =>
|
||||
_text.getMaxIntrinsicHeight(width) + _meta.getMaxIntrinsicHeight(width);
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) =>
|
||||
BaselineOffset(_text.getDistanceToActualBaseline(baseline)).offset;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
_meta.layout(const BoxConstraints(), parentUsesSize: true);
|
||||
final metaSize = _meta.size;
|
||||
|
||||
_text.layout(constraints.loosen(), parentUsesSize: true);
|
||||
final textSize = _text.size;
|
||||
|
||||
final lineWidth = _fillWidth && constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: textSize.width;
|
||||
final paragraph = _soleParagraph();
|
||||
final needed = _gap + metaSize.width;
|
||||
|
||||
double width;
|
||||
double height;
|
||||
var metaOnOwnLine = false;
|
||||
|
||||
if (paragraph != null) {
|
||||
final length = paragraph.text.toPlainText().length;
|
||||
final caret = paragraph.getOffsetForCaret(
|
||||
TextPosition(offset: length),
|
||||
Rect.zero,
|
||||
);
|
||||
final lastLine = caret.dx;
|
||||
final singleLine = caret.dy < 0.5;
|
||||
if (lastLine + needed <= lineWidth) {
|
||||
width = lineWidth;
|
||||
height = textSize.height;
|
||||
} else if (singleLine && lastLine + needed <= constraints.maxWidth) {
|
||||
width = lastLine + needed;
|
||||
height = textSize.height;
|
||||
} else {
|
||||
width = math.max(lineWidth, metaSize.width);
|
||||
height = textSize.height + metaSize.height;
|
||||
metaOnOwnLine = true;
|
||||
}
|
||||
} else {
|
||||
width = math.max(lineWidth, metaSize.width);
|
||||
height = textSize.height + metaSize.height;
|
||||
metaOnOwnLine = true;
|
||||
}
|
||||
|
||||
size = constraints.constrain(Size(width, height));
|
||||
|
||||
(_text.parentData! as TextWithMetaParentData).offset = Offset.zero;
|
||||
(_meta.parentData! as TextWithMetaParentData).offset = Offset(
|
||||
math.max(0, size.width - metaSize.width),
|
||||
metaOnOwnLine
|
||||
? size.height - metaSize.height
|
||||
: size.height - metaSize.height - _baselineNudge,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user