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

This commit is contained in:
sevenhill
2026-09-10 17:55:55 +03:00
parent 50816588e4
commit b22654b99f
4976 changed files with 18539 additions and 15865 deletions
@@ -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),
),
],
),
),
],
);
}
}
+113 -4
View File
@@ -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(
+96 -21
View File
@@ -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,
+3 -6
View File
@@ -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;
}
+88 -17
View File
@@ -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;
}
}
+181 -209
View File
@@ -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),
);
}
}
+80 -12
View File
@@ -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,
+184
View File
@@ -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;
}
+152
View File
@@ -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,
);
}
}
+25
View File
@@ -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;
+162
View File
@@ -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);
}
}