import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.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 createState() => _PublicProductScreenState(); } class _PublicProductScreenState extends State { Map? _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 _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)['data'] as Map; setState(() { _data = data; _loading = false; }); } catch (e) { setState(() { _error = e.toString(); _loading = false; }); } } void _showFeedbackDialog({ required String title, required String hint, required String errorType, }) { showDialog( context: context, builder: (ctx) => _FeedbackDialog( title: title, hint: hint, onSubmit: (msg) async { final shopNo = (_data?['shop'] as Map?)?['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? ?? []).cast>(); 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?) ?.map((e) => e as String) .toList() ?? const []; 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?; final batch = d['batch'] as Map?; 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 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 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? 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 = []; 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 = []; 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 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 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 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( 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 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 _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('提交'), ), ], ); } }