mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
## Summary - replace the mobile composer sheet with a compact expanding capsule - add vertically stacked attachment actions and an inline camera preview - align suggestion, formatting, and send treatments with desktop ## Validation - `just mobile-check` - `just mobile-test` (541 passed, 1 skipped) --------- Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
313 lines
9.0 KiB
Dart
313 lines
9.0 KiB
Dart
part of '../compose_bar.dart';
|
||
|
||
class _InlineCameraPreview extends HookConsumerWidget {
|
||
final Future<void> Function(XFile image) onCapture;
|
||
final VoidCallback onClose;
|
||
|
||
const _InlineCameraPreview({required this.onCapture, required this.onClose});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final controller = useState<camera.CameraController?>(null);
|
||
final controllerRef = useRef<camera.CameraController?>(null);
|
||
final isInitializing = useState(true);
|
||
final isCapturing = useState(false);
|
||
final error = useState<String?>(null);
|
||
|
||
useEffect(() {
|
||
var disposed = false;
|
||
var generation = 0;
|
||
|
||
Future<void> disposeCurrent() async {
|
||
generation += 1;
|
||
final current = controllerRef.value;
|
||
controllerRef.value = null;
|
||
if (!disposed) controller.value = null;
|
||
await current?.dispose();
|
||
}
|
||
|
||
Future<void> initialize() async {
|
||
final currentGeneration = ++generation;
|
||
if (!disposed) {
|
||
isInitializing.value = true;
|
||
error.value = null;
|
||
}
|
||
|
||
camera.CameraController? next;
|
||
try {
|
||
final available = await camera.availableCameras();
|
||
if (disposed || currentGeneration != generation) return;
|
||
if (available.isEmpty) {
|
||
throw camera.CameraException(
|
||
'no-cameras',
|
||
'No cameras are available on this device.',
|
||
);
|
||
}
|
||
|
||
final description = available.firstWhere(
|
||
(candidate) =>
|
||
candidate.lensDirection == camera.CameraLensDirection.back,
|
||
orElse: () => available.first,
|
||
);
|
||
next = camera.CameraController(
|
||
description,
|
||
camera.ResolutionPreset.high,
|
||
enableAudio: false,
|
||
);
|
||
await next.initialize();
|
||
|
||
if (disposed || currentGeneration != generation) {
|
||
await next.dispose();
|
||
return;
|
||
}
|
||
controllerRef.value = next;
|
||
controller.value = next;
|
||
isInitializing.value = false;
|
||
} catch (cameraError) {
|
||
await next?.dispose();
|
||
if (!disposed && currentGeneration == generation) {
|
||
error.value = _cameraErrorMessage(cameraError);
|
||
isInitializing.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
final lifecycleListener = AppLifecycleListener(
|
||
onInactive: () => unawaited(disposeCurrent()),
|
||
onResume: () => unawaited(initialize()),
|
||
);
|
||
unawaited(initialize());
|
||
|
||
return () {
|
||
disposed = true;
|
||
lifecycleListener.dispose();
|
||
generation += 1;
|
||
final current = controllerRef.value;
|
||
controllerRef.value = null;
|
||
unawaited(current?.dispose() ?? Future<void>.value());
|
||
};
|
||
}, const []);
|
||
|
||
Future<void> capture() async {
|
||
final activeController = controller.value;
|
||
if (activeController == null ||
|
||
isCapturing.value ||
|
||
activeController.value.isTakingPicture) {
|
||
return;
|
||
}
|
||
|
||
isCapturing.value = true;
|
||
error.value = null;
|
||
try {
|
||
final image = await activeController.takePicture();
|
||
if (context.mounted) {
|
||
await processCapturedImage(image, onCapture);
|
||
} else {
|
||
await processCapturedImage(image, (_) async {});
|
||
}
|
||
} catch (captureError) {
|
||
if (context.mounted) {
|
||
error.value = _cameraErrorMessage(captureError);
|
||
}
|
||
} finally {
|
||
if (context.mounted) isCapturing.value = false;
|
||
}
|
||
}
|
||
|
||
final activeController = controller.value;
|
||
return Container(
|
||
width: double.infinity,
|
||
clipBehavior: Clip.antiAlias,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black,
|
||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||
),
|
||
foregroundDecoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||
border: Border.all(
|
||
color: Colors.black.withValues(alpha: 0.04),
|
||
width: 1,
|
||
),
|
||
),
|
||
child: AspectRatio(
|
||
aspectRatio: 4 / 3,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (activeController case final initialized?)
|
||
_CameraFeed(controller: initialized)
|
||
else
|
||
_CameraPlaceholder(
|
||
isInitializing: isInitializing.value,
|
||
message: error.value,
|
||
),
|
||
if (activeController != null)
|
||
Align(
|
||
alignment: Alignment.bottomCenter,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Grid.twelve),
|
||
child: _CameraCaptureButton(
|
||
isPressed: isCapturing.value,
|
||
onTap: capture,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: Grid.xxs,
|
||
right: Grid.xxs,
|
||
child: _CameraCloseButton(onTap: onClose),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CameraFeed extends StatelessWidget {
|
||
final camera.CameraController controller;
|
||
|
||
const _CameraFeed({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final previewSize = controller.value.previewSize;
|
||
if (previewSize == null) return const ColoredBox(color: Colors.black);
|
||
|
||
return ClipRect(
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: SizedBox(
|
||
width: previewSize.height,
|
||
height: previewSize.width,
|
||
child: camera.CameraPreview(controller),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CameraPlaceholder extends StatelessWidget {
|
||
final bool isInitializing;
|
||
final String? message;
|
||
|
||
const _CameraPlaceholder({
|
||
required this.isInitializing,
|
||
required this.message,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ColoredBox(
|
||
color: Colors.black,
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Grid.sm),
|
||
child: isInitializing
|
||
? const CircularProgressIndicator(
|
||
color: Colors.white,
|
||
strokeWidth: 2,
|
||
)
|
||
: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
LucideIcons.cameraOff,
|
||
color: Colors.white,
|
||
size: 28,
|
||
),
|
||
const SizedBox(height: Grid.xxs),
|
||
Text(
|
||
message ?? 'Camera isn’t available here.',
|
||
textAlign: TextAlign.center,
|
||
style: context.textTheme.bodyMedium?.copyWith(
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CameraCaptureButton extends StatelessWidget {
|
||
final bool isPressed;
|
||
final VoidCallback onTap;
|
||
|
||
const _CameraCaptureButton({required this.isPressed, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final duration = MediaQuery.disableAnimationsOf(context)
|
||
? Duration.zero
|
||
: const Duration(milliseconds: 100);
|
||
|
||
return Semantics(
|
||
button: true,
|
||
label: 'Take photo',
|
||
child: GestureDetector(
|
||
onTap: isPressed ? null : onTap,
|
||
child: AnimatedScale(
|
||
scale: isPressed ? 0.92 : 1,
|
||
duration: duration,
|
||
curve: Curves.easeOutCubic,
|
||
child: Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withValues(alpha: 0.24),
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 3),
|
||
),
|
||
padding: const EdgeInsets.all(Grid.half),
|
||
child: const DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CameraCloseButton extends StatelessWidget {
|
||
final VoidCallback onTap;
|
||
|
||
const _CameraCloseButton({required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox.square(
|
||
dimension: 36,
|
||
child: IconButton(
|
||
onPressed: onTap,
|
||
tooltip: 'Close camera',
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.black.withValues(alpha: 0.56),
|
||
foregroundColor: Colors.white,
|
||
),
|
||
icon: const Icon(LucideIcons.x, size: 18),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _cameraErrorMessage(Object error) {
|
||
if (error is camera.CameraException) {
|
||
return switch (error.code) {
|
||
'CameraAccessDenied' ||
|
||
'CameraAccessDeniedWithoutPrompt' ||
|
||
'CameraAccessRestricted' => 'Camera access is turned off for Buzz.',
|
||
'no-cameras' => 'Camera isn’t available on this device.',
|
||
_ => 'Camera couldn’t start. Try again.',
|
||
};
|
||
}
|
||
return 'Camera couldn’t start. Try again.';
|
||
}
|