feat(client): 商品详情页图片支持双击全屏查看
抽出共享全屏查看器 fullscreen_image_viewer.dart(复用扫码页的 InteractiveViewer+PageView+黑底 modal),详情页缩略图双击打开, 扫码公开页改为复用同一组件去重。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/utils/print_util.dart';
|
import '../../core/utils/print_util.dart';
|
||||||
import '../../widgets/label_preview_dialog.dart';
|
import '../../widgets/label_preview_dialog.dart';
|
||||||
|
import '../../widgets/fullscreen_image_viewer.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/product.dart';
|
import '../../models/product.dart';
|
||||||
@@ -309,9 +310,16 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
|||||||
height: 120,
|
height: 120,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
...p.images.map((img) => _ImageThumbnail(
|
...p.images.asMap().entries.map((e) => _ImageThumbnail(
|
||||||
url: AppConfig.baseUrl + img.url,
|
url: AppConfig.baseUrl + e.value.url,
|
||||||
onDelete: () => _deleteImage(img),
|
onDelete: () => _deleteImage(e.value),
|
||||||
|
onView: () => showFullscreenImages(
|
||||||
|
context,
|
||||||
|
p.images
|
||||||
|
.map((img) => AppConfig.baseUrl + img.url)
|
||||||
|
.toList(),
|
||||||
|
initialIndex: e.key,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
if (p.images.length < 5)
|
if (p.images.length < 5)
|
||||||
WriteGuard(
|
WriteGuard(
|
||||||
@@ -567,7 +575,9 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
|||||||
class _ImageThumbnail extends StatelessWidget {
|
class _ImageThumbnail extends StatelessWidget {
|
||||||
final String url;
|
final String url;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
const _ImageThumbnail({required this.url, required this.onDelete});
|
final VoidCallback onView;
|
||||||
|
const _ImageThumbnail(
|
||||||
|
{required this.url, required this.onDelete, required this.onView});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -575,19 +585,22 @@ class _ImageThumbnail extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
GestureDetector(
|
||||||
borderRadius: BorderRadius.circular(4),
|
onDoubleTap: onView,
|
||||||
child: Image.network(
|
child: ClipRRect(
|
||||||
url,
|
borderRadius: BorderRadius.circular(4),
|
||||||
width: 110,
|
child: Image.network(
|
||||||
height: 110,
|
url,
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) => Container(
|
|
||||||
width: 110,
|
width: 110,
|
||||||
height: 110,
|
height: 110,
|
||||||
color: AppTheme.border,
|
fit: BoxFit.cover,
|
||||||
child: const Icon(Icons.broken_image,
|
errorBuilder: (_, __, ___) => Container(
|
||||||
color: AppTheme.textSecondary),
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
color: AppTheme.border,
|
||||||
|
child: const Icon(Icons.broken_image,
|
||||||
|
color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
|
import '../../widgets/fullscreen_image_viewer.dart';
|
||||||
|
|
||||||
// ── Palette & typography constants ────────────────────────────────────
|
// ── Palette & typography constants ────────────────────────────────────
|
||||||
const _kPaper = Color(0xFFFAF8F5);
|
const _kPaper = Color(0xFFFAF8F5);
|
||||||
@@ -476,12 +477,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openFullscreen(int index) {
|
void _openFullscreen(int index) {
|
||||||
Navigator.of(context).push(PageRouteBuilder(
|
showFullscreenImages(context, widget.imageUrls, initialIndex: index);
|
||||||
opaque: false,
|
|
||||||
barrierColor: Colors.black87,
|
|
||||||
pageBuilder: (_, __, ___) => _FullscreenViewer(
|
|
||||||
urls: widget.imageUrls, initialIndex: index),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,66 +530,6 @@ class _GlassChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Full-screen image viewer ──────────────────────────────────────────
|
|
||||||
|
|
||||||
class _FullscreenViewer extends StatefulWidget {
|
|
||||||
final List<String> urls;
|
|
||||||
final int initialIndex;
|
|
||||||
const _FullscreenViewer({required this.urls, required this.initialIndex});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_FullscreenViewer> createState() => _FullscreenViewerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FullscreenViewerState extends State<_FullscreenViewer> {
|
|
||||||
late PageController _ctrl;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_ctrl = PageController(initialPage: widget.initialIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
PageView.builder(
|
|
||||||
controller: _ctrl,
|
|
||||||
itemCount: widget.urls.length,
|
|
||||||
itemBuilder: (_, i) => Center(
|
|
||||||
child: InteractiveViewer(
|
|
||||||
child: Image.network(widget.urls[i], fit: BoxFit.contain,
|
|
||||||
errorBuilder: (_, __, ___) =>
|
|
||||||
const Icon(Icons.broken_image, size: 64, color: Colors.white54)),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 40, right: 16,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
|
||||||
child: const Icon(Icons.close, color: Colors.white, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Verified Ribbon ───────────────────────────────────────────────────
|
// ── Verified Ribbon ───────────────────────────────────────────────────
|
||||||
|
|
||||||
class _VerifiedRibbon extends StatelessWidget {
|
class _VerifiedRibbon extends StatelessWidget {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 全屏图片查看器:黑底 modal + 多图左右翻页(PageView)+ 双指缩放/拖拽(InteractiveViewer)。
|
||||||
|
/// 点击背景或右上角关闭按钮退出。纯 Flutter 内置组件,无需额外依赖。
|
||||||
|
///
|
||||||
|
/// 用法:
|
||||||
|
/// ```dart
|
||||||
|
/// showFullscreenImages(context, urls, initialIndex: 2);
|
||||||
|
/// ```
|
||||||
|
void showFullscreenImages(BuildContext context, List<String> urls,
|
||||||
|
{int initialIndex = 0}) {
|
||||||
|
if (urls.isEmpty) return;
|
||||||
|
Navigator.of(context).push(PageRouteBuilder(
|
||||||
|
opaque: false,
|
||||||
|
barrierColor: Colors.black87,
|
||||||
|
pageBuilder: (_, __, ___) =>
|
||||||
|
FullscreenImageViewer(urls: urls, initialIndex: initialIndex),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
class FullscreenImageViewer extends StatefulWidget {
|
||||||
|
final List<String> urls;
|
||||||
|
final int initialIndex;
|
||||||
|
const FullscreenImageViewer(
|
||||||
|
{super.key, required this.urls, this.initialIndex = 0});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FullscreenImageViewer> createState() => _FullscreenImageViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FullscreenImageViewerState extends State<FullscreenImageViewer> {
|
||||||
|
late PageController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = PageController(initialPage: widget.initialIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
PageView.builder(
|
||||||
|
controller: _ctrl,
|
||||||
|
itemCount: widget.urls.length,
|
||||||
|
itemBuilder: (_, i) => Center(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.network(widget.urls[i], fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white54)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 40,
|
||||||
|
right: 16,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user