feat(image): 图片查看全量升级 — 缓存/Hero/下拉关闭/长按菜单(#57~#59)

#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 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 22:03:08 +09:00
parent 21b7201590
commit 0995a4bf79
5 changed files with 605 additions and 138 deletions

View File

@@ -0,0 +1,143 @@
# 图片查看与缓存 — 架构文档
> 对应 Gitea issues #32初版/ #57缓存/ #58ImageViewerPage 升级)/ #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-zoom1x5x| #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')
└─ PageRouteBuilderfade 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()` 在设置页集成

View File

@@ -1,44 +1,71 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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.dart';
import 'package:photo_view/photo_view_gallery.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'; import 'package:share_plus/share_plus.dart';
/// 全屏图片查看页(#32 /// 全屏图片查看页(#32 / #58
/// ///
/// 对应 iOS `ImageFullscreenView.swift` /// ## 功能
/// /// - 单图 / 多图横向滑动([PhotoViewGallery]
/// 支持:
/// - 单图 / 多图滑动([PhotoViewGallery]
/// - Pinch-to-zoom1x5x双击 2.5x /// - Pinch-to-zoom1x5x双击 2.5x
/// - 底部工具栏:保存到相册 + 分享 /// - 磁盘缓存([CachedNetworkImageProvider]#57
/// - Shimmer 加载占位 → 渐入
/// - 加载失败重试按钮
/// - 下拉关闭drag > 80pt 自动 pop背景随拖动渐变
/// - 长按底部菜单:保存 / 分享 / 复制链接
/// - 多图页面指示点(≤ 10 张)
/// - Hero 动画支持(通过 [heroTag]
/// ///
/// ## 使用 /// ## 使用
/// ///
/// ```dart /// ```dart
/// // 普通打开
/// ImageViewerPage.open(context, urls: [url1, url2], initialIndex: 0); /// ImageViewerPage.open(context, urls: [url1, url2], initialIndex: 0);
///
/// // 带 Hero 动画(单图)
/// ImageViewerPage.open(context, urls: [url], heroTag: 'img_$url');
/// ``` /// ```
class ImageViewerPage extends StatefulWidget { class ImageViewerPage extends StatefulWidget {
const ImageViewerPage({ const ImageViewerPage({
super.key, super.key,
required this.urls, required this.urls,
this.initialIndex = 0, this.initialIndex = 0,
this.heroTag,
}); });
final List<String> urls; final List<String> urls;
final int initialIndex; final int initialIndex;
/// Hero tag单图时传入使动画生效与气泡中 Hero tag 对应)
final String? heroTag;
/// 打开全屏图片查看页 /// 打开全屏图片查看页
static void open( static void open(
BuildContext context, { BuildContext context, {
required List<String> urls, required List<String> urls,
int initialIndex = 0, int initialIndex = 0,
String? heroTag,
}) { }) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute<void>( PageRouteBuilder<void>(
builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex), opaque: false,
fullscreenDialog: true, 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<ImageViewerPage> createState() => _ImageViewerPageState(); State<ImageViewerPage> createState() => _ImageViewerPageState();
} }
class _ImageViewerPageState extends State<ImageViewerPage> { class _ImageViewerPageState extends State<ImageViewerPage>
with SingleTickerProviderStateMixin {
late int _currentIndex; late int _currentIndex;
bool _isSaving = false; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_currentIndex = widget.initialIndex; _currentIndex = widget.initialIndex;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
} }
String get _currentUrl => widget.urls[_currentIndex]; String get _currentUrl => widget.urls[_currentIndex];
// ── 保存 ──────────────────────────────────────────────────────────────────
Future<void> _saveToGallery() async { Future<void> _saveToGallery() async {
if (_isSaving) return; if (_isSaving) return;
setState(() => _isSaving = true); setState(() => _isSaving = true);
try { try {
final result = await ImageGallerySaverPlus.saveNetworkImage(_currentUrl); final result = await ImageGallerySaverPlus.saveNetworkImage(_currentUrl);
final success = result['isSuccess'] as bool? ?? false; final success = result['isSuccess'] as bool? ?? false;
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(success ? '已保存到相册' : '保存失败')), SnackBar(content: Text(success ? '已保存到相册' : '保存失败,请重试')),
); );
} }
} catch (e) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('保存失败'))); .showSnackBar(const SnackBar(content: Text('保存失败')));
@@ -81,116 +131,388 @@ class _ImageViewerPageState extends State<ImageViewerPage> {
} }
} }
void _share() { void _share() => Share.share(_currentUrl);
Share.share(_currentUrl);
void _copyLink() {
Clipboard.setData(ClipboardData(text: _currentUrl));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('链接已复制')));
}
// ── 长按菜单 ──────────────────────────────────────────────────────────────
void _showLongPressMenu() {
showModalBottomSheet<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return GestureDetector(
backgroundColor: Colors.black, // ── 下拉手势关闭 ──────────────────────────────────────────────────────
extendBodyBehindAppBar: true, onVerticalDragStart: (_) {
appBar: AppBar( setState(() => _isDragging = true);
backgroundColor: Colors.transparent, },
foregroundColor: Colors.white, onVerticalDragUpdate: (details) {
elevation: 0, setState(() => _dragOffset += details.delta.dy);
title: widget.urls.length > 1 },
? Text('${_currentIndex + 1} / ${widget.urls.length}') onVerticalDragEnd: (_) {
: null, if (_dragOffset.abs() > _dismissThreshold) {
), Navigator.of(context).pop();
body: Stack( } else {
children: [ setState(() {
// ── 图片区域 ────────────────────────────────────────────────────────── _dragOffset = 0;
widget.urls.length == 1 _isDragging = false;
? PhotoView( });
imageProvider: NetworkImage(widget.urls.first), }
minScale: PhotoViewComputedScale.contained, },
maxScale: PhotoViewComputedScale.covered * 5, child: AnimatedContainer(
initialScale: PhotoViewComputedScale.contained, duration: _isDragging
enableDoubleTapZoom: true, ? Duration.zero
backgroundDecoration: : const Duration(milliseconds: 150),
const BoxDecoration(color: Colors.black), color: Colors.black.withOpacity(_backgroundOpacity),
errorBuilder: (_, __, ___) => const Center( child: Transform.translate(
child: Icon( offset: Offset(0, _dragOffset),
Icons.broken_image_outlined, child: Scaffold(
color: Colors.white54, backgroundColor: Colors.transparent,
size: 48, extendBodyBehindAppBar: true,
),
),
)
: 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,
),
),
),
),
// ── 底部工具栏 ──────────────────────────────────────────────────────── // ── AppBar ─────────────────────────────────────────────────────
Positioned( appBar: _barsVisible
left: 0, ? AppBar(
right: 0, backgroundColor: Colors.black54,
bottom: 0, foregroundColor: Colors.white,
child: SafeArea( elevation: 0,
child: Container( title: widget.urls.length > 1
color: Colors.black54, ? Text('${_currentIndex + 1} / ${widget.urls.length}')
padding: : null,
const EdgeInsets.symmetric(horizontal: 24, vertical: 12), actions: [
child: Row( if (_isSaving)
mainAxisAlignment: MainAxisAlignment.spaceAround, const Padding(
children: [ padding: EdgeInsets.all(14),
// 保存 child: SizedBox(
TextButton.icon( width: 20,
onPressed: _isSaving ? null : _saveToGallery, height: 20,
icon: _isSaving child: CircularProgressIndicator(
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2),
)
: const Icon(
Icons.download_rounded,
color: Colors.white, 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<double> _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( ),
'分享', const SizedBox(height: 8),
style: TextStyle(color: Colors.white), 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),
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:im_app/core/services/cdn_url_resolver.dart'; import 'package:im_app/core/services/cdn_url_resolver.dart';
@@ -133,14 +134,13 @@ class _GridCell extends StatelessWidget {
width: size, width: size,
height: size, height: size,
child: url.isNotEmpty child: url.isNotEmpty
? Image.network( ? CachedNetworkImage(
url, imageUrl: url,
fit: BoxFit.cover, fit: BoxFit.cover,
loadingBuilder: (_, child, progress) { fadeInDuration: const Duration(milliseconds: 200),
if (progress == null) return child; placeholder: (_, __) =>
return Container(color: Colors.grey.shade200); Container(color: Colors.grey.shade200),
}, errorWidget: (_, __, ___) => Container(
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade300, color: Colors.grey.shade300,
child: const Center( child: const Center(
child: Icon( child: Icon(

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:im_app/core/services/cdn_url_resolver.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 displaySize = _computeDisplaySize(rawW, rawH);
final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : ''; final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : '';
final heroTag = resolvedUrl.isNotEmpty ? 'img_$resolvedUrl' : null;
return GestureDetector( return GestureDetector(
onTap: resolvedUrl.isNotEmpty onTap: resolvedUrl.isNotEmpty
? () => ImageViewerPage.open(context, urls: [resolvedUrl]) ? () => ImageViewerPage.open(
context,
urls: [resolvedUrl],
heroTag: heroTag,
)
: null, : null,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -63,34 +70,26 @@ class ImageMessageBubble extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// ── 图片内容 ───────────────────────────────────────────────────── // ── 图片内容CachedNetworkImage + Hero───────────────────────
if (resolvedUrl.isNotEmpty) if (resolvedUrl.isNotEmpty)
Image.network( Hero(
resolvedUrl, tag: heroTag!,
fit: BoxFit.cover, child: CachedNetworkImage(
loadingBuilder: (_, child, loadingProgress) { imageUrl: resolvedUrl,
if (loadingProgress == null) return child; fit: BoxFit.cover,
return Container( fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => Container(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Center( ),
child: CircularProgressIndicator( errorWidget: (_, __, ___) => Container(
value: loadingProgress.expectedTotalBytes != null color: Colors.grey.shade300,
? loadingProgress.cumulativeBytesLoaded / child: const Center(
loadingProgress.expectedTotalBytes! child: Icon(
: null, Icons.broken_image_outlined,
strokeWidth: 2, 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,
),
), ),
), ),
) )

View File

@@ -112,6 +112,9 @@ dependencies:
# 图片编辑 — 裁剪/旋转(#34 # 图片编辑 — 裁剪/旋转(#34
image_cropper: ^5.0.1 image_cropper: ^5.0.1
# 图片网络缓存 — 磁盘+内存双缓存(#57
cached_network_image: ^3.3.1
# 图片保存到相册(#32 # 图片保存到相册(#32
image_gallery_saver_plus: ^3.0.5 image_gallery_saver_plus: ^3.0.5