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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1640 lines
57 KiB
Dart
1640 lines
57 KiB
Dart
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('提交'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|