слегка botomshet в чат скрин набурмалдил

This commit is contained in:
Jganenok
2026-06-09 18:42:28 +07:00
parent 580e30897b
commit 080ee8f644
4 changed files with 537 additions and 41 deletions
+1 -1
View File
@@ -1774,7 +1774,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
void _openAttachmentSheet() {
showAttachmentSheet(context);
showAttachmentSheet(context, title: widget.name);
}
Future<void> _pickAndUploadFile() async {
@@ -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<PillNavItem> _navItems = [
PillNavItem(icon: Symbols.person, label: 'Контакт'),
];
Future<void> showAttachmentSheet(BuildContext context) {
Future<void> showAttachmentSheet(BuildContext context, {String? title}) {
return showModalBottomSheet<void>(
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<AttachmentSheet> createState() => _AttachmentSheetState();
}
class _AttachmentSheetState extends State<AttachmentSheet> {
static List<GalleryItem>? _cachedItems;
static GalleryPermission _cachedPermission = GalleryPermission.granted;
final GallerySource _source = GallerySource.create();
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final PageController _pageController = PageController();
@@ -48,7 +55,15 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
@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<AttachmentSheet> {
super.dispose();
}
Future<void> _loadGallery() async {
setState(() => _loading = true);
Future<void> _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<AttachmentSheet> {
}
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<AttachmentSheet> {
_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<AttachmentSheet> {
);
}
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 <GalleryItem>[];
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<GalleryItem> 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<Set<String>> 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),
),
),
),
],
),
@@ -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<Set<String>> 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<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
}
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
final TextEditingController _caption = TextEditingController();
Future<File?>? _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<File?>(
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<Set<String>>(
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<Set<String>> 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<Set<String>>(
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),
),
),
);
}
}
+5
View File
@@ -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