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

@@ -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-zoom1x5x双击 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<String> urls;
final int initialIndex;
/// Hero tag单图时传入使动画生效与气泡中 Hero tag 对应)
final String? heroTag;
/// 打开全屏图片查看页
static void open(
BuildContext context, {
required List<String> urls,
int initialIndex = 0,
String? heroTag,
}) {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex),
fullscreenDialog: true,
PageRouteBuilder<void>(
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<ImageViewerPage> createState() => _ImageViewerPageState();
}
class _ImageViewerPageState extends State<ImageViewerPage> {
class _ImageViewerPageState extends State<ImageViewerPage>
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<void> _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<ImageViewerPage> {
}
}
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<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
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<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(
'分享',
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),
),
),
),
);
}
}

View File

@@ -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(

View File

@@ -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,
),
),
),
)