Files
jiu/client/lib/screens/public/public_product_screen.dart
T
wangjia d48f9f4bd0
Deploy / deploy (push) Successful in 1m20s
fix(client): 商品公开页暂无图片时显示门店名首字,替换硬编码岩美
_EmptyGalleryContent 接收 shopName 参数,无图片时展示
门店名第一个字符的文字头像,不再显示品牌名。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:59:17 +08:00

1400 lines
49 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: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; });
} catch (e) {
setState(() { _error = e.toString(); _loading = false; });
}
}
@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 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,
),
),
// 4. Quick specs (only if we could parse any)
if (quickSpecs.isNotEmpty)
SliverToBoxAdapter(
child: _QuickSpecs(items: quickSpecs),
),
// 5. Description — always shown; falls back to editorial mock text
SliverToBoxAdapter(
child: _DescriptionSection(description: description),
),
// 6. Params — full table
SliverToBoxAdapter(
child: _ParamsCard(
publicId: widget.publicId,
spec: spec,
brand: brand,
series: series,
unit: unit,
productionDate: productionDate,
),
),
// 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
const SliverToBoxAdapter(child: _Footer()),
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),
],
),
);
}
}
// ── 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 description;
const _DescriptionSection({required this.description});
static const _mockBody =
'飞天牌茅台酒采用本地优质红高粱与小麦为原料,承袭历经数百年沉淀的酱香酿造工艺,'
'经一年一个生产周期、两次投料、九次蒸煮、八次发酵、七次取酒,'
'于陶坛中陈藏至少五年,方得装瓶。色泽微黄透明,开瓶酱香盈室,'
'入口绵柔不烈,咽下后舌底生津,余香萦绕,是中国酱香型白酒之典范。';
static const _mockKeywords = ['酱香典范', '陈藏五年', '回味悠长', '国家地理标志保护产品'];
@override
Widget build(BuildContext context) {
final body = description.isNotEmpty ? description : _mockBody;
final keywords = description.isNotEmpty ? _extractKeywords(description) : _mockKeywords;
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: [
const _SectionHeader(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(),
),
],
),
);
}
List<String> _extractKeywords(String text) {
final matches = RegExp(r'【([^】]+)】').allMatches(text);
return matches.map((m) => m.group(1)!).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;
const _ParamsCard({
required this.publicId,
required this.spec,
required this.brand,
required this.series,
required this.unit,
this.productionDate,
});
@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),
(label: '产地', value: '贵州省仁怀市茅台镇', mono: false),
(label: '生产日期', value: productionDate ?? '2024-01-01', mono: false),
(label: '保质期', value: '无限期(适饮)', mono: false),
(label: '储存方式', value: '阴凉干燥、避光保存', 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? ?? '';
// 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: () {},
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)),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity, height: 40,
child: OutlinedButton.icon(
onPressed: () {},
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)),
),
),
],
),
);
}
}
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 {
const _Footer();
@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: '商品报错'),
const _FooterDivider(),
_FooterTextLink(text: '意见反馈'),
const _FooterDivider(),
_FooterTextLink(text: '关于岩美', url: 'https://www.yanmei.com'),
],
),
],
),
);
}
}
class _FooterTextLink extends StatelessWidget {
final String text;
final String? url;
const _FooterTextLink({required this.text, this.url});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: url != null
? () => launchUrl(Uri.parse(url!), mode: LaunchMode.externalApplication)
: null,
child: Text(text,
style: TextStyle(
fontSize: 11,
color: url != null ? const Color(0xFF2563AC) : _kTextMid,
letterSpacing: 0.02,
decoration: url != null ? TextDecoration.underline : null,
decorationColor: url != null ? 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)),
);
}
}