Files
Qlyra/lib/frontend/widgets/attachment/attachment_sheet.dart
T

686 lines
19 KiB
Dart

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';
import 'package:komet/core/utils/format.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';
const List<PillNavItem> _navItems = [
PillNavItem(icon: Symbols.image, label: 'Галерея'),
PillNavItem(icon: Symbols.description, label: 'Файл'),
PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'),
PillNavItem(icon: Symbols.person, label: 'Контакт'),
];
Future<void> showAttachmentSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (_) => const AttachmentSheet(),
);
}
class AttachmentSheet extends StatefulWidget {
const AttachmentSheet({super.key});
@override
State<AttachmentSheet> createState() => _AttachmentSheetState();
}
class _AttachmentSheetState extends State<AttachmentSheet> {
final GallerySource _source = GallerySource.create();
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final PageController _pageController = PageController();
bool _navDragging = false;
double _navDragBasePageT = 0;
double _navDragAccumDx = 0;
bool _loading = true;
GalleryPermission _permission = GalleryPermission.granted;
List<GalleryItem> _items = const [];
@override
void initState() {
super.initState();
_loadGallery();
}
@override
void dispose() {
_pageController.dispose();
_selected.dispose();
super.dispose();
}
Future<void> _loadGallery() async {
setState(() => _loading = true);
final permission = await _source.ensurePermission();
if (!mounted) return;
if (permission == GalleryPermission.denied) {
setState(() {
_permission = permission;
_items = const [];
_loading = false;
});
return;
}
final items = await _source.load(limit: 120);
if (!mounted) return;
setState(() {
_permission = permission;
_items = items;
_loading = false;
});
}
void _toggleSelection(GalleryItem item) {
final next = Set<String>.from(_selected.value);
if (!next.remove(item.id)) next.add(item.id);
_selected.value = next;
}
void _onSectionTap(int index) {
_pageController.animateToPage(
index,
duration: _navAnim,
curve: Curves.easeOutCubic,
);
}
void _onCameraTap() {
showCustomNotification(context, 'Камера скоро появится');
}
void _onSend() {
final count = _selected.value.length;
final overlay = Overlay.of(context, rootOverlay: true);
Navigator.of(context).pop();
showCustomNotificationOnOverlay(
overlay,
'Отправка $count выбранных скоро появится',
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
initialChildSize: 0.62,
minChildSize: 0.4,
maxChildSize: 0.94,
expand: false,
snap: true,
snapSizes: const [0.62, 0.94],
builder: (context, scrollController) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final barReserve = _barHeight + bottomInset;
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const SheetGrabber(),
Expanded(
child: Stack(
children: [
_buildPages(scrollController, cs, barReserve),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildBottomBar(),
),
Positioned(
right: 16,
bottom: barReserve + 8,
child: AnimatedBuilder(
animation: Listenable.merge([
_selected,
_pageController,
]),
builder: (context, _) {
final count = _selected.value.length;
final galleryT = (1 - _currentPageT()).clamp(
0.0,
1.0,
);
if (count == 0 || galleryT == 0) {
return const SizedBox.shrink();
}
return Opacity(
opacity: galleryT,
child: IgnorePointer(
ignoring: galleryT < 0.5,
child: _buildSendButton(cs, count),
),
);
},
),
),
],
),
),
],
),
);
},
);
}
static const double _pillMargin = 10;
static const double _barHeight = SlidingPillNav.height + _pillMargin;
static const Duration _navAnim = Duration(milliseconds: 300);
Widget _buildPages(
ScrollController scrollController,
ColorScheme cs,
double bottomReserve,
) {
return PageView(
controller: _pageController,
children: [
_KeepAlivePage(
child: _buildGalleryPage(scrollController, cs, bottomReserve),
),
_buildPlaceholderPage(cs, bottomReserve),
_buildPlaceholderPage(cs, bottomReserve),
_buildPlaceholderPage(cs, bottomReserve),
],
);
}
Widget _buildGalleryPage(
ScrollController scrollController,
ColorScheme cs,
double bottomReserve,
) {
if (_loading) {
return Center(child: CircularProgressIndicator(color: cs.primary));
}
if (_permission == GalleryPermission.denied) {
return _buildDenied(scrollController, cs, bottomReserve);
}
if (_items.isEmpty) {
return _buildMessage(
scrollController,
cs,
'Изображений не найдено',
bottomReserve,
);
}
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,
),
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),
),
),
],
);
}
Widget _buildLimitedBanner(ColorScheme cs) {
return InkWell(
onTap: () => _source.manageAccess().then((_) => _loadGallery()),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: cs.surfaceContainerHighest,
child: Row(
children: [
Icon(Symbols.info, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(
'Доступны не все фото',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
Text(
'Изменить',
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
Widget _buildPlaceholderPage(ColorScheme cs, double bottomReserve) {
return Padding(
padding: EdgeInsets.only(bottom: bottomReserve),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Раздел в разработке',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
],
),
),
);
}
Widget _buildDenied(
ScrollController scrollController,
ColorScheme cs,
double bottomReserve,
) {
return _scrollableCenter(
scrollController,
bottomReserve,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Нет доступа к галерее',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Разрешите доступ к фото, чтобы выбрать их отсюда',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _loadGallery,
child: const Text('Разрешить'),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => _source.openSettings(),
child: const Text('Настройки'),
),
],
),
],
),
),
);
}
Widget _buildMessage(
ScrollController scrollController,
ColorScheme cs,
String text,
double bottomReserve,
) {
return _scrollableCenter(
scrollController,
bottomReserve,
Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)),
);
}
Widget _scrollableCenter(
ScrollController scrollController,
double bottomReserve,
Widget child,
) {
return CustomScrollView(
controller: scrollController,
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.only(bottom: bottomReserve),
child: Center(child: child),
),
),
],
);
}
Widget _buildSendButton(ColorScheme cs, int count) {
return Material(
color: cs.primary,
shape: const StadiumBorder(),
elevation: 3,
child: InkWell(
customBorder: const StadiumBorder(),
onTap: _onSend,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500),
const SizedBox(width: 8),
Text(
'$count',
style: TextStyle(
color: cs.onPrimary,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
double _currentPageT() {
if (!_pageController.hasClients) return 0;
return _pageController.page ?? 0;
}
void _onPillDragStart() {
_navDragging = true;
_navDragBasePageT = _currentPageT();
_navDragAccumDx = 0;
}
void _onPillDragUpdate(double dx, double inactiveWidth) {
if (!_navDragging || !_pageController.hasClients) return;
_navDragAccumDx += dx;
final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp(
0.0,
3.0,
);
_pageController.jumpTo(pageT * _pageController.position.viewportDimension);
}
void _onPillDragEnd() {
if (!_navDragging) return;
_navDragging = false;
final target = _currentPageT().round().clamp(0, 3);
_pageController.animateToPage(
target,
duration: _navAnim,
curve: Curves.easeOutCubic,
);
}
Widget _buildBottomBar() {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
child: LayoutBuilder(
builder: (context, constraints) {
final geometry = PillNavGeometry.fromInnerWidth(
constraints.maxWidth - 4,
_navItems.length,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) => _onPillDragStart(),
onHorizontalDragUpdate: (d) =>
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
onHorizontalDragEnd: (_) => _onPillDragEnd(),
onHorizontalDragCancel: _onPillDragEnd,
child: AnimatedBuilder(
animation: _pageController,
builder: (context, _) {
return SlidingPillNav(
items: _navItems,
position: _currentPageT(),
geometry: geometry,
onTap: _onSectionTap,
);
},
),
);
},
),
),
);
}
}
class _KeepAlivePage extends StatefulWidget {
final Widget child;
const _KeepAlivePage({required this.child});
@override
State<_KeepAlivePage> createState() => _KeepAlivePageState();
}
class _KeepAlivePageState extends State<_KeepAlivePage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class _CameraTile extends StatelessWidget {
final VoidCallback onTap;
final ColorScheme cs;
const _CameraTile({required this.onTap, required this.cs});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
color: cs.surfaceContainerHighest,
alignment: Alignment.center,
child: Icon(
Symbols.photo_camera,
size: 34,
color: cs.onSurface,
weight: 400,
),
),
);
}
}
class _GalleryTile extends StatefulWidget {
final GalleryItem item;
final ValueListenable<Set<String>> selectedIds;
final VoidCallback onTap;
final ColorScheme cs;
const _GalleryTile({
super.key,
required this.item,
required this.selectedIds,
required this.onTap,
required this.cs,
});
@override
State<_GalleryTile> createState() => _GalleryTileState();
}
class _GalleryTileState extends State<_GalleryTile> {
late bool _selected;
@override
void initState() {
super.initState();
_selected = widget.selectedIds.value.contains(widget.item.id);
widget.selectedIds.addListener(_onSelectionChanged);
}
@override
void dispose() {
widget.selectedIds.removeListener(_onSelectionChanged);
super.dispose();
}
void _onSelectionChanged() {
final selected = widget.selectedIds.value.contains(widget.item.id);
if (selected != _selected) setState(() => _selected = selected);
}
@override
Widget build(BuildContext context) {
final item = widget.item;
return GestureDetector(
onTap: widget.onTap,
child: Stack(
fit: StackFit.expand,
children: [
AnimatedScale(
scale: _selected ? 0.86 : 1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: _Thumbnail(item: item, cs: widget.cs),
),
if (item.isVideo)
Positioned(
left: 6,
bottom: 6,
child: Row(
children: [
Icon(
Symbols.play_arrow,
size: 16,
color: Colors.white,
fill: 1,
),
if (item.duration != null)
Text(
formatDurationMmSs(item.duration!),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 3, color: Colors.black54)],
),
),
],
),
),
Positioned(
top: 6,
right: 6,
child: _SelectionCheck(selected: _selected, cs: widget.cs),
),
],
),
);
}
}
class _SelectionCheck extends StatelessWidget {
final bool selected;
final ColorScheme cs;
const _SelectionCheck({required this.selected, required this.cs});
@override
Widget build(BuildContext context) {
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
border: Border.all(color: Colors.white, width: 2),
),
child: selected
? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700)
: null,
);
}
}
class _Thumbnail extends StatefulWidget {
final GalleryItem item;
final ColorScheme cs;
const _Thumbnail({required this.item, required this.cs});
@override
State<_Thumbnail> createState() => _ThumbnailState();
}
class _ThumbnailState extends State<_Thumbnail> {
static const int _pixelSize = 320;
Future<Uint8List?>? _future;
@override
void initState() {
super.initState();
if (widget.item.localFile == null) {
_future = widget.item.thumbnail(_pixelSize);
}
}
@override
Widget build(BuildContext context) {
final file = widget.item.localFile;
if (file != null) {
return Image.file(
file,
fit: BoxFit.cover,
cacheWidth: _pixelSize,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
}
return FutureBuilder<Uint8List?>(
future: _future,
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) return _placeholder();
return Image.memory(
data,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
},
);
}
Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest);
}