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:
143
Doc/image_viewer_architecture.md
Normal file
143
Doc/image_viewer_architecture.md
Normal file
@@ -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()` 在设置页集成
|
||||||
@@ -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-zoom(1x–5x),双击 2.5x
|
/// - Pinch-to-zoom(1x–5x),双击 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user