Files
jiu/client/lib/screens/public/public_product_screen.dart
T
wangjia 3b02a848c0
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m46s
fix(client): 商品公开页加载后更新浏览器标题为商品名称
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:26:22 +08:00

1640 lines
57 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
// ── Palette & typography constants ────────────────────────────────────
const _kPaper = Color(0xFFFAF8F5);
const _kPaperDeep = Color(0xFFF4F1EB);
const _kInkDeep = Color(0xFF161412);
const _kInkSoft = Color(0xFF2A2725);
const _kBurgundy = Color(0xFF8B2331);
const _kBurgundyLight = Color(0xFFF7ECED);
const _kTextMid = Color(0xFF6E7888);
const _kBorder = Color(0xFFE8E4DC);
const _kBorderSub = Color(0xFFF0EDE7);
const _kSuccess = Color(0xFF2E8B57);
const _kSuccessLight = Color(0xFFE8F5EE);
const _kGalleryDark = Color(0xFF100C0A);
// ─────────────────────────────────────────────────────────────────────
class PublicProductScreen extends StatefulWidget {
final String publicId;
const PublicProductScreen({super.key, required this.publicId});
@override
State<PublicProductScreen> createState() => _PublicProductScreenState();
}
class _PublicProductScreenState extends State<PublicProductScreen> {
Map<String, dynamic>? _data;
String? _error;
bool _loading = true;
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final resp = await _dio.get(
'${AppConfig.apiBaseUrl}/public/products/${widget.publicId}',
);
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
setState(() { _data = data; _loading = false; });
// 更新浏览器标签标题(Flutter Web 会写 document.title
final name = data['name'] as String? ?? '';
if (name.isNotEmpty) {
SystemChrome.setApplicationSwitcherDescription(
ApplicationSwitcherDescription(label: name, primaryColor: 0xFF8B2331),
);
}
} catch (e) {
setState(() { _error = e.toString(); _loading = false; });
}
}
void _showFeedbackDialog({
required String title,
required String hint,
required String errorType,
}) {
showDialog<void>(
context: context,
builder: (ctx) => _FeedbackDialog(
title: title,
hint: hint,
onSubmit: (msg) async {
final shopNo = (_data?['shop'] as Map<String, dynamic>?)?['code'] as String? ?? '';
final productName = _data?['name'] as String? ?? '';
final productCode = _data?['code'] as String? ?? '';
final productInfo = [
if (productName.isNotEmpty) productName,
if (productCode.isNotEmpty) productCode,
if (widget.publicId.isNotEmpty) widget.publicId,
].join(' / ');
await _dio.post(
'${AppConfig.apiBaseUrl}/public/errors',
data: {
'error_type': errorType,
'error_msg': msg,
'platform': 'web-scan',
'shop_no': shopNo,
if (productInfo.isNotEmpty) 'product_info': productInfo,
},
);
},
),
);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
backgroundColor: _kPaper,
body: Center(child: CircularProgressIndicator(color: _kBurgundy, strokeWidth: 2)),
);
}
if (_error != null || _data == null) {
return Scaffold(
backgroundColor: _kPaper,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: const Color(0xFFFDECEC),
shape: BoxShape.circle,
),
child: const Icon(Icons.error_outline, size: 28, color: Color(0xFFD14343)),
),
const SizedBox(height: 16),
const Text('商品不存在或已下架',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep)),
const SizedBox(height: 8),
const Text('请检查二维码是否完整,或联系出售方确认',
style: TextStyle(fontSize: 13, color: _kTextMid), textAlign: TextAlign.center),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _load,
style: ElevatedButton.styleFrom(
backgroundColor: _kBurgundy,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('重试'),
),
],
),
),
),
);
}
final d = _data!;
final images = (d['images'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final imageUrls = images.map((img) => AppConfig.baseUrl + (img['url'] as String)).toList();
final name = d['name'] as String? ?? '';
final series = d['series'] as String? ?? '';
final spec = d['spec'] as String? ?? '';
final brand = d['brand'] as String? ?? '';
final unit = d['unit'] as String? ?? '';
final description = d['description'] as String? ?? '';
final descriptionTitle = d['description_title'] as String? ?? '商品介绍';
final descriptionKeywords = (d['description_keywords'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ?? const <String>[];
final salePrice = (d['sale_price'] as num?)?.toDouble() ?? 0.0;
final origin = d['origin'] as String? ?? '';
final shelfLife = d['shelf_life'] as String? ?? '无限期(适饮)';
final storage = d['storage'] as String? ?? '阴凉干燥、避光保存';
final shop = d['shop'] as Map<String, dynamic>?;
final batch = d['batch'] as Map<String, dynamic>?;
final quickSpecs = _parseQuickSpecs(spec, batch?['production_date'] as String?);
final productionDate = batch?['production_date'] as String?;
return Scaffold(
backgroundColor: _kPaperDeep,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: CustomScrollView(
slivers: [
// 1. Gallery
SliverToBoxAdapter(
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
),
// 2. Verified ribbon
SliverToBoxAdapter(
child: _VerifiedRibbon(shop: shop),
),
// 3. Editorial title block
SliverToBoxAdapter(
child: _TitleBlock(
name: name,
brand: brand,
series: series,
spec: spec,
unit: unit,
),
),
// 4a. Sale price badge (only when price > 0)
if (salePrice > 0)
SliverToBoxAdapter(
child: _PriceBadge(price: salePrice),
),
// 4b. Quick specs (only if we could parse any)
if (quickSpecs.isNotEmpty)
SliverToBoxAdapter(
child: _QuickSpecs(items: quickSpecs),
),
// 5. Description — always shown; body/keywords come from backend (三级回退)
SliverToBoxAdapter(
child: _DescriptionSection(
title: descriptionTitle,
body: description,
keywords: descriptionKeywords,
),
),
// 6. Params — full table
SliverToBoxAdapter(
child: _ParamsCard(
publicId: widget.publicId,
spec: spec,
brand: brand,
series: series,
unit: unit,
productionDate: productionDate,
origin: origin,
shelfLife: shelfLife,
storage: storage,
),
),
// 7. Authenticity card (batch + stamp)
if (batch != null)
SliverToBoxAdapter(
child: _AuthenticityCard(batch: batch),
),
// 8. Shop card
if (shop != null)
SliverToBoxAdapter(child: _ShopCard(shop: shop)),
// 9. Footer
SliverToBoxAdapter(
child: _Footer(
onReport: () => _showFeedbackDialog(
title: '商品报错',
hint: '请描述商品信息有误的地方...',
errorType: 'product_error',
),
onFeedback: () => _showFeedbackDialog(
title: '意见反馈',
hint: '请输入您的建议或意见...',
errorType: 'feedback',
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
),
),
);
}
// Attempt to extract quick spec items from free-text spec string
static List<({String value, String unit, String label})> _parseQuickSpecs(
String spec, String? productionDate) {
final items = <({String value, String unit, String label})>[];
// Alcohol degree: 53度 / 53° / 53%vol
final degReg = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false);
final degM = degReg.firstMatch(spec);
if (degM != null) {
items.add((value: degM.group(1)!, unit: '°', label: '酒精度'));
}
// Volume in ml
final mlReg = RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false);
final mlM = mlReg.firstMatch(spec);
if (mlM != null) {
items.add((value: mlM.group(1)!, unit: 'ml', label: '净含量'));
}
// Fragrance type: 清香/浓香/酱香/兼香/馥郁香/凤香
final xiangReg = RegExp(r'([清浓酱兼馥凤]香)');
final xiangM = xiangReg.firstMatch(spec);
if (xiangM != null) {
items.add((value: xiangM.group(1)!, unit: '', label: '香型'));
}
// Year from production date
if (productionDate != null) {
final yearM = RegExp(r'^(\d{4})').firstMatch(productionDate);
if (yearM != null) {
items.add((value: yearM.group(1)!, unit: '', label: '年份'));
}
}
return items;
}
}
// ── Hero Gallery ──────────────────────────────────────────────────────
class _HeroGallery extends StatefulWidget {
final List<String> imageUrls;
final String shopName;
const _HeroGallery({required this.imageUrls, this.shopName = ''});
@override
State<_HeroGallery> createState() => _HeroGalleryState();
}
class _HeroGalleryState extends State<_HeroGallery> {
int _current = 0;
final PageController _ctrl = PageController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final urls = widget.imageUrls;
final height = math.min(MediaQuery.of(context).size.width * 0.95, 440.0);
return SizedBox(
height: height,
child: Stack(
fit: StackFit.expand,
children: [
// Dark background gradient
Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -0.2),
radius: 1.4,
colors: [Color(0xFF2A1F1A), _kGalleryDark],
),
),
),
// Golden halo glow (behind product)
Center(
child: Container(
width: 240, height: 240,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFC8A572).withValues(alpha: 0.12),
Colors.transparent,
],
),
),
),
),
// Image or placeholder
if (urls.isEmpty)
_EmptyGalleryContent(shopName: widget.shopName)
else
PageView.builder(
controller: _ctrl,
itemCount: urls.length,
onPageChanged: (i) => setState(() => _current = i),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _openFullscreen(i),
child: Image.network(
urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
),
),
),
// Top gradient scrim
Positioned(
top: 0, left: 0, right: 0, height: 64,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent],
),
),
),
),
// "点击放大" hint (top right)
if (urls.isNotEmpty)
Positioned(
top: 52, right: 14,
child: _GlassChip(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.zoom_in, size: 12, color: Colors.white),
SizedBox(width: 4),
Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)),
],
),
),
),
// Bottom: dots + page counter
Positioned(
bottom: 16, left: 0, right: 0,
child: Row(
children: [
const SizedBox(width: 16),
// Dots
...List.generate(math.max(urls.length, 1), (i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: i == _current ? 18 : 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: i == _current
? Colors.white.withValues(alpha: 0.95)
: Colors.white.withValues(alpha: 0.30),
borderRadius: BorderRadius.circular(999),
),
)),
const Spacer(),
if (urls.length > 1)
Text(
'${_current + 1} / ${urls.length}',
style: const TextStyle(
fontSize: 11, color: Color(0x88FFFFFF),
fontFeatures: [FontFeature.tabularFigures()],
letterSpacing: 0.5,
),
),
const SizedBox(width: 16),
],
),
),
// Thumbnail strip
if (urls.length > 1)
Positioned(
bottom: 44, left: 0, right: 0,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: ListView.separated(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: urls.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _ctrl.animateToPage(i,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 44, height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: i == _current
? Colors.white
: Colors.white.withValues(alpha: 0.25),
width: i == _current ? 2 : 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.network(urls[i], fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox()),
),
),
),
),
),
),
],
),
);
}
void _openFullscreen(int index) {
Navigator.of(context).push(PageRouteBuilder(
opaque: false,
barrierColor: Colors.black87,
pageBuilder: (_, __, ___) => _FullscreenViewer(
urls: widget.imageUrls, initialIndex: index),
));
}
}
class _EmptyGalleryContent extends StatelessWidget {
final String shopName;
const _EmptyGalleryContent({this.shopName = ''});
@override
Widget build(BuildContext context) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64, height: 64,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
const Text('暂无图片',
style: TextStyle(fontSize: 12, color: Color(0x669E9580))),
],
),
);
}
}
class _GlassChip extends StatelessWidget {
final Widget child;
const _GlassChip({required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.10),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
borderRadius: BorderRadius.circular(999),
),
child: child,
);
}
}
// ── 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 ───────────────────────────────────────────────────
class _VerifiedRibbon extends StatelessWidget {
final Map<String, dynamic>? shop;
const _VerifiedRibbon({this.shop});
@override
Widget build(BuildContext context) {
final shopName = shop?['name'] as String? ?? '';
final shopCode = shop?['code'] as String? ?? '';
final detail = [
if (shopName.isNotEmpty) shopName,
if (shopCode.isNotEmpty) shopCode,
].join(' · ');
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _kBorderSub)),
),
child: Row(
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
color: _kSuccessLight,
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFB4E2C5)),
),
child: const Icon(Icons.check, size: 15, color: _kSuccess),
),
const SizedBox(width: 12),
Expanded(
child: detail.isNotEmpty
? Text.rich(TextSpan(
text: '来自 ',
style: const TextStyle(fontSize: 13, color: _kTextMid),
children: [
TextSpan(
text: detail,
style: const TextStyle(
fontWeight: FontWeight.w600, color: Color(0xFF353C48)),
),
],
))
: Text.rich(
TextSpan(
text: 'powered by ',
style: const TextStyle(fontSize: 13, color: _kTextMid),
children: [
TextSpan(
text: '岩美科技',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF2563AC),
decoration: TextDecoration.underline,
decorationColor: Color(0xFF2563AC),
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse('https://www.yanmei.com'),
mode: LaunchMode.externalApplication),
),
],
),
),
),
const Icon(Icons.chevron_right, size: 18, color: _kTextMid),
],
),
);
}
}
// ── Price Badge ───────────────────────────────────────────────────────
class _PriceBadge extends StatelessWidget {
final double price;
const _PriceBadge({required this.price});
@override
Widget build(BuildContext context) {
final priceStr = price % 1 == 0
? price.toInt().toString()
: price.toStringAsFixed(2);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: _kPaper,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Row(
children: [
const Text('建议零售价',
style: TextStyle(fontSize: 13, color: _kTextMid)),
const Spacer(),
Text(
'¥$priceStr',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _kBurgundy,
letterSpacing: 0.04,
),
),
],
),
);
}
}
// ── Title Block ───────────────────────────────────────────────────────
class _TitleBlock extends StatelessWidget {
final String name, brand, series, spec, unit;
const _TitleBlock({
required this.name, required this.brand, required this.series,
required this.spec, required this.unit,
});
@override
Widget build(BuildContext context) {
// Brand/series separator row text
final separatorParts = <String>[];
if (brand.isNotEmpty) separatorParts.add(brand);
if (series.isNotEmpty) separatorParts.add(series);
final separatorText = separatorParts.join(' · ');
// Spec detail line: prefer parsed display, fall back to raw spec
final specLine = _buildSpecLine(spec, unit, series);
return Container(
padding: const EdgeInsets.fromLTRB(20, 32, 20, 24),
decoration: const BoxDecoration(
color: _kPaper,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Column(
children: [
// — Brand · Series —
if (separatorText.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)),
const SizedBox(width: 10),
Text(
separatorText,
style: const TextStyle(
fontSize: 11, letterSpacing: 0.28,
color: _kBurgundy, fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 10),
Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)),
],
),
if (separatorText.isNotEmpty) const SizedBox(height: 14),
// Product name — large editorial
Text(
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 38, fontWeight: FontWeight.w700,
letterSpacing: 0.08, color: _kInkDeep,
height: 1.15,
),
),
// Key spec subtitle (e.g. "53° · 酱香型白酒")
if (_keySpec(spec).isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_keySpec(spec),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17, fontWeight: FontWeight.w500,
color: _kBurgundy, letterSpacing: 0.04,
),
),
],
// Detail spec line
if (specLine.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
specLine,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13, color: _kTextMid, letterSpacing: 0.03,
),
),
],
// Tags
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 8, runSpacing: 6,
children: [
if (series.isNotEmpty)
_TitleTag(label: series, style: _TitleTagStyle.burgundy),
_TitleTag(label: '正品保证', style: _TitleTagStyle.green),
if (unit.isNotEmpty && unit != series)
_TitleTag(label: unit, style: _TitleTagStyle.neutral),
],
),
],
),
);
}
// Extract "53° · 酱香型白酒" from spec
String _keySpec(String spec) {
final parts = <String>[];
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec);
if (degM != null) parts.add('${degM.group(1)!}°');
final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec);
if (xiangM != null) parts.add(xiangM.group(1)!);
return parts.join(' · ');
}
String _buildSpecLine(String spec, String unit, String series) {
// Remove parts already captured in _keySpec
var s = spec
.replaceAll(RegExp(r'\d+(?:\.\d+)?\s*(?:度|°|%vol)', caseSensitive: false), '')
.replaceAll(RegExp(r'[清浓酱兼馥凤]香(?:型(?:白酒)?)?'), '')
.replaceAll(RegExp(r'^[·\s]+|[·\s]+$'), '')
.trim();
if (s.isEmpty) s = spec;
// Append series if not already in spec
if (series.isNotEmpty && !s.contains(series)) s = '$s · $series';
return s.trim();
}
}
enum _TitleTagStyle { burgundy, green, neutral }
class _TitleTag extends StatelessWidget {
final String label;
final _TitleTagStyle style;
const _TitleTag({required this.label, required this.style});
@override
Widget build(BuildContext context) {
late Color bg, fg;
switch (style) {
case _TitleTagStyle.burgundy:
bg = _kBurgundyLight;
fg = _kBurgundy;
case _TitleTagStyle.green:
bg = const Color(0xFFE8F5EE);
fg = const Color(0xFF1F6B41);
case _TitleTagStyle.neutral:
bg = const Color(0xFFEEEAE3);
fg = const Color(0xFF6B6259);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
child: Text(label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600,
color: fg, letterSpacing: 0.06)),
);
}
}
// ── Quick Specs Grid ──────────────────────────────────────────────────
class _QuickSpecs extends StatelessWidget {
final List<({String value, String unit, String label})> items;
const _QuickSpecs({required this.items});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Row(
children: items.asMap().entries.map((entry) {
final i = entry.key;
final it = entry.value;
return Expanded(
child: Container(
decoration: BoxDecoration(
border: i == 0
? null
: const Border(left: BorderSide(color: _kBorderSub)),
),
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
text: it.value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w700,
color: _kInkDeep, height: 1,
fontFeatures: [FontFeature.tabularFigures()],
),
children: it.unit.isNotEmpty
? [
TextSpan(
text: it.unit,
style: const TextStyle(
fontSize: 10, fontWeight: FontWeight.w500,
color: _kTextMid),
)
]
: null,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(it.label,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11, color: _kTextMid, letterSpacing: 0.06)),
],
),
),
);
}).toList(),
),
);
}
}
// ── Description Section ───────────────────────────────────────────────
class _DescriptionSection extends StatelessWidget {
final String title;
final String body;
final List<String> keywords;
const _DescriptionSection({
required this.title,
required this.body,
required this.keywords,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(title: title),
const SizedBox(height: 14),
Text(
body,
style: const TextStyle(
fontSize: 14, color: _kInkSoft, height: 1.85, letterSpacing: 0.02),
textAlign: TextAlign.justify,
),
const SizedBox(height: 16),
Wrap(
spacing: 8, runSpacing: 6,
children: keywords.map((kw) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F3EF),
border: Border.all(color: _kBorderSub),
borderRadius: BorderRadius.circular(999),
),
child: Text(kw, style: const TextStyle(
fontSize: 11, color: _kTextMid)),
)).toList(),
),
],
),
);
}
}
// ── Authenticity Card ─────────────────────────────────────────────────
class _AuthenticityCard extends StatelessWidget {
final Map<String, dynamic> batch;
const _AuthenticityCard({required this.batch});
@override
Widget build(BuildContext context) {
final batchNo = batch['batch_no'] as String? ?? '';
final productionDate = batch['production_date'] as String? ?? '';
final inStockDate = batch['in_stock_date'] as String? ?? '';
final quantity = batch['quantity'];
final qty = quantity is int ? quantity : (quantity as num?)?.toInt() ?? 0;
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
decoration: const BoxDecoration(
color: _kPaper,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionHeader(title: '批次与防伪'),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _kBorder),
),
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (batchNo.isNotEmpty) ...[
_AuthRow(label: '批次号', value: batchNo, mono: true),
const SizedBox(height: 14),
],
if (productionDate.isNotEmpty) ...[
_AuthRow(label: '生产日期', value: productionDate),
const SizedBox(height: 14),
],
if (inStockDate.isNotEmpty) ...[
_AuthRow(label: '入库日期', value: inStockDate),
const SizedBox(height: 14),
],
if (qty > 0)
_AuthRow(label: '批次数量', value: '$qty'),
],
),
),
const SizedBox(width: 16),
Transform.rotate(
angle: -8 * math.pi / 180,
child: const _AuthStamp(),
),
],
),
),
const SizedBox(height: 12),
const Text(
'此防伪码已与本批次库存绑定,扫码即完成验证,可放心购买。',
style: TextStyle(fontSize: 11, color: _kTextMid, height: 1.6),
),
],
),
);
}
}
class _AuthRow extends StatelessWidget {
final String label, value;
final bool mono;
const _AuthRow({required this.label, required this.value, this.mono = false});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(fontSize: 10, color: _kTextMid, letterSpacing: 0.05)),
const SizedBox(height: 3),
Text(value,
style: TextStyle(
fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500,
fontFamily: mono ? 'monospace' : null,
letterSpacing: mono ? 0.5 : 0,
)),
],
);
}
}
class _AuthStamp extends StatelessWidget {
const _AuthStamp();
@override
Widget build(BuildContext context) {
return SizedBox(
width: 84, height: 84,
child: CustomPaint(painter: _StampPainter()),
);
}
}
class _StampPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
final cy = size.height / 2;
final center = Offset(cx, cy);
final outerR = size.width / 2 - 1;
final innerR = outerR - 3;
// Double-ring border
final ringPaint = Paint()
..color = _kBurgundy
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawCircle(center, outerR, ringPaint);
canvas.drawCircle(center, innerR, ringPaint);
// Circular text "防伪验证 · VERIFIED · "
_drawCircularText(canvas, '防 伪 验 证 · V E R I F I E D · ', center, innerR - 7);
// Center: checkmark
final checkPaint = Paint()
..color = _kBurgundy
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(cx - 9, cy - 1)
..lineTo(cx - 3, cy + 6)
..lineTo(cx + 10, cy - 8);
canvas.drawPath(path, checkPaint);
// "已验证" below checkmark
final tp = TextPainter(
text: const TextSpan(
text: '已验证',
style: TextStyle(
fontSize: 8.5, fontWeight: FontWeight.w700,
color: _kBurgundy, letterSpacing: 1.0,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(cx - tp.width / 2, cy + 10));
}
void _drawCircularText(Canvas canvas, String text, Offset center, double radius) {
final charCount = text.length;
final angleStep = (2 * math.pi) / charCount;
const startAngle = -math.pi / 2;
for (int i = 0; i < charCount; i++) {
final angle = startAngle + i * angleStep;
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(angle + math.pi / 2);
final tp = TextPainter(
text: TextSpan(
text: text[i],
style: const TextStyle(
fontSize: 6.5, color: _kBurgundy,
fontWeight: FontWeight.w600, letterSpacing: 0,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(-tp.width / 2, -radius - tp.height));
canvas.restore();
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// ── Params Table ──────────────────────────────────────────────────────
class _ParamsCard extends StatelessWidget {
final String publicId, spec, brand, series, unit;
final String? productionDate;
final String origin; // 空串表示无产地数据,不显示该行
final String shelfLife; // 后端已回填默认值
final String storage; // 后端已回填默认值
const _ParamsCard({
required this.publicId,
required this.spec,
required this.brand,
required this.series,
required this.unit,
this.productionDate,
this.origin = '',
this.shelfLife = '无限期(适饮)',
this.storage = '阴凉干燥、避光保存',
});
@override
Widget build(BuildContext context) {
// Parse alcohol and volume from spec
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec);
final mlM = RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false).firstMatch(spec);
final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec);
final rows = <({String label, String value, bool mono})>[
if (publicId.isNotEmpty) (label: '商品编码', value: publicId, mono: true),
if (brand.isNotEmpty) (label: '品牌', value: brand, mono: false),
if (series.isNotEmpty) (label: '系列', value: series, mono: false),
if (xiangM != null) (label: '香型', value: xiangM.group(1)!, mono: false),
if (degM != null) (label: '酒精度', value: '${degM.group(1)!}% vol', mono: false),
if (mlM != null) (label: '净含量', value: '${mlM.group(1)!} ml', mono: false),
if (spec.isNotEmpty) (label: '规格', value: spec, mono: false),
if (origin.isNotEmpty) (label: '产地', value: origin, mono: false),
if (productionDate != null) (label: '生产日期', value: productionDate!, mono: false),
(label: '保质期', value: shelfLife, mono: false),
(label: '储存方式', value: storage, mono: false),
];
if (rows.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(title: '商品参数'),
const SizedBox(height: 14),
...rows.asMap().entries.map((entry) {
final i = entry.key;
final r = entry.value;
return Column(
children: [
if (i > 0)
const Divider(height: 1, color: _kBorderSub, indent: 0),
Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(r.label,
style: const TextStyle(fontSize: 12, color: _kTextMid, letterSpacing: 0.02)),
),
const SizedBox(width: 16),
Expanded(
child: Text(r.value,
style: TextStyle(
fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500,
fontFamily: r.mono ? 'monospace' : null,
letterSpacing: r.mono ? 0.5 : 0.01,
)),
),
],
),
),
],
);
}),
],
),
);
}
}
// ── Section Header ────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(width: 3, height: 16, color: _kBurgundy,
margin: const EdgeInsets.only(right: 10)),
Text(title,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700,
color: _kInkDeep, letterSpacing: 0.06)),
],
);
}
}
// ── Shop Card ─────────────────────────────────────────────────────────
class _ShopCard extends StatelessWidget {
final Map<String, dynamic> shop;
const _ShopCard({required this.shop});
@override
Widget build(BuildContext context) {
final name = shop['name'] as String? ?? '';
final code = shop['code'] as String? ?? '';
final address = shop['address'] as String? ?? '';
final phone = shop['phone'] as String? ?? '';
final hours = shop['business_hours'] as String? ?? '';
final wechatId = shop['wechat_id'] as String? ?? '';
// Avatar text: first 2 chars of shop name
final avatarText = name.length >= 2 ? name.substring(0, 2) : name;
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(title: '销售门店'),
const SizedBox(height: 16),
// Shop header row
Row(
children: [
Container(
width: 52, height: 52,
decoration: BoxDecoration(
color: const Color(0xFF0A1F3B),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(avatarText,
style: const TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w700,
letterSpacing: 0.04)),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep,
letterSpacing: 0.04)),
if (code.isNotEmpty) ...[
const SizedBox(height: 2),
Text('门店编号 $code',
style: const TextStyle(fontSize: 12, color: _kTextMid)),
],
],
),
),
],
),
const SizedBox(height: 16),
Container(height: 1, color: _kBorderSub),
const SizedBox(height: 14),
if (address.isNotEmpty) _ShopInfoRow(icon: Icons.location_on_outlined, text: address),
if (phone.isNotEmpty) _ShopInfoRow(icon: Icons.phone_outlined, text: phone),
_ShopInfoRow(
icon: Icons.access_time_outlined,
text: '营业时间 ${hours.isNotEmpty ? hours : "09:30 22:00"}',
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity, height: 44,
child: ElevatedButton(
onPressed: code.isNotEmpty
? () => context.push(
'/shop/$code?shopName=${Uri.encodeComponent(name)}')
: null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0A1F3B),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('查看本店其他商品 →',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.04)),
),
),
if (wechatId.isNotEmpty) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity, height: 40,
child: OutlinedButton.icon(
onPressed: () => _showWechatDialog(context, wechatId),
style: OutlinedButton.styleFrom(
foregroundColor: _kInkDeep,
side: const BorderSide(color: _kBorder, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.person_outline, size: 16),
label: const Text('添加门店微信',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
),
],
],
),
);
}
}
void _showWechatDialog(BuildContext context, String wechatId) {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('门店微信号', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wechat, size: 48, color: Color(0xFF07C160)),
const SizedBox(height: 12),
SelectableText(
wechatId,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text('长按上方微信号可复制', style: TextStyle(fontSize: 12, color: _kTextMid)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
);
}
class _ShopInfoRow extends StatelessWidget {
final IconData icon;
final String text;
const _ShopInfoRow({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 14, color: _kTextMid),
const SizedBox(width: 10),
Expanded(
child: Text(text,
style: const TextStyle(fontSize: 13, color: _kInkSoft, height: 1.5)),
),
],
),
);
}
}
// ── Footer ────────────────────────────────────────────────────────────
class _Footer extends StatelessWidget {
final VoidCallback? onReport;
final VoidCallback? onFeedback;
const _Footer({this.onReport, this.onFeedback});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: _kPaperDeep),
padding: const EdgeInsets.symmetric(vertical: 28),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 20, height: 20,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2563AC), Color(0xFF1A4580)],
),
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563AC).withValues(alpha: 0.30),
blurRadius: 4, offset: const Offset(0, 2)),
],
),
child: const Center(
child: Text('',
style: TextStyle(color: Colors.white, fontSize: 11,
fontWeight: FontWeight.w800, letterSpacing: -0.5)),
),
),
const SizedBox(width: 8),
Text.rich(
const TextSpan(
text: '',
style: TextStyle(fontSize: 11, color: _kTextMid),
children: [
TextSpan(
text: '岩美酒库管理系统',
style: TextStyle(fontWeight: FontWeight.w600, color: Color(0xFF353C48)),
),
TextSpan(text: ' 提供'),
],
),
),
],
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_FooterTextLink(text: '商品报错', onTap: onReport),
const _FooterDivider(),
_FooterTextLink(text: '意见反馈', onTap: onFeedback),
const _FooterDivider(),
_FooterTextLink(text: '关于岩美', url: AppConfig.aboutUrl),
],
),
],
),
);
}
}
class _FooterTextLink extends StatelessWidget {
final String text;
final String? url;
final VoidCallback? onTap;
const _FooterTextLink({required this.text, this.url, this.onTap});
@override
Widget build(BuildContext context) {
final active = url != null || onTap != null;
return GestureDetector(
onTap: onTap ?? (url != null
? () => launchUrl(Uri.parse(url!), mode: LaunchMode.externalApplication)
: null),
child: Text(text,
style: TextStyle(
fontSize: 11,
color: active ? const Color(0xFF2563AC) : _kTextMid,
letterSpacing: 0.02,
decoration: active ? TextDecoration.underline : null,
decorationColor: active ? const Color(0xFF2563AC) : null,
)),
);
}
}
class _FooterDivider extends StatelessWidget {
const _FooterDivider();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('·', style: TextStyle(fontSize: 11, color: _kTextMid)),
);
}
}
// ── Feedback / Report Dialog ──────────────────────────────────────────
class _FeedbackDialog extends StatefulWidget {
final String title;
final String hint;
final Future<void> Function(String) onSubmit;
const _FeedbackDialog({required this.title, required this.hint, required this.onSubmit});
@override
State<_FeedbackDialog> createState() => _FeedbackDialogState();
}
class _FeedbackDialogState extends State<_FeedbackDialog> {
final _ctrl = TextEditingController();
bool _submitting = false;
bool _done = false;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_ctrl.text.trim().isEmpty) return;
setState(() => _submitting = true);
try {
await widget.onSubmit(_ctrl.text.trim());
if (mounted) setState(() { _done = true; _submitting = false; });
} catch (_) {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
if (_done) {
return AlertDialog(
title: Text(widget.title),
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline, size: 48, color: _kSuccess),
SizedBox(height: 12),
Text('提交成功,感谢您的反馈!',
style: TextStyle(fontSize: 14, color: _kInkDeep)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
);
}
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: 320,
child: TextField(
controller: _ctrl,
maxLines: 4,
autofocus: true,
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: const TextStyle(fontSize: 13, color: _kTextMid),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.all(12),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: _submitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: _kBurgundy, foregroundColor: Colors.white),
child: _submitting
? const SizedBox(width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('提交'),
),
],
);
}
}