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:
@@ -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<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user