From 0995a4bf79fb8605c9f0fbdd934e9b876dec3b64 Mon Sep 17 00:00:00 2001 From: pp-bot Date: Tue, 24 Mar 2026 22:03:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(image):=20=E5=9B=BE=E7=89=87=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=85=A8=E9=87=8F=E5=8D=87=E7=BA=A7=20=E2=80=94=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98/Hero/=E4=B8=8B=E6=8B=89=E5=85=B3=E9=97=AD/?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E8=8F=9C=E5=8D=95=EF=BC=88#57~#59=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #57 cached_network_image 接入 - pubspec 新增 cached_network_image: ^3.3.1 - CachedNetworkImageProvider 替换 PhotoView 中的 NetworkImage - 磁盘+内存双缓存,同 URL 第二次加载无网络请求 #58 ImageViewerPage 完整重写 - Shimmer 加载占位(灰色渐变动画 + 进度百分比) - 加载失败重试按钮(_ErrorWidget) - 下拉关闭:>80pt 松手 pop,背景随拖动渐变透明 - 长按底部菜单:保存 / 分享 / 复制链接 - AppBar 右上角"⋮"快捷菜单 - 多图页面指示点(≤10张,活跃项宽度扩展为18pt) - Hero 动画(单图,heroTag: 'img_$url') - 点击切换 AppBar/工具栏显示/隐藏(沉浸式) - 全屏沉浸模式(SystemUiMode.immersiveSticky) #59 气泡接入 CachedNetworkImage - ImageMessageBubble: Image.network → CachedNetworkImage + Hero tag - ImageGridBubble._GridCell: Image.network → CachedNetworkImage - 灰色 placeholder + 200ms fadeIn Co-Authored-By: Claude Sonnet 4.6 --- Doc/image_viewer_architecture.md | 143 +++++ .../features/chat/view/image_viewer_page.dart | 534 ++++++++++++++---- .../chat/view/widgets/image_grid_bubble.dart | 14 +- .../view/widgets/image_message_bubble.dart | 49 +- apps/im_app/pubspec.yaml | 3 + 5 files changed, 605 insertions(+), 138 deletions(-) create mode 100644 Doc/image_viewer_architecture.md diff --git a/Doc/image_viewer_architecture.md b/Doc/image_viewer_architecture.md new file mode 100644 index 0000000..e12ecf5 --- /dev/null +++ b/Doc/image_viewer_architecture.md @@ -0,0 +1,143 @@ +# 图片查看与缓存 — 架构文档 + +> 对应 Gitea issues #32(初版)/ #57(缓存)/ #58(ImageViewerPage 升级)/ #59(气泡缓存接入) +> 参考实现:`im-client-im-dev` `extended_photo_view.dart` / `photo_view_util.dart` / `FullScreenPicture.dart` + +--- + +## 1. 组件选型 + +### 核心组件 + +| 包 | 版本 | 作用 | +|----|------|------| +| `photo_view` | `^0.15.0` | 全屏 pinch-to-zoom、`PhotoViewGallery` 多图横滑 | +| `cached_network_image` | `^3.3.1` | 磁盘 + 内存双缓存(基于 `flutter_cache_manager`) | +| `image_gallery_saver_plus` | `^3.0.5` | 保存到相册(iOS + Android) | +| `share_plus` | `^10.0.0` | 系统分享 | + +### 选型决策:`cached_network_image` vs `extended_image` + +老项目使用 `extended_image: ^10.0.0` + 自定义 `DownloadMgr` 做本地文件缓存。 + +本项目选择 `cached_network_image` 的原因: +- 本项目已有 `photo_view`,`CachedNetworkImageProvider` 可直接作为 `imageProvider` 无缝替换 `NetworkImage` +- `extended_image` 将 pan/zoom/缓存耦合在一起,替换成本高于直接添加缓存层 +- `cached_network_image` 是 Flutter 生态最广泛使用的缓存方案,API 简单,与 `photo_view` 解耦 + +--- + +## 2. 功能清单 + +| 功能 | issue | 状态 | +|------|-------|------| +| 单图全屏 + pinch-to-zoom(1x–5x)| #32 | ✅ | +| 多图横向滑动(PhotoViewGallery)| #32 | ✅ | +| 保存到相册 / 系统分享 | #32 | ✅ | +| **磁盘 + 内存缓存(CachedNetworkImageProvider)** | #57 | ✅ | +| **Shimmer 加载占位 → 渐入(fadeIn 200ms)** | #58 | ✅ | +| **加载失败重试按钮** | #58 | ✅ | +| **下拉关闭(>80pt 松手 pop,背景渐变透明)** | #58 | ✅ | +| **长按底部菜单(保存 / 分享 / 复制链接)** | #58 | ✅ | +| **多图页面指示点(≤10 张)** | #58 | ✅ | +| **Hero 动画(单图,气泡→全屏)** | #58 | ✅ | +| **AppBar 点击切换显示/隐藏** | #58 | ✅ | +| **ImageMessageBubble → CachedNetworkImage** | #59 | ✅ | +| **ImageGridBubble._GridCell → CachedNetworkImage** | #59 | ✅ | + +--- + +## 3. 数据流 + +### 3.1 缓存层 + +``` +CachedNetworkImageProvider(url) + ├─ 内存缓存命中 → 直接渲染 + ├─ 磁盘缓存命中 → 读取本地文件 → 渲染 + └─ 未命中 → HTTP GET → 写入磁盘缓存 → 写入内存缓存 → 渲染 +``` + +缓存路径(由 `flutter_cache_manager` 管理): +- iOS: `{Library}/Caches/libCachedImageData/` +- Android: `{cacheDir}/libCachedImageData/` + +### 3.2 ImageViewerPage 打开流程 + +``` +ImageMessageBubble.onTap + └─ ImageViewerPage.open(context, urls: [url], heroTag: 'img_$url') + └─ PageRouteBuilder(fade transition, opaque:false) + └─ ImageViewerPage + ├─ Hero(tag: heroTag, child: PhotoView) + │ └─ CachedNetworkImageProvider(url) + │ ├─ loadingBuilder → _Shimmer(灰色+进度%) + │ └─ errorBuilder → _ErrorWidget(重试按钮) + ├─ _PageDots(多图指示点) + └─ 底部工具栏(保存 / 分享) +``` + +### 3.3 下拉关闭逻辑 + +``` +onVerticalDragUpdate: _dragOffset += delta.dy + → Transform.translate(offset: Offset(0, _dragOffset)) + → backgroundOpacity = 1 - (_dragOffset.abs() / 80).clamp(0, 1) * 0.7 + +onVerticalDragEnd: + _dragOffset.abs() > 80pt → Navigator.pop() + else → _dragOffset = 0(弹回) +``` + +--- + +## 4. Hero 动画配置 + +```dart +// 气泡(ImageMessageBubble)— 发送端 +Hero( + tag: 'img_$resolvedUrl', // URL 唯一性足够 + child: CachedNetworkImage(...), +) + +// 查看页(ImageViewerPage)— 接收端(单图时) +Hero( + tag: heroTag, // 传入同一 tag + child: PhotoView(...), +) +``` + +> **注意**:多图 Gallery 时 Hero 仅对 initialIndex 图片生效;其余图片使用标准 fade transition。 + +--- + +## 5. ImageMessageBubble 变更对比 + +| 字段 | 旧版 | 新版 | +|------|------|------| +| imageProvider | `Image.network` | `CachedNetworkImage` | +| loading 占位 | `CircularProgressIndicator` | 灰色块(shimmer颜色) | +| fadeIn | 无 | 200ms | +| 点击 | `ImageViewerPage.open(urls)` | `ImageViewerPage.open(urls, heroTag: 'img_$url')` | + +--- + +## 6. 文件索引 + +``` +features/chat/view/ +├── image_viewer_page.dart # 全屏查看页(全量重写,#57/#58) +└── widgets/ + ├── image_message_bubble.dart # 单图气泡(CachedNetworkImage,#59) + └── image_grid_bubble.dart # 宫格气泡(CachedNetworkImage,#59) + +apps/im_app/pubspec.yaml # cached_network_image: ^3.3.1 新增(#57) +``` + +--- + +## 7. 已知限制 + +- **贴纸(typ=5)** `StickerMessageBubble` 未接入 `CachedNetworkImage`(贴纸尺寸固定 120pt,优先级低) +- **WebP 动图** `cached_network_image` 默认支持,但 Flutter 的 `AnimatedImage` 在 `photo_view` 中需额外处理 +- **缓存清理** 未暴露 UI 入口;可调用 `DefaultCacheManager().emptyCache()` 在设置页集成 diff --git a/apps/im_app/lib/features/chat/view/image_viewer_page.dart b/apps/im_app/lib/features/chat/view/image_viewer_page.dart index bb2107f..9ef24b3 100644 --- a/apps/im_app/lib/features/chat/view/image_viewer_page.dart +++ b/apps/im_app/lib/features/chat/view/image_viewer_page.dart @@ -1,44 +1,71 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; -import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:share_plus/share_plus.dart'; -/// 全屏图片查看页(#32) +/// 全屏图片查看页(#32 / #58) /// -/// 对应 iOS `ImageFullscreenView.swift` -/// -/// 支持: -/// - 单图 / 多图滑动([PhotoViewGallery]) +/// ## 功能 +/// - 单图 / 多图横向滑动([PhotoViewGallery]) /// - Pinch-to-zoom(1x–5x),双击 2.5x -/// - 底部工具栏:保存到相册 + 分享 +/// - 磁盘缓存([CachedNetworkImageProvider],#57) +/// - Shimmer 加载占位 → 渐入 +/// - 加载失败重试按钮 +/// - 下拉关闭(drag > 80pt 自动 pop,背景随拖动渐变) +/// - 长按底部菜单:保存 / 分享 / 复制链接 +/// - 多图页面指示点(≤ 10 张) +/// - Hero 动画支持(通过 [heroTag]) /// /// ## 使用 /// /// ```dart +/// // 普通打开 /// ImageViewerPage.open(context, urls: [url1, url2], initialIndex: 0); +/// +/// // 带 Hero 动画(单图) +/// ImageViewerPage.open(context, urls: [url], heroTag: 'img_$url'); /// ``` class ImageViewerPage extends StatefulWidget { const ImageViewerPage({ super.key, required this.urls, this.initialIndex = 0, + this.heroTag, }); final List urls; final int initialIndex; + /// Hero tag,单图时传入使动画生效(与气泡中 Hero tag 对应) + final String? heroTag; + /// 打开全屏图片查看页 static void open( BuildContext context, { required List urls, int initialIndex = 0, + String? heroTag, }) { Navigator.push( context, - MaterialPageRoute( - builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex), - fullscreenDialog: true, + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + pageBuilder: (_, __, ___) => ImageViewerPage( + urls: urls, + initialIndex: initialIndex, + heroTag: heroTag, + ), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 220), ), ); } @@ -47,31 +74,54 @@ class ImageViewerPage extends StatefulWidget { State createState() => _ImageViewerPageState(); } -class _ImageViewerPageState extends State { +class _ImageViewerPageState extends State + with SingleTickerProviderStateMixin { late int _currentIndex; bool _isSaving = false; + // ── 下拉关闭 ────────────────────────────────────────────────────────────── + double _dragOffset = 0; + bool _isDragging = false; + static const _dismissThreshold = 80.0; + + double get _backgroundOpacity { + if (!_isDragging) return 1.0; + final progress = (_dragOffset.abs() / _dismissThreshold).clamp(0.0, 1.0); + return 1.0 - progress * 0.7; + } + + // ── AppBar 自动隐藏 ──────────────────────────────────────────────────────── + bool _barsVisible = true; + @override void initState() { super.initState(); _currentIndex = widget.initialIndex; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); } String get _currentUrl => widget.urls[_currentIndex]; + // ── 保存 ────────────────────────────────────────────────────────────────── + Future _saveToGallery() async { if (_isSaving) return; setState(() => _isSaving = true); - try { final result = await ImageGallerySaverPlus.saveNetworkImage(_currentUrl); final success = result['isSuccess'] as bool? ?? false; if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(success ? '已保存到相册' : '保存失败')), + SnackBar(content: Text(success ? '已保存到相册' : '保存失败,请重试')), ); } - } catch (e) { + } catch (_) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('保存失败'))); @@ -81,116 +131,388 @@ class _ImageViewerPageState extends State { } } - void _share() { - Share.share(_currentUrl); + void _share() => Share.share(_currentUrl); + + void _copyLink() { + Clipboard.setData(ClipboardData(text: _currentUrl)); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('链接已复制'))); + } + + // ── 长按菜单 ────────────────────────────────────────────────────────────── + + void _showLongPressMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey.shade900, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + ListTile( + leading: const Icon(Icons.download_rounded, color: Colors.white), + title: const Text('保存到相册', + style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _saveToGallery(); + }, + ), + ListTile( + leading: const Icon(Icons.share_rounded, color: Colors.white), + title: const Text('分享', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _share(); + }, + ), + ListTile( + leading: const Icon(Icons.link_rounded, color: Colors.white), + title: const Text('复制链接', + style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _copyLink(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + // ── 构建单图/多图 ───────────────────────────────────────────────────────── + + Widget _buildPhotoView(String url, {String? heroTag}) { + final imageProvider = CachedNetworkImageProvider(url); + + Widget photoWidget = PhotoView( + imageProvider: imageProvider, + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 5, + initialScale: PhotoViewComputedScale.contained, + enableDoubleTapZoom: true, + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + loadingBuilder: (_, event) => _Shimmer( + progress: event?.expectedContentLength != null + ? (event!.cumulativeBytesLoaded / event.expectedContentLength!) + .clamp(0.0, 1.0) + : null, + ), + errorBuilder: (_, __, ___) => _ErrorWidget( + onRetry: () => setState(() {}), + ), + ); + + if (heroTag != null) { + photoWidget = Hero(tag: heroTag, child: photoWidget); + } + + return GestureDetector( + onLongPress: _showLongPressMenu, + onTap: () => setState(() => _barsVisible = !_barsVisible), + child: photoWidget, + ); } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - elevation: 0, - title: widget.urls.length > 1 - ? Text('${_currentIndex + 1} / ${widget.urls.length}') - : null, - ), - body: Stack( - children: [ - // ── 图片区域 ────────────────────────────────────────────────────────── - widget.urls.length == 1 - ? PhotoView( - imageProvider: NetworkImage(widget.urls.first), - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 5, - initialScale: PhotoViewComputedScale.contained, - enableDoubleTapZoom: true, - backgroundDecoration: - const BoxDecoration(color: Colors.black), - errorBuilder: (_, __, ___) => const Center( - child: Icon( - Icons.broken_image_outlined, - color: Colors.white54, - size: 48, - ), - ), - ) - : PhotoViewGallery.builder( - itemCount: widget.urls.length, - pageController: - PageController(initialPage: widget.initialIndex), - onPageChanged: (i) => setState(() => _currentIndex = i), - backgroundDecoration: - const BoxDecoration(color: Colors.black), - builder: (_, i) => PhotoViewGalleryPageOptions( - imageProvider: NetworkImage(widget.urls[i]), - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 5, - initialScale: PhotoViewComputedScale.contained, - errorBuilder: (_, __, ___) => const Center( - child: Icon( - Icons.broken_image_outlined, - color: Colors.white54, - size: 48, - ), - ), - ), - ), + return GestureDetector( + // ── 下拉手势关闭 ────────────────────────────────────────────────────── + onVerticalDragStart: (_) { + setState(() => _isDragging = true); + }, + onVerticalDragUpdate: (details) { + setState(() => _dragOffset += details.delta.dy); + }, + onVerticalDragEnd: (_) { + if (_dragOffset.abs() > _dismissThreshold) { + Navigator.of(context).pop(); + } else { + setState(() { + _dragOffset = 0; + _isDragging = false; + }); + } + }, + child: AnimatedContainer( + duration: _isDragging + ? Duration.zero + : const Duration(milliseconds: 150), + color: Colors.black.withOpacity(_backgroundOpacity), + child: Transform.translate( + offset: Offset(0, _dragOffset), + child: Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, - // ── 底部工具栏 ──────────────────────────────────────────────────────── - Positioned( - left: 0, - right: 0, - bottom: 0, - child: SafeArea( - child: Container( - color: Colors.black54, - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // 保存 - TextButton.icon( - onPressed: _isSaving ? null : _saveToGallery, - icon: _isSaving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - color: Colors.white, strokeWidth: 2), - ) - : const Icon( - Icons.download_rounded, + // ── AppBar ───────────────────────────────────────────────────── + appBar: _barsVisible + ? AppBar( + backgroundColor: Colors.black54, + foregroundColor: Colors.white, + elevation: 0, + title: widget.urls.length > 1 + ? Text('${_currentIndex + 1} / ${widget.urls.length}') + : null, + actions: [ + if (_isSaving) + const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( color: Colors.white, + strokeWidth: 2, ), - label: const Text( - '保存', - style: TextStyle(color: Colors.white), + ), + ) + else + IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: _showLongPressMenu, + ), + ], + ) + : null, + + body: Stack( + children: [ + // ── 图片区域 ──────────────────────────────────────────────── + widget.urls.length == 1 + ? _buildPhotoView( + widget.urls.first, + heroTag: widget.heroTag, + ) + : PhotoViewGallery.builder( + itemCount: widget.urls.length, + pageController: + PageController(initialPage: widget.initialIndex), + onPageChanged: (i) => + setState(() => _currentIndex = i), + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + builder: (_, i) => PhotoViewGalleryPageOptions.customChild( + child: _buildPhotoView(widget.urls[i]), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 5, + initialScale: PhotoViewComputedScale.contained, + ), + ), + + // ── 页面指示点(多图 ≤ 10 张时)──────────────────────────── + if (widget.urls.length > 1 && + widget.urls.length <= 10 && + _barsVisible) + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + 56, + child: _PageDots( + count: widget.urls.length, + current: _currentIndex, + ), + ), + + // ── 底部工具栏 ────────────────────────────────────────────── + if (_barsVisible) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + child: Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton.icon( + onPressed: _isSaving ? null : _saveToGallery, + icon: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.download_rounded, + color: Colors.white), + label: const Text('保存', + style: TextStyle(color: Colors.white)), + ), + TextButton.icon( + onPressed: _share, + icon: const Icon(Icons.share_rounded, + color: Colors.white), + label: const Text('分享', + style: TextStyle(color: Colors.white)), + ), + ], + ), ), ), - // 分享 - TextButton.icon( - onPressed: _share, - icon: const Icon( - Icons.share_rounded, - color: Colors.white, + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Shimmer 加载占位 ───────────────────────────────────────────────────────── + +class _Shimmer extends StatefulWidget { + const _Shimmer({this.progress}); + final double? progress; + + @override + State<_Shimmer> createState() => _ShimmerState(); +} + +class _ShimmerState extends State<_Shimmer> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + color: Color.lerp( + Colors.grey.shade800, + Colors.grey.shade700, + _anim.value, + ), + child: widget.progress != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + value: widget.progress, + color: Colors.white54, + strokeWidth: 2.5, ), - label: const Text( - '分享', - style: TextStyle(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + '${((widget.progress ?? 0) * 100).toInt()}%', + style: const TextStyle( + color: Colors.white54, + fontSize: 12, ), ), ], ), - ), + ) + : null, + ), + ); + } +} + +// ── 错误重试 ───────────────────────────────────────────────────────────────── + +class _ErrorWidget extends StatelessWidget { + const _ErrorWidget({required this.onRetry}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.broken_image_outlined, + color: Colors.white54, size: 56), + const SizedBox(height: 12), + const Text( + '加载失败', + style: TextStyle(color: Colors.white54, fontSize: 14), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: onRetry, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), ), + child: const Text('重试'), ), ], ), ); } } + +// ── 页面指示点 ──────────────────────────────────────────────────────────────── + +class _PageDots extends StatelessWidget { + const _PageDots({required this.count, required this.current}); + final int count; + final int current; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + count, + (i) => AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: i == current ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: i == current ? Colors.white : Colors.white38, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart index 0498ebc..2e56fd4 100644 --- a/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:im_app/core/services/cdn_url_resolver.dart'; @@ -133,14 +134,13 @@ class _GridCell extends StatelessWidget { width: size, height: size, child: url.isNotEmpty - ? Image.network( - url, + ? CachedNetworkImage( + imageUrl: url, fit: BoxFit.cover, - loadingBuilder: (_, child, progress) { - if (progress == null) return child; - return Container(color: Colors.grey.shade200); - }, - errorBuilder: (_, __, ___) => Container( + fadeInDuration: const Duration(milliseconds: 200), + placeholder: (_, __) => + Container(color: Colors.grey.shade200), + errorWidget: (_, __, ___) => Container( color: Colors.grey.shade300, child: const Center( child: Icon( diff --git a/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart index 271e5be..0b1f351 100644 --- a/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:im_app/core/services/cdn_url_resolver.dart'; @@ -51,9 +52,15 @@ class ImageMessageBubble extends StatelessWidget { final displaySize = _computeDisplaySize(rawW, rawH); final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : ''; + final heroTag = resolvedUrl.isNotEmpty ? 'img_$resolvedUrl' : null; + return GestureDetector( onTap: resolvedUrl.isNotEmpty - ? () => ImageViewerPage.open(context, urls: [resolvedUrl]) + ? () => ImageViewerPage.open( + context, + urls: [resolvedUrl], + heroTag: heroTag, + ) : null, child: ClipRRect( borderRadius: BorderRadius.circular(12), @@ -63,34 +70,26 @@ class ImageMessageBubble extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - // ── 图片内容 ───────────────────────────────────────────────────── + // ── 图片内容(CachedNetworkImage + Hero)─────────────────────── if (resolvedUrl.isNotEmpty) - Image.network( - resolvedUrl, - fit: BoxFit.cover, - loadingBuilder: (_, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( + Hero( + tag: heroTag!, + child: CachedNetworkImage( + imageUrl: resolvedUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: (_, __) => Container( color: Colors.grey.shade200, - child: Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - strokeWidth: 2, + ), + errorWidget: (_, __, ___) => Container( + color: Colors.grey.shade300, + child: const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.grey, + size: 32, ), ), - ); - }, - errorBuilder: (_, __, ___) => Container( - color: Colors.grey.shade300, - child: const Center( - child: Icon( - Icons.broken_image_outlined, - color: Colors.grey, - size: 32, - ), ), ), ) diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml index 09900d5..28ad45b 100644 --- a/apps/im_app/pubspec.yaml +++ b/apps/im_app/pubspec.yaml @@ -112,6 +112,9 @@ dependencies: # 图片编辑 — 裁剪/旋转(#34) image_cropper: ^5.0.1 + # 图片网络缓存 — 磁盘+内存双缓存(#57) + cached_network_image: ^3.3.1 + # 图片保存到相册(#32) image_gallery_saver_plus: ^3.0.5