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