Files
buzz/mobile/lib/features/channels/compose_bar/camera_preview.dart
klopez4212 ffb12d3b05 Fix mobile thread tail and iOS channel header (#6399)
## Summary

- Hide the thread Latest control once the lazy tail is fully reached.
- Polish the mobile channel header and use native iOS liquid glass for
Back.
- Keep the two-line header aligned and safe at larger text sizes.

## Validation

- `just mobile-check`
- `flutter test` (1,553 tests)
- Signed iPhone build installed and launched

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-08-21 15:27:30 +01:00

333 lines
9.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

part of '../compose_bar.dart';
class _InlineCameraPreview extends HookConsumerWidget {
final bool initializeCamera;
final Future<void> Function(XFile image) onCapture;
final VoidCallback onClose;
const _InlineCameraPreview({
required this.initializeCamera,
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(() {
if (!initializeCamera) return null;
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()),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!disposed) unawaited(initialize());
});
return () {
disposed = true;
lifecycleListener.dispose();
generation += 1;
final current = controllerRef.value;
controllerRef.value = null;
unawaited(current?.dispose() ?? Future<void>.value());
};
}, [initializeCamera]);
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;
final usesAndroidCameraLayout =
defaultTargetPlatform == TargetPlatform.android;
return ColoredBox(
color: Colors.black,
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: usesAndroidCameraLayout ? null : Grid.xxs,
left: usesAndroidCameraLayout ? Grid.twelve : null,
right: usesAndroidCameraLayout ? null : Grid.xxs,
bottom: usesAndroidCameraLayout
? Grid.twelve + ((_cameraCaptureSize - _cameraBackSize) / 2)
: null,
child: _CameraCloseButton(
onTap: onClose,
emphasized: usesAndroidCameraLayout,
),
),
],
),
);
}
}
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 BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: 'Starting camera',
)
: 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 : () => _runComposerAction(onTap),
child: AnimatedScale(
scale: isPressed ? 0.92 : 1,
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: _cameraCaptureSize,
height: _cameraCaptureSize,
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;
final bool emphasized;
const _CameraCloseButton({required this.onTap, required this.emphasized});
@override
Widget build(BuildContext context) {
if (Theme.of(context).platform == TargetPlatform.iOS) {
return IosGlassNavigationButton(
icon: IosGlassNavigationIcon.back,
semanticLabel: 'Back to attachment options',
onPressed: () => _runComposerAction(onTap),
width: emphasized ? _cameraBackSize : 40,
height: emphasized ? _cameraBackSize : 40,
foregroundColor: Colors.white,
);
}
return SizedBox.square(
dimension: emphasized ? _cameraBackSize : 36,
child: IconButton(
onPressed: () => _runComposerAction(onTap),
tooltip: 'Back to attachment options',
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(
alpha: emphasized ? 0.68 : 0.56,
),
foregroundColor: Colors.white,
),
icon: Icon(LucideIcons.arrowLeft, size: emphasized ? 24 : 18),
),
);
}
}
const _cameraCaptureSize = 64.0;
const _cameraBackSize = 44.0;
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.';
}