feat(app): P0 batch 1 — photo viewer zoom, camera lifecycle, image memory management
P0-2 查看器缩放:InteractiveViewer(5x max,放大后平移,1x 时穿透到 PageView 滑动切页) P0-3 相机后台释放:WidgetsBindingObserver——paused 时 dispose(省电+ 隐私指示灯),resumed 时重开(沿用前后摄/闪光灯偏好) P0-6 图片内存管理:PhotoThumbnail 加 maxCacheWidth 参数——网格/列表 缩略图限制解码宽度(600/400px),查看器大图不限 397/397 全绿、analyze 0 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -380,7 +380,11 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
||||||
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
|
PhotoThumbnail(
|
||||||
|
url: album.cover?.url,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
maxCacheWidth: 600,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
kSpace16,
|
kSpace16,
|
||||||
@@ -761,6 +765,7 @@ class _RecentPhotosSection extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) => PhotoThumbnail(
|
itemBuilder: (context, index) => PhotoThumbnail(
|
||||||
url: photos[index].preview?.thumb.url,
|
url: photos[index].preview?.thumb.url,
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
|
maxCacheWidth: 400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
|
|||||||
@@ -568,43 +568,65 @@ class _ViewerImage extends StatefulWidget {
|
|||||||
class _ViewerImageState extends State<_ViewerImage> {
|
class _ViewerImageState extends State<_ViewerImage> {
|
||||||
bool _autoRefreshScheduled = false;
|
bool _autoRefreshScheduled = false;
|
||||||
|
|
||||||
|
/// 当前缩放比例(1.0 = 原始;> 1.0 = 放大中,PageView 滑动被禁用)。
|
||||||
|
/// 通过 [InheritedWidget] 通知父级 PageView 切换 physics。
|
||||||
|
final ValueNotifier<double> _scaleNotifier = ValueNotifier(1.0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scaleNotifier.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return Image.network(
|
// P0-2(UI06 分析 §二):双指缩放 + 放大后平移;1x 时手势穿透到
|
||||||
widget.imageUrl,
|
// PageView 左右滑动切页(InteractiveViewer 在内容不溢出时不拦截)。
|
||||||
fit: BoxFit.contain,
|
return InteractiveViewer(
|
||||||
gaplessPlayback: true,
|
maxScale: 5.0,
|
||||||
loadingBuilder: (context, child, progress) => progress == null
|
minScale: 1.0,
|
||||||
? child
|
clipBehavior: Clip.none,
|
||||||
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
onInteractionUpdate: (details) => _scaleNotifier.value = details.scale,
|
||||||
errorBuilder: (_, _, _) {
|
onInteractionEnd: (_) {
|
||||||
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
|
// 回弹到 1x 时恢复 PageView 滑动。
|
||||||
if (!_autoRefreshScheduled &&
|
if (_scaleNotifier.value <= 1.01) _scaleNotifier.value = 1.0;
|
||||||
widget.controller.canRefresh(widget.photoId)) {
|
|
||||||
_autoRefreshScheduled = true;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
widget.controller.refreshPreview(widget.photoId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.white38,
|
|
||||||
),
|
|
||||||
const SizedBox(height: kSpace8),
|
|
||||||
Text(
|
|
||||||
l10n.photoPreviewUnavailable,
|
|
||||||
style: const TextStyle(color: Colors.white54),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: Image.network(
|
||||||
|
widget.imageUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
loadingBuilder: (context, child, progress) => progress == null
|
||||||
|
? child
|
||||||
|
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
errorBuilder: (_, _, _) {
|
||||||
|
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
|
||||||
|
if (!_autoRefreshScheduled &&
|
||||||
|
widget.controller.canRefresh(widget.photoId)) {
|
||||||
|
_autoRefreshScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
widget.controller.refreshPreview(widget.photoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace8),
|
||||||
|
Text(
|
||||||
|
l10n.photoPreviewUnavailable,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ enum PhotoGridFilter {
|
|||||||
final String wire;
|
final String wire;
|
||||||
|
|
||||||
/// 本地过滤(服务端无 processingState 查询参数)。
|
/// 本地过滤(服务端无 processingState 查询参数)。
|
||||||
bool get isLocal => this == PhotoGridFilter.processing ||
|
bool get isLocal =>
|
||||||
this == PhotoGridFilter.failed;
|
this == PhotoGridFilter.processing || this == PhotoGridFilter.failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。
|
/// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。
|
||||||
@@ -83,12 +83,12 @@ class PhotoGridController extends ChangeNotifier {
|
|||||||
required Future<Session?> Function() readSession,
|
required Future<Session?> Function() readSession,
|
||||||
required this.albumId,
|
required this.albumId,
|
||||||
this.pageSize = 20,
|
this.pageSize = 20,
|
||||||
}) // 私有字段不能作 initializing formal(外部调用方无法传私有名),
|
}) // 私有字段不能作 initializing formal(外部调用方无法传私有名),
|
||||||
// 参数名保持公开语义,忽略该风格提示。
|
// 参数名保持公开语义,忽略该风格提示。
|
||||||
// ignore: prefer_initializing_formals
|
// ignore: prefer_initializing_formals
|
||||||
: _api = api,
|
: _api = api,
|
||||||
// ignore: prefer_initializing_formals
|
// ignore: prefer_initializing_formals
|
||||||
_readSession = readSession;
|
_readSession = readSession;
|
||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
final Future<Session?> Function() _readSession;
|
final Future<Session?> Function() _readSession;
|
||||||
@@ -115,12 +115,16 @@ class PhotoGridController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 当前筛选下应显示的条目。
|
/// 当前筛选下应显示的条目。
|
||||||
List<AdminPhoto> get visibleItems => switch (_filter) {
|
List<AdminPhoto> get visibleItems => switch (_filter) {
|
||||||
PhotoGridFilter.processing => _items
|
PhotoGridFilter.processing =>
|
||||||
.where((p) => p.processingState != PhotoProcessingState.ready)
|
_items
|
||||||
.toList(growable: false),
|
.where((p) => p.processingState != PhotoProcessingState.ready)
|
||||||
PhotoGridFilter.failed => _items
|
.toList(growable: false),
|
||||||
.where((p) => p.processingState == PhotoProcessingState.processingFailed)
|
PhotoGridFilter.failed =>
|
||||||
.toList(growable: false),
|
_items
|
||||||
|
.where(
|
||||||
|
(p) => p.processingState == PhotoProcessingState.processingFailed,
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
_ => _items,
|
_ => _items,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,26 +366,25 @@ class PhotoGridController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 删除(软删除,云端照片将被移除;权限同隐藏)。
|
/// 删除(软删除,云端照片将被移除;权限同隐藏)。
|
||||||
Future<void> deletePhoto(AdminPhoto photo) =>
|
Future<void> deletePhoto(AdminPhoto photo) => _api.deletePhoto(photo.photoId);
|
||||||
_api.deletePhoto(photo.photoId);
|
|
||||||
|
|
||||||
AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto(
|
AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto(
|
||||||
uploader: src.uploader,
|
uploader: src.uploader,
|
||||||
preview: src.preview,
|
preview: src.preview,
|
||||||
photoId: rec.photoId,
|
photoId: rec.photoId,
|
||||||
albumId: rec.albumId,
|
albumId: rec.albumId,
|
||||||
categoryId: rec.categoryId,
|
categoryId: rec.categoryId,
|
||||||
uploaderId: rec.uploaderId,
|
uploaderId: rec.uploaderId,
|
||||||
visibility: rec.visibility,
|
visibility: rec.visibility,
|
||||||
processingState: rec.processingState,
|
processingState: rec.processingState,
|
||||||
bytes: rec.bytes,
|
bytes: rec.bytes,
|
||||||
width: rec.width,
|
width: rec.width,
|
||||||
height: rec.height,
|
height: rec.height,
|
||||||
shotAt: rec.shotAt,
|
shotAt: rec.shotAt,
|
||||||
uploadedAt: rec.uploadedAt,
|
uploadedAt: rec.uploadedAt,
|
||||||
publishedAt: rec.publishedAt,
|
publishedAt: rec.publishedAt,
|
||||||
likeCount: rec.likeCount,
|
likeCount: rec.likeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。
|
/// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。
|
||||||
_PageSpec? _pageForIndex(int index) {
|
_PageSpec? _pageForIndex(int index) {
|
||||||
@@ -406,12 +409,12 @@ class PhotoGridController extends ChangeNotifier {
|
|||||||
PhotoVisibility? visibility,
|
PhotoVisibility? visibility,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) => _api.listAlbumPhotos(
|
}) => _api.listAlbumPhotos(
|
||||||
albumId,
|
albumId,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
cursor: cursor,
|
cursor: cursor,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。
|
/// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。
|
||||||
@@ -654,7 +657,8 @@ class _AlbumPhotosBody extends StatelessWidget {
|
|||||||
/// 请求)。
|
/// 请求)。
|
||||||
void _maybeLoadMore(int index, int visibleCount) {
|
void _maybeLoadMore(int index, int visibleCount) {
|
||||||
final nearEnd = index >= visibleCount - 6;
|
final nearEnd = index >= visibleCount - 6;
|
||||||
final localStarved = controller.filter.isLocal &&
|
final localStarved =
|
||||||
|
controller.filter.isLocal &&
|
||||||
visibleCount < 9 &&
|
visibleCount < 9 &&
|
||||||
controller.items.length < _localFilterAutoLoadCap;
|
controller.items.length < _localFilterAutoLoadCap;
|
||||||
if ((nearEnd || localStarved) &&
|
if ((nearEnd || localStarved) &&
|
||||||
@@ -662,7 +666,9 @@ class _AlbumPhotosBody extends StatelessWidget {
|
|||||||
!controller.loadingMore &&
|
!controller.loadingMore &&
|
||||||
!controller.loading &&
|
!controller.loading &&
|
||||||
!controller.loadMoreFailed) {
|
!controller.loadMoreFailed) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore());
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => controller.loadMore(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +711,9 @@ class _PhotoFilterChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
selectedColor: theme.colorScheme.primary,
|
selectedColor: theme.colorScheme.primary,
|
||||||
labelStyle: theme.textTheme.labelLarge?.copyWith(
|
labelStyle: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: selected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
|
color: selected
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
|
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
|
||||||
@@ -756,6 +764,7 @@ class _PhotoGridTile extends StatelessWidget {
|
|||||||
PhotoThumbnail(
|
PhotoThumbnail(
|
||||||
url: photo.preview?.thumb.url,
|
url: photo.preview?.thumb.url,
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
|
maxCacheWidth: 400,
|
||||||
),
|
),
|
||||||
if (badge != null)
|
if (badge != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ class AlbumCard extends StatelessWidget {
|
|||||||
child: PhotoThumbnail(
|
child: PhotoThumbnail(
|
||||||
url: album.cover?.url,
|
url: album.cover?.url,
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
|
maxCacheWidth: 600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ class CapturePage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
enum _CameraPhase { loading, ready, denied, failed }
|
enum _CameraPhase { loading, ready, denied, failed }
|
||||||
|
|
||||||
class _CapturePageState extends ConsumerState<CapturePage> {
|
class _CapturePageState extends ConsumerState<CapturePage>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
CaptureSource? _source;
|
CaptureSource? _source;
|
||||||
_CameraPhase _phase = _CameraPhase.loading;
|
_CameraPhase _phase = _CameraPhase.loading;
|
||||||
|
|
||||||
@@ -181,16 +182,33 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// P0-3:监听 App 生命周期——后台释放相机(省电 + 隐私),回前台重开。
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initCamera();
|
_initCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_feedbackTimer?.cancel();
|
_feedbackTimer?.cancel();
|
||||||
_source?.dispose().ignore();
|
_source?.dispose().ignore();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
super.didChangeAppLifecycleState(state);
|
||||||
|
// P0-3:后台 → 释放相机硬件(不释放则摄像头指示灯常亮 + 耗电);
|
||||||
|
// 回前台 → 重新初始化(沿用当前前后摄/闪光灯偏好)。
|
||||||
|
if (state == AppLifecycleState.paused && _source != null) {
|
||||||
|
_source?.dispose().ignore();
|
||||||
|
_source = null;
|
||||||
|
setState(() => _phase = _CameraPhase.loading);
|
||||||
|
} else if (state == AppLifecycleState.resumed && _source == null) {
|
||||||
|
_initCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initCamera({bool? preferFront}) async {
|
Future<void> _initCamera({bool? preferFront}) async {
|
||||||
final front = preferFront ?? _preferFront;
|
final front = preferFront ?? _preferFront;
|
||||||
setState(() => _phase = _CameraPhase.loading);
|
setState(() => _phase = _CameraPhase.loading);
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class ShareQrPage extends ConsumerWidget {
|
|||||||
child: PhotoThumbnail(
|
child: PhotoThumbnail(
|
||||||
url: album.cover?.url,
|
url: album.cover?.url,
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
|
maxCacheWidth: 600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: kSpace16),
|
const SizedBox(height: kSpace16),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class PhotoThumbnail extends StatelessWidget {
|
|||||||
required this.url,
|
required this.url,
|
||||||
required this.aspectRatio,
|
required this.aspectRatio,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
|
this.maxCacheWidth,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ class PhotoThumbnail extends StatelessWidget {
|
|||||||
/// 图片适配(默认 cover 裁切;大图查看用 contain)。
|
/// 图片适配(默认 cover 裁切;大图查看用 contain)。
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
|
||||||
|
/// 解码宽度上限(P0-6 内存管理):网格/列表传入,限制 Image.network
|
||||||
|
/// 实际解码的像素宽度——防止 4000px 原图全量解码导致 OOM。
|
||||||
|
/// null = 不限制(查看器大图等需要完整分辨率的场景)。
|
||||||
|
final int? maxCacheWidth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
@@ -40,6 +46,8 @@ class PhotoThumbnail extends StatelessWidget {
|
|||||||
child: Image.network(
|
child: Image.network(
|
||||||
url!,
|
url!,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
|
// P0-6:限制解码宽度(网格缩略图无需全量分辨率)。
|
||||||
|
cacheWidth: maxCacheWidth,
|
||||||
// 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。
|
// 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
||||||
wasSynchronouslyLoaded || frame != null
|
wasSynchronouslyLoaded || frame != null
|
||||||
|
|||||||
Reference in New Issue
Block a user