1082 lines
32 KiB
Dart
1082 lines
32 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import 'package:komet/core/config/app_frost.dart';
|
|
import 'package:komet/core/config/app_nav_pill_style.dart';
|
|
import 'package:komet/core/config/app_visual_style.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/attachment/photo_editor.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';
|
|
import 'package:komet/l10n/app_localizations.dart';
|
|
|
|
const int _navItemCount = 5;
|
|
|
|
List<PillNavItem> _buildNavItems(AppLocalizations l10n) => [
|
|
PillNavItem(icon: Symbols.image, label: l10n.attachSheetGallery),
|
|
PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile),
|
|
PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation),
|
|
PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll),
|
|
PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback),
|
|
];
|
|
|
|
Future<void> showAttachmentSheet(
|
|
BuildContext context, {
|
|
String? title,
|
|
void Function(List<PickedPhoto> photos, String caption)? onSend,
|
|
VoidCallback? onPickFile,
|
|
VoidCallback? onShareLocation,
|
|
VoidCallback? onCreatePoll,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
requestFocus: false,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: Colors.black.withValues(alpha: 0.45),
|
|
builder: (_) => AttachmentSheet(
|
|
title: title,
|
|
onSend: onSend,
|
|
onPickFile: onPickFile,
|
|
onShareLocation: onShareLocation,
|
|
onCreatePoll: onCreatePoll,
|
|
),
|
|
);
|
|
}
|
|
|
|
class AttachmentSheet extends StatefulWidget {
|
|
final String? title;
|
|
final void Function(List<PickedPhoto> photos, String caption)? onSend;
|
|
final VoidCallback? onPickFile;
|
|
final VoidCallback? onShareLocation;
|
|
final VoidCallback? onCreatePoll;
|
|
|
|
const AttachmentSheet({
|
|
super.key,
|
|
this.title,
|
|
this.onSend,
|
|
this.onPickFile,
|
|
this.onShareLocation,
|
|
this.onCreatePoll,
|
|
});
|
|
|
|
@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 Map<String, PhotoEditState> _edits = {};
|
|
final Set<String> _tempFiles = {};
|
|
final Set<String> _sentFiles = {};
|
|
final TextEditingController _captionCtrl = TextEditingController();
|
|
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();
|
|
final cached = _cachedItems;
|
|
if (cached != null) {
|
|
_items = cached;
|
|
_permission = _cachedPermission;
|
|
_loading = false;
|
|
_loadGallery(silent: true);
|
|
} else {
|
|
_loadGallery();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pageController.dispose();
|
|
_selected.dispose();
|
|
_captionCtrl.dispose();
|
|
for (final path in _tempFiles) {
|
|
if (_sentFiles.contains(path)) continue;
|
|
File(path).delete().then((_) {}, onError: (_) {});
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
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 [];
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
final items = await _source.load(limit: 120);
|
|
if (!mounted) return;
|
|
_cachedItems = items;
|
|
_cachedPermission = permission;
|
|
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 _openPreview(GalleryItem item) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => MediaPreviewScreen(
|
|
item: item,
|
|
title: widget.title,
|
|
selectedIds: _selected,
|
|
onToggleSelection: () => _toggleSelection(item),
|
|
onSend: () => _sendSelection(fallback: item),
|
|
editState: _edits[item.id],
|
|
onEditChanged: (state) {
|
|
if (mounted) setState(() => _edits[item.id] = state);
|
|
},
|
|
initialCaption: _captionCtrl.text,
|
|
onCaptionChanged: (text) => _captionCtrl.text = text,
|
|
tempFiles: _tempFiles,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onSectionTap(int index) {
|
|
_pageController.animateToPage(
|
|
index,
|
|
duration: _navAnim,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
|
|
void _onCameraTap() {
|
|
showCustomNotification(
|
|
context,
|
|
AppLocalizations.of(context)!.attachSheetCameraComingSoon,
|
|
);
|
|
}
|
|
|
|
void _sendSelection({GalleryItem? fallback}) {
|
|
final ids = _selected.value;
|
|
var chosen = _items.where((it) => ids.contains(it.id)).toList();
|
|
if (chosen.isEmpty && fallback != null) chosen = [fallback];
|
|
if (chosen.isEmpty) return;
|
|
final picked = chosen
|
|
.map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working))
|
|
.toList();
|
|
final callback = widget.onSend;
|
|
if (callback != null) {
|
|
for (final photo in picked) {
|
|
final path = photo.editedFile?.path;
|
|
if (path != null) _sentFiles.add(path);
|
|
}
|
|
}
|
|
final caption = _captionCtrl.text.trim();
|
|
Navigator.of(context).pop();
|
|
callback?.call(picked, caption);
|
|
}
|
|
|
|
@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 +
|
|
MediaQuery.viewInsetsOf(context).bottom,
|
|
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),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static const double _pillMargin = 10;
|
|
static const double _barHeight = SlidingPillNav.height + _pillMargin;
|
|
static const Duration _navAnim = Duration(milliseconds: 300);
|
|
|
|
Color _composerColor(ColorScheme cs) => Color.alphaBlend(
|
|
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
|
cs.surface,
|
|
);
|
|
|
|
Color _composerBorderColor(ColorScheme cs) =>
|
|
cs.outlineVariant.withValues(alpha: 0.5);
|
|
|
|
Widget _buildPages(
|
|
ScrollController scrollController,
|
|
ColorScheme cs,
|
|
double bottomReserve,
|
|
) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return PageView(
|
|
controller: _pageController,
|
|
children: [
|
|
_KeepAlivePage(
|
|
child: _buildGalleryPage(scrollController, cs, bottomReserve),
|
|
),
|
|
_buildActionPage(
|
|
cs,
|
|
bottomReserve,
|
|
icon: Symbols.description,
|
|
title: l10n.attachSheetSendFileTitle,
|
|
subtitle: l10n.attachSheetSendFileSubtitle,
|
|
buttonLabel: l10n.attachSheetChooseFileButton,
|
|
onTap: widget.onPickFile,
|
|
),
|
|
_buildActionPage(
|
|
cs,
|
|
bottomReserve,
|
|
icon: Symbols.location_on,
|
|
title: l10n.attachSheetShareLocationTitle,
|
|
subtitle: l10n.attachSheetShareLocationSubtitle,
|
|
buttonLabel: l10n.attachSheetSendLocationButton,
|
|
onTap: widget.onShareLocation,
|
|
),
|
|
_buildActionPage(
|
|
cs,
|
|
bottomReserve,
|
|
icon: Symbols.bar_chart,
|
|
title: l10n.attachSheetCreatePoll,
|
|
subtitle: l10n.attachSheetCreatePollSubtitle,
|
|
buttonLabel: l10n.attachSheetCreatePoll,
|
|
onTap: widget.onCreatePoll,
|
|
),
|
|
_buildPlaceholderPage(cs, bottomReserve),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildActionPage(
|
|
ColorScheme cs,
|
|
double bottomReserve, {
|
|
required IconData icon,
|
|
required String title,
|
|
required String subtitle,
|
|
required String buttonLabel,
|
|
required VoidCallback? onTap,
|
|
}) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: bottomReserve),
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 72,
|
|
height: 72,
|
|
decoration: BoxDecoration(
|
|
color: cs.primaryContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(icon, size: 34, color: cs.onPrimaryContainer),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: onTap == null
|
|
? null
|
|
: () {
|
|
Navigator.of(context).pop();
|
|
onTap();
|
|
},
|
|
child: Text(buttonLabel),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
AppLocalizations.of(context)!.attachSheetNoImagesFound,
|
|
bottomReserve,
|
|
);
|
|
}
|
|
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
editedFile: _edits[item.id]?.working,
|
|
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),
|
|
editedFile: _edits[item.id]?.working,
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLimitedBanner(ColorScheme cs) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
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(
|
|
l10n.attachSheetLimitedAccessInfo,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
),
|
|
Text(
|
|
l10n.loginEdit,
|
|
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(
|
|
AppLocalizations.of(context)!.attachSheetSectionInProgress,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDenied(
|
|
ScrollController scrollController,
|
|
ColorScheme cs,
|
|
double bottomReserve,
|
|
) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
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(
|
|
l10n.attachSheetNoGalleryAccessTitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.attachSheetNoGalleryAccessSubtitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _loadGallery,
|
|
child: Text(l10n.attachSheetAllow),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: () => _source.openSettings(),
|
|
child: Text(l10n.attachSheetSettings),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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) {
|
|
return Material(
|
|
color: cs.primary,
|
|
shape: const StadiumBorder(),
|
|
elevation: 3,
|
|
child: InkWell(
|
|
customBorder: const StadiumBorder(),
|
|
onTap: () => _sendSelection(),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Icon(Symbols.send, color: cs.onPrimary, size: 24, weight: 500),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
(_navItemCount - 1).toDouble(),
|
|
);
|
|
_pageController.jumpTo(pageT * _pageController.position.viewportDimension);
|
|
}
|
|
|
|
void _onPillDragEnd() {
|
|
if (!_navDragging) return;
|
|
_navDragging = false;
|
|
final target = _currentPageT().round().clamp(0, _navItemCount - 1);
|
|
_pageController.animateToPage(
|
|
target,
|
|
duration: _navAnim,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomBar() {
|
|
final inset = MediaQuery.viewInsetsOf(context).bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: inset),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
|
child: ValueListenableBuilder<Set<String>>(
|
|
valueListenable: _selected,
|
|
builder: (context, selected, _) {
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeIn,
|
|
child: selected.isEmpty
|
|
? _buildPillNav()
|
|
: _buildCaptionBar(Theme.of(context).colorScheme),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPillNav() {
|
|
final navItems = _buildNavItems(AppLocalizations.of(context)!);
|
|
return LayoutBuilder(
|
|
key: const ValueKey('nav'),
|
|
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, _) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return ValueListenableBuilder<VisualStyle>(
|
|
valueListenable: AppVisualStyle.current,
|
|
builder: (context, style, _) =>
|
|
ValueListenableBuilder<NavPillStyle>(
|
|
valueListenable: AppNavPillStyle.current,
|
|
builder: (context, navStyle, _) {
|
|
final frost =
|
|
style.glossyChrome &&
|
|
NavPillMaterial.isFrost(navStyle);
|
|
final liquid =
|
|
style.glossyChrome &&
|
|
NavPillMaterial.isLiquid(navStyle);
|
|
return SlidingPillNav(
|
|
items: navItems,
|
|
position: _currentPageT(),
|
|
geometry: geometry,
|
|
onTap: _onSectionTap,
|
|
backgroundColor: liquid
|
|
? null
|
|
: (frost
|
|
? AppFrost.navPillTint(cs)
|
|
: _composerColor(cs)),
|
|
borderColor: _composerBorderColor(cs),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildCaptionBar(ColorScheme cs) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return SizedBox(
|
|
key: const ValueKey('caption'),
|
|
height: SlidingPillNav.height,
|
|
child: Center(
|
|
child: Container(
|
|
height: 52,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
decoration: BoxDecoration(
|
|
color: _composerColor(cs),
|
|
borderRadius: BorderRadius.circular(26),
|
|
border: Border.all(color: _composerBorderColor(cs), width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _captionCtrl,
|
|
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
|
cursorColor: cs.primary,
|
|
decoration: InputDecoration(
|
|
isCollapsed: true,
|
|
border: InputBorder.none,
|
|
hintText: l10n.attachSheetAddCaptionHint,
|
|
hintStyle: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Symbols.photo_camera,
|
|
size: 34,
|
|
color: cs.onSurface,
|
|
weight: 400,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
AppLocalizations.of(context)!.attachSheetCamera,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GalleryTile extends StatefulWidget {
|
|
final GalleryItem item;
|
|
final ValueListenable<Set<String>> selectedIds;
|
|
final VoidCallback onOpen;
|
|
final VoidCallback onToggle;
|
|
final File? editedFile;
|
|
final ColorScheme cs;
|
|
|
|
const _GalleryTile({
|
|
super.key,
|
|
required this.item,
|
|
required this.selectedIds,
|
|
required this.onOpen,
|
|
required this.onToggle,
|
|
this.editedFile,
|
|
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.onOpen,
|
|
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,
|
|
editedFile: widget.editedFile,
|
|
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: 0,
|
|
right: 0,
|
|
child: GestureDetector(
|
|
onTap: widget.onToggle,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: ValueListenableBuilder<Set<String>>(
|
|
valueListenable: widget.selectedIds,
|
|
builder: (context, ids, _) {
|
|
final index = ids.toList().indexOf(widget.item.id);
|
|
return _SelectionCheck(
|
|
number: index >= 0 ? index + 1 : null,
|
|
cs: widget.cs,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SelectionCheck extends StatelessWidget {
|
|
final int? number;
|
|
final ColorScheme cs;
|
|
|
|
const _SelectionCheck({required this.number, required this.cs});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selected = number != null;
|
|
return Container(
|
|
width: 24,
|
|
height: 24,
|
|
alignment: Alignment.center,
|
|
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
|
|
? Text(
|
|
'$number',
|
|
style: TextStyle(
|
|
color: cs.onPrimary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.0,
|
|
),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Thumbnail extends StatefulWidget {
|
|
final GalleryItem item;
|
|
final File? editedFile;
|
|
final ColorScheme cs;
|
|
|
|
const _Thumbnail({required this.item, this.editedFile, 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 edited = widget.editedFile;
|
|
if (edited != null) {
|
|
return Image.file(
|
|
edited,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: _pixelSize,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => _placeholder(),
|
|
);
|
|
}
|
|
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);
|
|
}
|