fix(app): camera preview distortion — swap sensor W/H for portrait

previewSize 报横向(W>H),竖屏直用会把画面拉扁。交换宽高转纵向比例再
FittedBox cover 铺满(裁切不变形)。397/397 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-24 16:07:44 +08:00
parent c199d0630c
commit 07761b1b26
9 changed files with 306 additions and 47 deletions
+54 -47
View File
@@ -62,14 +62,16 @@ class CameraPluginSource extends CaptureSource {
throw CameraException('noCamera', 'no camera available');
}
CameraDescription pick() => cameras.firstWhere(
(c) => c.lensDirection ==
(preferFront
? CameraLensDirection.front
: CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(pick(), ResolutionPreset.max,
enableAudio: false);
(c) =>
c.lensDirection ==
(preferFront ? CameraLensDirection.front : CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(
pick(),
ResolutionPreset.max,
enableAudio: false,
);
await controller.initialize();
return CameraPluginSource._(controller);
}
@@ -84,15 +86,17 @@ class CameraPluginSource extends CaptureSource {
@override
Widget buildPreview() {
// 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满——
// CameraPreview 直填父约束会把画面拉扁/拉高。
// 纵横比校正(§4 页面 13):传感器 previewSize 报的是**横向**(width >
// height),竖屏 App 直接用它会把画面拉扁——需交换宽高转为纵向比例,
// 再 FittedBox cover 铺满(裁切不变形)。
final size = _controller.value.previewSize;
Widget preview = CameraPreview(_controller);
if (size != null && size.width > 0 && size.height > 0) {
preview = FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(width: size.width, height: size.height, child: preview),
// 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。
child: SizedBox(width: size.height, height: size.width, child: preview),
);
}
return preview;
@@ -102,9 +106,8 @@ class CameraPluginSource extends CaptureSource {
bool get supportsFlash => true;
@override
Future<void> setFlashEnabled(bool enabled) => _controller.setFlashMode(
enabled ? FlashMode.always : FlashMode.off,
);
Future<void> setFlashEnabled(bool enabled) =>
_controller.setFlashMode(enabled ? FlashMode.always : FlashMode.off);
@override
Future<void> dispose() => _controller.dispose();
@@ -113,7 +116,8 @@ class CameraPluginSource extends CaptureSource {
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function({bool preferFront})>(
(ref) => CameraPluginSource.create);
(ref) => CameraPluginSource.create,
);
// ── 连拍页 ────────────────────────────────────────────────────────────────
@@ -207,9 +211,11 @@ class _CapturePageState extends ConsumerState<CapturePage> {
});
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed);
setState(
() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed,
);
}
}
@@ -247,23 +253,25 @@ class _CapturePageState extends ConsumerState<CapturePage> {
final source = _source;
if (source == null || _phase != _CameraPhase.ready) return;
final categoryId = _categoryId;
_shotTail = _shotTail.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
if (enqueued > 0 && mounted) _flashEnqueued();
}).catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
_shotTail = _shotTail
.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
if (enqueued > 0 && mounted) _flashEnqueued();
})
.catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
}
/// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。
@@ -488,14 +496,19 @@ class _TopBar extends StatelessWidget {
child: Row(
children: [
IconButton(
tooltip: MaterialLocalizations.of(context)
.backButtonTooltip, // 语义标签;深色界面固定白色前景。
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip, // 语义标签;深色界面固定白色前景。
color: Colors.white,
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
const SizedBox(width: kSpace4),
const Icon(Icons.photo_album_outlined, size: 16, color: Colors.white70),
const Icon(
Icons.photo_album_outlined,
size: 16,
color: Colors.white70,
),
const SizedBox(width: 6),
Expanded(
child: Text(
@@ -609,10 +622,7 @@ class _Badge extends StatelessWidget {
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(color: foreground, fontSize: 12),
),
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
);
}
}
@@ -711,11 +721,8 @@ class _LastShotButton extends StatelessWidget {
content = Image.file(
File(lastShotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.image_outlined,
size: 24,
color: Colors.white,
),
errorBuilder: (_, _, _) =>
const Icon(Icons.image_outlined, size: 24, color: Colors.white),
);
}
return IconButton(