From 080ee8f644602c34576524d343b341c6d33a8452 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Tue, 9 Jun 2026 18:42:28 +0700 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B3=D0=BA=D0=B0=20botomshe?= =?UTF-8?q?t=20=D0=B2=20=D1=87=D0=B0=D1=82=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=D0=B1=D1=83=D1=80=D0=BC=D0=B0=D0=BB=D0=B4?= =?UTF-8?q?=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 2 +- .../widgets/attachment/attachment_sheet.dart | 203 ++++++++-- .../attachment/media_preview_screen.dart | 368 ++++++++++++++++++ linux/CMakeLists.txt | 5 + 4 files changed, 537 insertions(+), 41 deletions(-) create mode 100644 lib/frontend/widgets/attachment/media_preview_screen.dart diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 51688e2..d5404a5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1774,7 +1774,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } void _openAttachmentSheet() { - showAttachmentSheet(context); + showAttachmentSheet(context, title: widget.name); } Future _pickAndUploadFile() async { diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 1467aed..fc12169 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; +import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; @@ -15,24 +16,30 @@ const List _navItems = [ PillNavItem(icon: Symbols.person, label: 'Контакт'), ]; -Future showAttachmentSheet(BuildContext context) { +Future showAttachmentSheet(BuildContext context, {String? title}) { return showModalBottomSheet( context: context, isScrollControlled: true, + requestFocus: false, backgroundColor: Colors.transparent, barrierColor: Colors.black.withValues(alpha: 0.45), - builder: (_) => const AttachmentSheet(), + builder: (_) => AttachmentSheet(title: title), ); } class AttachmentSheet extends StatefulWidget { - const AttachmentSheet({super.key}); + final String? title; + + const AttachmentSheet({super.key, this.title}); @override State createState() => _AttachmentSheetState(); } class _AttachmentSheetState extends State { + static List? _cachedItems; + static GalleryPermission _cachedPermission = GalleryPermission.granted; + final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); final PageController _pageController = PageController(); @@ -48,7 +55,15 @@ class _AttachmentSheetState extends State { @override void initState() { super.initState(); - _loadGallery(); + final cached = _cachedItems; + if (cached != null) { + _items = cached; + _permission = _cachedPermission; + _loading = false; + _loadGallery(silent: true); + } else { + _loadGallery(); + } } @override @@ -58,11 +73,12 @@ class _AttachmentSheetState extends State { super.dispose(); } - Future _loadGallery() async { - setState(() => _loading = true); + Future _loadGallery({bool silent = false}) async { + if (!silent) setState(() => _loading = true); final permission = await _source.ensurePermission(); if (!mounted) return; if (permission == GalleryPermission.denied) { + _cachedItems = null; setState(() { _permission = permission; _items = const []; @@ -72,6 +88,8 @@ class _AttachmentSheetState extends State { } final items = await _source.load(limit: 120); if (!mounted) return; + _cachedItems = items; + _cachedPermission = permission; setState(() { _permission = permission; _items = items; @@ -85,6 +103,20 @@ class _AttachmentSheetState extends State { _selected.value = next; } + void _openPreview(GalleryItem item) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MediaPreviewScreen( + item: item, + title: widget.title, + selectedIds: _selected, + onToggleSelection: () => _toggleSelection(item), + onSend: _onSend, + ), + ), + ); + } + void _onSectionTap(int index) { _pageController.animateToPage( index, @@ -218,30 +250,102 @@ class _AttachmentSheetState extends State { ); } - return CustomScrollView( - controller: scrollController, - slivers: [ - if (_permission == GalleryPermission.limited) - SliverToBoxAdapter(child: _buildLimitedBanner(cs)), - SliverPadding( - padding: EdgeInsets.fromLTRB(2, 2, 2, bottomReserve + 6), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 2, - crossAxisSpacing: 2, + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 2.0; + const hpad = 2.0; + final cell = (constraints.maxWidth - hpad * 2 - spacing * 2) / 3; + final headerHeight = cell * 2 + spacing; + final headerPhotos = _items.take(4).toList(); + final gridPhotos = _items.length > 4 + ? _items.sublist(4) + : const []; + + return CustomScrollView( + controller: scrollController, + slivers: [ + if (_permission == GalleryPermission.limited) + SliverToBoxAdapter(child: _buildLimitedBanner(cs)), + SliverPadding( + padding: const EdgeInsets.fromLTRB(hpad, hpad, hpad, 0), + sliver: SliverToBoxAdapter( + child: SizedBox( + height: headerHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: cell, + child: _CameraTile(onTap: _onCameraTap, cs: cs), + ), + const SizedBox(width: spacing), + Expanded(child: _buildHeaderPhotos(headerPhotos, cs)), + ], + ), + ), + ), ), - delegate: SliverChildBuilderDelegate((context, index) { - if (index == 0) return _CameraTile(onTap: _onCameraTap, cs: cs); - final item = _items[index - 1]; - return _GalleryTile( - key: ValueKey(item.id), - item: item, - selectedIds: _selected, - onTap: () => _toggleSelection(item), - cs: cs, - ); - }, childCount: _items.length + 1), + SliverPadding( + padding: EdgeInsets.fromLTRB(hpad, spacing, hpad, bottomReserve + 6), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = gridPhotos[index]; + return _GalleryTile( + key: ValueKey(item.id), + item: item, + selectedIds: _selected, + onOpen: () => _openPreview(item), + onToggle: () => _toggleSelection(item), + cs: cs, + ); + }, childCount: gridPhotos.length), + ), + ), + ], + ); + }, + ); + } + + Widget _buildHeaderPhotos(List photos, ColorScheme cs) { + const spacing = 2.0; + Widget tile(int i) { + if (i >= photos.length) return const SizedBox.expand(); + final item = photos[i]; + return _GalleryTile( + key: ValueKey(item.id), + item: item, + selectedIds: _selected, + onOpen: () => _openPreview(item), + onToggle: () => _toggleSelection(item), + cs: cs, + ); + } + + return Column( + children: [ + Expanded( + child: Row( + children: [ + Expanded(child: tile(0)), + const SizedBox(width: spacing), + Expanded(child: tile(1)), + ], + ), + ), + const SizedBox(height: spacing), + Expanded( + child: Row( + children: [ + Expanded(child: tile(2)), + const SizedBox(width: spacing), + Expanded(child: tile(3)), + ], ), ), ], @@ -509,11 +613,21 @@ class _CameraTile extends StatelessWidget { child: Container( color: cs.surfaceContainerHighest, alignment: Alignment.center, - child: Icon( - Symbols.photo_camera, - size: 34, - color: cs.onSurface, - weight: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.photo_camera, + size: 34, + color: cs.onSurface, + weight: 400, + ), + const SizedBox(height: 6), + Text( + 'Камера', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + ], ), ), ); @@ -523,14 +637,16 @@ class _CameraTile extends StatelessWidget { class _GalleryTile extends StatefulWidget { final GalleryItem item; final ValueListenable> selectedIds; - final VoidCallback onTap; + final VoidCallback onOpen; + final VoidCallback onToggle; final ColorScheme cs; const _GalleryTile({ super.key, required this.item, required this.selectedIds, - required this.onTap, + required this.onOpen, + required this.onToggle, required this.cs, }); @@ -563,7 +679,7 @@ class _GalleryTileState extends State<_GalleryTile> { Widget build(BuildContext context) { final item = widget.item; return GestureDetector( - onTap: widget.onTap, + onTap: widget.onOpen, child: Stack( fit: StackFit.expand, children: [ @@ -599,9 +715,16 @@ class _GalleryTileState extends State<_GalleryTile> { ), ), Positioned( - top: 6, - right: 6, - child: _SelectionCheck(selected: _selected, cs: widget.cs), + top: 0, + right: 0, + child: GestureDetector( + onTap: widget.onToggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(6), + child: _SelectionCheck(selected: _selected, cs: widget.cs), + ), + ), ), ], ), diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart new file mode 100644 index 0000000..130bb91 --- /dev/null +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -0,0 +1,368 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/media/gallery_source.dart'; + +const Color _kAccent = Color(0xFF2F8FFF); +const Color _kBar = Color(0xFF1E1E1E); + +class MediaPreviewScreen extends StatefulWidget { + final GalleryItem item; + final String? title; + final ValueListenable> selectedIds; + final VoidCallback onToggleSelection; + final VoidCallback onSend; + + const MediaPreviewScreen({ + super.key, + required this.item, + required this.selectedIds, + required this.onToggleSelection, + required this.onSend, + this.title, + }); + + @override + State createState() => _MediaPreviewScreenState(); +} + +class _MediaPreviewScreenState extends State { + final TextEditingController _caption = TextEditingController(); + Future? _fileFuture; + + @override + void initState() { + super.initState(); + if (widget.item.localFile == null) { + _fileFuture = widget.item.originFile(); + } + } + + @override + void dispose() { + _caption.dispose(); + super.dispose(); + } + + void _send() { + Navigator.of(context).pop(); + widget.onSend(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + surfaceTintColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text( + widget.title ?? '', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 14), + child: _SelectionToggle( + selectedIds: widget.selectedIds, + id: widget.item.id, + onTap: widget.onToggleSelection, + ), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Center( + child: InteractiveViewer( + minScale: 1, + maxScale: 4, + child: _buildImage(), + ), + ), + ), + _buildBottomBar(), + ], + ), + ); + } + + Widget _buildImage() { + final local = widget.item.localFile; + if (local != null) { + return Image.file(local, fit: BoxFit.contain); + } + return FutureBuilder( + future: _fileFuture, + builder: (context, snapshot) { + final file = snapshot.data; + if (file == null) { + return const SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white24, + ), + ); + } + return Image.file(file, fit: BoxFit.contain); + }, + ); + } + + Widget _buildBottomBar() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCaptionField(), + const SizedBox(height: 10), + _buildToolbar(), + ], + ), + ), + ); + } + + Widget _buildCaptionField() { + return Container( + decoration: BoxDecoration( + color: _kBar, + borderRadius: BorderRadius.circular(28), + ), + padding: const EdgeInsets.fromLTRB(20, 6, 8, 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _caption, + style: const TextStyle(color: Colors.white, fontSize: 15), + cursorColor: Colors.white, + decoration: const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: 'Добавить подпись...', + hintStyle: TextStyle(color: Colors.white54, fontSize: 15), + ), + ), + ), + const SizedBox(width: 8), + ValueListenableBuilder>( + valueListenable: widget.selectedIds, + builder: (context, selected, _) { + final count = selected.isEmpty ? 1 : selected.length; + return _CountBadge(count: count); + }, + ), + ], + ), + ); + } + + Widget _buildToolbar() { + return Row( + children: [ + Expanded( + child: Container( + height: 52, + decoration: BoxDecoration( + color: _kBar, + borderRadius: BorderRadius.circular(28), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _ToolIcon(icon: Symbols.crop_rotate, onTap: () {}), + _ToolIcon(icon: Symbols.brush, onTap: () {}), + _QualityBadge(onTap: () {}), + _ToolIcon(icon: Symbols.tune, onTap: () {}), + ], + ), + ), + ), + const SizedBox(width: 10), + _SendButton(onTap: _send), + ], + ); + } +} + +class _SelectionToggle extends StatelessWidget { + final ValueListenable> selectedIds; + final String id; + final VoidCallback onTap; + + const _SelectionToggle({ + required this.selectedIds, + required this.id, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: selectedIds, + builder: (context, selected, _) { + final isSelected = selected.contains(id); + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? _kAccent : Colors.transparent, + border: Border.all(color: Colors.white, width: 2), + ), + child: isSelected + ? const Icon( + Symbols.check, + color: Colors.white, + size: 18, + weight: 700, + ) + : null, + ), + ); + }, + ); + } +} + +class _CountBadge extends StatelessWidget { + final int count; + + const _CountBadge({required this.count}); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: const _DashedCirclePainter(color: Colors.white), + child: SizedBox( + width: 34, + height: 34, + child: Center( + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + +class _DashedCirclePainter extends CustomPainter { + final Color color; + + const _DashedCirclePainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + final rect = Rect.fromLTWH(1.5, 1.5, size.width - 3, size.height - 3); + const dashes = 22; + const sweep = (2 * math.pi) / dashes; + const dashRatio = 0.55; + for (var i = 0; i < dashes; i++) { + canvas.drawArc(rect, i * sweep, sweep * dashRatio, false, paint); + } + } + + @override + bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) => + oldDelegate.color != color; +} + +class _ToolIcon extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + + const _ToolIcon({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + icon: Icon(icon, color: Colors.white, size: 24), + ); + } +} + +class _QualityBadge extends StatelessWidget { + final VoidCallback onTap; + + const _QualityBadge({required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'SD', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _SendButton extends StatelessWidget { + final VoidCallback onTap; + + const _SendButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: _kAccent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: const SizedBox( + width: 52, + height: 52, + child: Icon(Symbols.send, color: Colors.white, size: 24, fill: 1), + ), + ), + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 106dcd4..fecd5de 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -75,6 +75,11 @@ set_target_properties(${BINARY_NAME} # them to the application. include(flutter/generated_plugins.cmake) +if(TARGET flutter_secure_storage_linux_plugin) + target_compile_options(flutter_secure_storage_linux_plugin PRIVATE + -Wno-error=deprecated-literal-operator) +endif() + # === Installation === # By default, "installing" just makes a relocatable bundle in the build