diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 1970e8d..0763f27 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -380,7 +380,11 @@ class _AlbumDetailPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 封面:全宽 16:9(无封面同尺寸中性占位)。 - PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9), + PhotoThumbnail( + url: album.cover?.url, + aspectRatio: 16 / 9, + maxCacheWidth: 600, + ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, @@ -761,6 +765,7 @@ class _RecentPhotosSection extends StatelessWidget { itemBuilder: (context, index) => PhotoThumbnail( url: photos[index].preview?.thumb.url, aspectRatio: 1, + maxCacheWidth: 400, ), ), ] else diff --git a/apps/mobile/lib/pages/album_photo_viewer_page.dart b/apps/mobile/lib/pages/album_photo_viewer_page.dart index 2f3fc96..f98f032 100644 --- a/apps/mobile/lib/pages/album_photo_viewer_page.dart +++ b/apps/mobile/lib/pages/album_photo_viewer_page.dart @@ -568,43 +568,65 @@ class _ViewerImage extends StatefulWidget { class _ViewerImageState extends State<_ViewerImage> { bool _autoRefreshScheduled = false; + /// 当前缩放比例(1.0 = 原始;> 1.0 = 放大中,PageView 滑动被禁用)。 + /// 通过 [InheritedWidget] 通知父级 PageView 切换 physics。 + final ValueNotifier _scaleNotifier = ValueNotifier(1.0); + + @override + void dispose() { + _scaleNotifier.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return 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), - ), - ], - ), - ); + // P0-2(UI06 分析 §二):双指缩放 + 放大后平移;1x 时手势穿透到 + // PageView 左右滑动切页(InteractiveViewer 在内容不溢出时不拦截)。 + return InteractiveViewer( + maxScale: 5.0, + minScale: 1.0, + clipBehavior: Clip.none, + onInteractionUpdate: (details) => _scaleNotifier.value = details.scale, + onInteractionEnd: (_) { + // 回弹到 1x 时恢复 PageView 滑动。 + if (_scaleNotifier.value <= 1.01) _scaleNotifier.value = 1.0; }, + 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), + ), + ], + ), + ); + }, + ), ); } } diff --git a/apps/mobile/lib/pages/album_photos_page.dart b/apps/mobile/lib/pages/album_photos_page.dart index e3bd5d3..e818ce5 100644 --- a/apps/mobile/lib/pages/album_photos_page.dart +++ b/apps/mobile/lib/pages/album_photos_page.dart @@ -50,8 +50,8 @@ enum PhotoGridFilter { final String wire; /// 本地过滤(服务端无 processingState 查询参数)。 - bool get isLocal => this == PhotoGridFilter.processing || - this == PhotoGridFilter.failed; + bool get isLocal => + this == PhotoGridFilter.processing || this == PhotoGridFilter.failed; } /// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。 @@ -83,12 +83,12 @@ class PhotoGridController extends ChangeNotifier { required Future Function() readSession, required this.albumId, this.pageSize = 20, - }) // 私有字段不能作 initializing formal(外部调用方无法传私有名), - // 参数名保持公开语义,忽略该风格提示。 - // ignore: prefer_initializing_formals - : _api = api, - // ignore: prefer_initializing_formals - _readSession = readSession; + }) // 私有字段不能作 initializing formal(外部调用方无法传私有名), + // 参数名保持公开语义,忽略该风格提示。 + // ignore: prefer_initializing_formals + : _api = api, + // ignore: prefer_initializing_formals + _readSession = readSession; final ApiClient _api; final Future Function() _readSession; @@ -115,12 +115,16 @@ class PhotoGridController extends ChangeNotifier { /// 当前筛选下应显示的条目。 List get visibleItems => switch (_filter) { - PhotoGridFilter.processing => _items - .where((p) => p.processingState != PhotoProcessingState.ready) - .toList(growable: false), - PhotoGridFilter.failed => _items - .where((p) => p.processingState == PhotoProcessingState.processingFailed) - .toList(growable: false), + PhotoGridFilter.processing => + _items + .where((p) => p.processingState != PhotoProcessingState.ready) + .toList(growable: false), + PhotoGridFilter.failed => + _items + .where( + (p) => p.processingState == PhotoProcessingState.processingFailed, + ) + .toList(growable: false), _ => _items, }; @@ -362,26 +366,25 @@ class PhotoGridController extends ChangeNotifier { } /// 删除(软删除,云端照片将被移除;权限同隐藏)。 - Future deletePhoto(AdminPhoto photo) => - _api.deletePhoto(photo.photoId); + Future deletePhoto(AdminPhoto photo) => _api.deletePhoto(photo.photoId); AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto( - uploader: src.uploader, - preview: src.preview, - photoId: rec.photoId, - albumId: rec.albumId, - categoryId: rec.categoryId, - uploaderId: rec.uploaderId, - visibility: rec.visibility, - processingState: rec.processingState, - bytes: rec.bytes, - width: rec.width, - height: rec.height, - shotAt: rec.shotAt, - uploadedAt: rec.uploadedAt, - publishedAt: rec.publishedAt, - likeCount: rec.likeCount, - ); + uploader: src.uploader, + preview: src.preview, + photoId: rec.photoId, + albumId: rec.albumId, + categoryId: rec.categoryId, + uploaderId: rec.uploaderId, + visibility: rec.visibility, + processingState: rec.processingState, + bytes: rec.bytes, + width: rec.width, + height: rec.height, + shotAt: rec.shotAt, + uploadedAt: rec.uploadedAt, + publishedAt: rec.publishedAt, + likeCount: rec.likeCount, + ); /// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。 _PageSpec? _pageForIndex(int index) { @@ -406,12 +409,12 @@ class PhotoGridController extends ChangeNotifier { PhotoVisibility? visibility, int? limit, }) => _api.listAlbumPhotos( - albumId, - scope: scope, - visibility: visibility, - cursor: cursor, - limit: limit, - ); + albumId, + scope: scope, + visibility: visibility, + cursor: cursor, + limit: limit, + ); } /// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。 @@ -654,7 +657,8 @@ class _AlbumPhotosBody extends StatelessWidget { /// 请求)。 void _maybeLoadMore(int index, int visibleCount) { final nearEnd = index >= visibleCount - 6; - final localStarved = controller.filter.isLocal && + final localStarved = + controller.filter.isLocal && visibleCount < 9 && controller.items.length < _localFilterAutoLoadCap; if ((nearEnd || localStarved) && @@ -662,7 +666,9 @@ class _AlbumPhotosBody extends StatelessWidget { !controller.loadingMore && !controller.loading && !controller.loadMoreFailed) { - WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.loadMore(), + ); } } @@ -705,7 +711,9 @@ class _PhotoFilterChip extends StatelessWidget { ), selectedColor: theme.colorScheme.primary, labelStyle: theme.textTheme.labelLarge?.copyWith( - color: selected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, fontWeight: FontWeight.w600, ), padding: const EdgeInsets.symmetric(horizontal: kSpace8), @@ -756,6 +764,7 @@ class _PhotoGridTile extends StatelessWidget { PhotoThumbnail( url: photo.preview?.thumb.url, aspectRatio: 1, + maxCacheWidth: 400, ), if (badge != null) Positioned( diff --git a/apps/mobile/lib/pages/albums_tab.dart b/apps/mobile/lib/pages/albums_tab.dart index 1a38a3c..ce6eef9 100644 --- a/apps/mobile/lib/pages/albums_tab.dart +++ b/apps/mobile/lib/pages/albums_tab.dart @@ -244,6 +244,7 @@ class AlbumCard extends StatelessWidget { child: PhotoThumbnail( url: album.cover?.url, aspectRatio: 16 / 9, + maxCacheWidth: 600, ), ), const Positioned.fill( diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index 95eaffc..fedf4e5 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -155,7 +155,8 @@ class CapturePage extends ConsumerStatefulWidget { enum _CameraPhase { loading, ready, denied, failed } -class _CapturePageState extends ConsumerState { +class _CapturePageState extends ConsumerState + with WidgetsBindingObserver { CaptureSource? _source; _CameraPhase _phase = _CameraPhase.loading; @@ -181,16 +182,33 @@ class _CapturePageState extends ConsumerState { @override void initState() { super.initState(); + // P0-3:监听 App 生命周期——后台释放相机(省电 + 隐私),回前台重开。 + WidgetsBinding.instance.addObserver(this); _initCamera(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _feedbackTimer?.cancel(); _source?.dispose().ignore(); 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 _initCamera({bool? preferFront}) async { final front = preferFront ?? _preferFront; setState(() => _phase = _CameraPhase.loading); diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart index 6d4ca60..4a12ec8 100644 --- a/apps/mobile/lib/pages/share_qr_dialog.dart +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -207,6 +207,7 @@ class ShareQrPage extends ConsumerWidget { child: PhotoThumbnail( url: album.cover?.url, aspectRatio: 16 / 9, + maxCacheWidth: 600, ), ), const SizedBox(height: kSpace16), diff --git a/apps/mobile/lib/ui/components/photo_thumbnail.dart b/apps/mobile/lib/ui/components/photo_thumbnail.dart index 7f17ccd..e742b19 100644 --- a/apps/mobile/lib/ui/components/photo_thumbnail.dart +++ b/apps/mobile/lib/ui/components/photo_thumbnail.dart @@ -15,6 +15,7 @@ class PhotoThumbnail extends StatelessWidget { required this.url, required this.aspectRatio, this.fit = BoxFit.cover, + this.maxCacheWidth, super.key, }); @@ -27,6 +28,11 @@ class PhotoThumbnail extends StatelessWidget { /// 图片适配(默认 cover 裁切;大图查看用 contain)。 final BoxFit fit; + /// 解码宽度上限(P0-6 内存管理):网格/列表传入,限制 Image.network + /// 实际解码的像素宽度——防止 4000px 原图全量解码导致 OOM。 + /// null = 不限制(查看器大图等需要完整分辨率的场景)。 + final int? maxCacheWidth; + @override Widget build(BuildContext context) { if (url == null) { @@ -40,6 +46,8 @@ class PhotoThumbnail extends StatelessWidget { child: Image.network( url!, fit: fit, + // P0-6:限制解码宽度(网格缩略图无需全量分辨率)。 + cacheWidth: maxCacheWidth, // 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。 frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => wasSynchronouslyLoaded || frame != null