9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
399 lines
13 KiB
Dart
399 lines
13 KiB
Dart
part of '../compose_bar.dart';
|
|
|
|
class _PhotoGalleryPicker extends StatelessWidget {
|
|
final VoidCallback onBack;
|
|
final Future<List<XFile>> Function() onPickAllPhotos;
|
|
final Future<void> Function(List<XFile> photos) onChoosePhotos;
|
|
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
|
|
|
|
const _PhotoGalleryPicker({
|
|
required this.onBack,
|
|
required this.onPickAllPhotos,
|
|
required this.onChoosePhotos,
|
|
required this.onChooseAllPhotos,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fallback = _RecentPhotoGalleryPicker(
|
|
onBack: onBack,
|
|
onPickAllPhotos: onPickAllPhotos,
|
|
onChoosePhotos: onChoosePhotos,
|
|
onChooseAllPhotos: onChooseAllPhotos,
|
|
);
|
|
if (defaultTargetPlatform != TargetPlatform.iOS) return fallback;
|
|
return _IOSInlinePhotoPicker(
|
|
onBack: onBack,
|
|
onPickAllPhotos: onPickAllPhotos,
|
|
onChoosePhotos: onChoosePhotos,
|
|
onChooseAllPhotos: onChooseAllPhotos,
|
|
fallback: fallback,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
|
final VoidCallback onBack;
|
|
final Future<List<XFile>> Function() onPickAllPhotos;
|
|
final Future<void> Function(List<XFile> photos) onChoosePhotos;
|
|
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
|
|
|
|
const _RecentPhotoGalleryPicker({
|
|
required this.onBack,
|
|
required this.onPickAllPhotos,
|
|
required this.onChoosePhotos,
|
|
required this.onChooseAllPhotos,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final recentPhotos = useMemoized(
|
|
ref.read(photoLibraryProvider).loadRecentPhotos,
|
|
);
|
|
final recentSnapshot = useFuture(recentPhotos);
|
|
final selection = useState<List<RecentPhoto>>([]);
|
|
final isResolving = useState(false);
|
|
final actionError = useState<String?>(null);
|
|
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
|
|
|
void togglePhoto(RecentPhoto photo) {
|
|
if (isResolving.value) return;
|
|
final current = selection.value;
|
|
final existingIndex = current.indexWhere((item) => item.id == photo.id);
|
|
selection.value = existingIndex < 0
|
|
? [...current, photo]
|
|
: [
|
|
...current.take(existingIndex),
|
|
...current.skip(existingIndex + 1),
|
|
];
|
|
}
|
|
|
|
Future<void> choosePhotos() async {
|
|
if (isResolving.value) return;
|
|
isResolving.value = true;
|
|
actionError.value = null;
|
|
try {
|
|
final photos = selection.value.isEmpty
|
|
? await onPickAllPhotos()
|
|
: await ref
|
|
.read(photoLibraryProvider)
|
|
.resolveSelectedPhotos(selection.value);
|
|
if (photos.isNotEmpty && context.mounted) {
|
|
await (selection.value.isEmpty ? onChooseAllPhotos : onChoosePhotos)(
|
|
photos,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
actionError.value = selection.value.isEmpty
|
|
? context.l10n.unablePhotoLibrary
|
|
: context.l10n.preparingPhotos;
|
|
}
|
|
} finally {
|
|
if (context.mounted) isResolving.value = false;
|
|
}
|
|
}
|
|
|
|
final selectedCount = selection.value.length;
|
|
final actionLabel = selectedCount == 0
|
|
? context.l10n.allPhotos
|
|
: context.l10n.addPhoto(selectedCount);
|
|
|
|
Widget buildGalleryBody() {
|
|
if (recentSnapshot.connectionState != ConnectionState.done) {
|
|
return Center(
|
|
child: BuzzLoadingIndicator(
|
|
size: 44,
|
|
semanticLabel: context.l10n.loadingRecentPhotos,
|
|
),
|
|
);
|
|
}
|
|
if (recentSnapshot.hasError) {
|
|
return _PhotoGalleryMessage(
|
|
icon: LucideIcons.images,
|
|
title: context.l10n.recentPhotosUnavailableTitle,
|
|
message: context.l10n.recentPhotosUnavailableMessage,
|
|
);
|
|
}
|
|
|
|
final photos = recentSnapshot.data ?? const <RecentPhoto>[];
|
|
if (photos.isEmpty) {
|
|
return _PhotoGalleryMessage(
|
|
icon: LucideIcons.images,
|
|
title: context.l10n.noRecentPhotosTitle,
|
|
message: context.l10n.noRecentPhotosMessage,
|
|
);
|
|
}
|
|
return GridView.builder(
|
|
key: const ValueKey('recent-photo-grid'),
|
|
padding: EdgeInsets.zero,
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
crossAxisSpacing: Grid.quarter,
|
|
mainAxisSpacing: Grid.quarter,
|
|
),
|
|
itemCount: photos.length,
|
|
itemBuilder: (context, index) {
|
|
final photo = photos[index];
|
|
final selectionIndex = selection.value.indexWhere(
|
|
(item) => item.id == photo.id,
|
|
);
|
|
return _RecentPhotoTile(
|
|
photo: photo,
|
|
selectionIndex: selectionIndex,
|
|
reducedMotion: reducedMotion,
|
|
onTap: () => _runComposerAction(() => togglePhoto(photo)),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
key: const ValueKey('photo-gallery-picker'),
|
|
padding: const EdgeInsets.all(Grid.xxs),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 40,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
key: const ValueKey('photo-gallery-back'),
|
|
onPressed: isResolving.value
|
|
? null
|
|
: () => _runComposerAction(onBack),
|
|
tooltip: context.l10n.backAttachmentOptions,
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(LucideIcons.arrowLeft, size: 20),
|
|
),
|
|
const SizedBox(width: Grid.quarter),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.recentPhotos,
|
|
style: context.textTheme.titleSmall?.copyWith(
|
|
color: context.colors.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (selectedCount > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: Grid.half),
|
|
child: Text(
|
|
context.l10n.selectedCount(selectedCount),
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.half),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
Expanded(child: buildGalleryBody()),
|
|
if (actionError.value case final error?) ...[
|
|
const SizedBox(height: Grid.half),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 72),
|
|
child: SingleChildScrollView(
|
|
child: Text(
|
|
error,
|
|
key: const ValueKey('photo-gallery-error'),
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: selectedCount == 0
|
|
? OutlinedButton.icon(
|
|
key: const ValueKey('photo-gallery-action'),
|
|
onPressed: isResolving.value
|
|
? null
|
|
: () => _runComposerAction(
|
|
() => unawaited(choosePhotos()),
|
|
),
|
|
icon: isResolving.value
|
|
? BuzzLoadingIndicator(
|
|
size: 22,
|
|
color: context.colors.primary,
|
|
semanticLabel: context.l10n.openingAllPhotos,
|
|
)
|
|
: const Icon(LucideIcons.images, size: 18),
|
|
label: Text(actionLabel),
|
|
)
|
|
: FilledButton.icon(
|
|
key: const ValueKey('photo-gallery-action'),
|
|
onPressed: isResolving.value
|
|
? null
|
|
: () => _runComposerAction(
|
|
() => unawaited(choosePhotos()),
|
|
),
|
|
icon: isResolving.value
|
|
? BuzzLoadingIndicator(
|
|
size: 22,
|
|
color: Colors.white,
|
|
semanticLabel: context.l10n.preparingPhotos,
|
|
)
|
|
: const Icon(LucideIcons.plus, size: 18),
|
|
label: Text(actionLabel),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecentPhotoTile extends StatelessWidget {
|
|
final RecentPhoto photo;
|
|
final int selectionIndex;
|
|
final bool reducedMotion;
|
|
final VoidCallback onTap;
|
|
|
|
const _RecentPhotoTile({
|
|
required this.photo,
|
|
required this.selectionIndex,
|
|
required this.reducedMotion,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isSelected = selectionIndex >= 0;
|
|
return Semantics(
|
|
button: true,
|
|
selected: isSelected,
|
|
label: isSelected
|
|
? context.l10n.photoSelected(selectionIndex + 1)
|
|
: context.l10n.selectPhoto,
|
|
child: GestureDetector(
|
|
key: ValueKey('recent-photo-${photo.id}'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
child: Image.memory(
|
|
photo.thumbnailBytes,
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
),
|
|
),
|
|
AnimatedContainer(
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? context.colors.primary
|
|
: Colors.transparent,
|
|
width: isSelected ? 3 : 0,
|
|
),
|
|
color: isSelected
|
|
? Colors.black.withValues(alpha: 0.08)
|
|
: Colors.transparent,
|
|
),
|
|
),
|
|
PositionedDirectional(
|
|
top: Grid.half,
|
|
end: Grid.half,
|
|
child: AnimatedScale(
|
|
scale: isSelected ? 1 : 0.8,
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: AnimatedOpacity(
|
|
opacity: isSelected ? 1 : 0,
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 100),
|
|
child: Container(
|
|
key: ValueKey('photo-selection-index-${photo.id}'),
|
|
width: 24,
|
|
height: 24,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: context.colors.primary,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 1.5),
|
|
),
|
|
child: Text(
|
|
'${selectionIndex + 1}',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onPrimary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PhotoGalleryMessage extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String message;
|
|
|
|
const _PhotoGalleryMessage({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.message,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(Grid.sm),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 32, color: context.colors.onSurfaceVariant),
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
title,
|
|
style: context.textTheme.titleSmall?.copyWith(
|
|
color: context.colors.onSurface,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: Grid.quarter),
|
|
Text(
|
|
message,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|