// screens/about/about_screen.dart — 关于我们(照原型 about.html 重建)。 // 居中 col(max760) 四卡:Hero(渐变+链接+反馈) / 产品信息(2×2 cell) / // 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release // 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除, // 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。 // 窄屏(原型 m-about.html):品牌 hero(图标+名+版本+检查更新+反馈) + hub 链接组 + // 产品信息卡(drow) + 更新日志时间线(pill 标签) + 反馈 sheet + 页脚备案; // 授权信息窄屏不显示(已迁 /me/license)。 import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_info.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/update/app_updater.dart'; import '../../core/utils/dialog_util.dart'; import '../../providers/changelog_provider.dart'; import '../../providers/feedback_provider.dart'; import '../../providers/update_provider.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/brand_mark.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; import '../../widgets/ds/m_hub.dart'; import '../../widgets/ds/m_sheet.dart'; class AboutScreen extends ConsumerStatefulWidget { const AboutScreen({super.key}); @override ConsumerState createState() => _AboutScreenState(); } class _AboutScreenState extends ConsumerState { bool _logExpanded = false; static const _logShown = 2; // 原型 SHOWN=2,其余折叠 @override Widget build(BuildContext context) { final t = context.tokens; // ── 窄屏(原型 m-about.html):hero + hub 链接组 + 产品信息 + 时间线 ── if (context.isMobile) return _mobileBody(t); return Container( color: t.bg, child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _heroCard(t), _productCard(t), _changelogCard(t), // 页脚 Padding( padding: const EdgeInsets.only(top: 4, bottom: 12), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('© 2026 岩美科技 · 保留所有权利 · ', style: TextStyle( fontSize: AppDims.fsXs, color: t.faint)), InkWell( onTap: () => launchUrl(Uri.parse('https://beian.miit.gov.cn')), child: Text('京ICP备2026039814号', style: TextStyle( fontSize: AppDims.fsXs, color: t.faint)), ), ], ), ), ], ), ), ), ), ); } // ── 窄屏整体(原型 m-about.html)──────────────────────────── Widget _mobileBody(dynamic t) { return Container( color: t.bg, child: ListView( padding: const EdgeInsets.all(14), children: [ _mHero(t), // hub 链接组(原型 .m-hub margin-top:10) Padding( padding: const EdgeInsets.only(top: 10), child: MHubGroup(items: [ MHubItem( icon: LucideIcons.globe, label: '官方网站', onTap: () => _open(AppInfo.website)), MHubItem( icon: LucideIcons.messageCircle, label: '微信客服', onTap: () => showDsToast(context, '微信客服:${AppInfo.wechat.isNotEmpty ? AppInfo.wechat : '请见官网'}')), MHubItem( // 原型 m-about 用 i-shield-2 icon: LucideIcons.shield, label: '隐私政策', onTap: () => _open(AppInfo.privacyUrl.isNotEmpty ? AppInfo.privacyUrl : '${AppInfo.website}/privacy/')), MHubItem( icon: LucideIcons.fileText, label: '服务条款', onTap: () => _open(AppInfo.termsUrl.isNotEmpty ? AppInfo.termsUrl : '${AppInfo.website}/terms/')), ]), ), _mSection(t, '产品信息'), _mProductCard(t), _mSection(t, '更新日志'), _mChangelogCard(t), // 页脚 © + 备案号 Padding( padding: const EdgeInsets.fromLTRB(0, 18, 0, 6), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('© 2026 岩美科技 · 保留所有权利 · ', style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), InkWell( onTap: () => launchUrl(Uri.parse('https://beian.miit.gov.cn')), child: Text('京ICP备2026039814号', style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), ), ], ), ), ], ), ); } /// .m-section Widget _mSection(dynamic t, String label) => Padding( padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), child: Text(label, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w700, letterSpacing: .4, color: t.muted)), ); /// 原型 .ab-hero:居中品牌图标 + 名称 + 版本 + 检查更新 + 反馈双按钮。 Widget _mHero(dynamic t) { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? ''; return Container( padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column(children: [ const BrandMark(size: 72), const SizedBox(height: 14), Text('岩美酒库', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w800, color: t.heading)), const SizedBox(height: 4), // 版本号 mono、中文说明走默认字体(mono 无 CJK 字形) Row(mainAxisSize: MainAxisSize.min, children: [ if (appVersion.isNotEmpty) Text('$appVersion · ', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text('酒水进销存管理系统', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ]), const SizedBox(height: 12), // 检查更新与下方反馈按钮同规格(38h 通栏,左右边缘对齐) Row(children: [ Expanded( child: DsButton('检查更新', icon: LucideIcons.refreshCw, onPressed: _checkUpdate), ), ]), const SizedBox(height: 10), Row(children: [ Expanded( child: DsButton('反馈 Bug', icon: LucideIcons.bug, variant: DsBtnVariant.primary, onPressed: () => _openFeedbackSheet('Bug')), ), const SizedBox(width: 10), Expanded( child: DsButton('功能建议', icon: LucideIcons.lightbulb, onPressed: () => _openFeedbackSheet('功能建议')), ), ]), ]), ); } /// 产品信息卡(原型 .pe-card + drow:出品方 / 多端覆盖 / 产品定位)。 Widget _mProductCard(dynamic t) { Widget drow(String label, String value, {bool last = false}) => Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( border: last ? null : Border(bottom: BorderSide(color: t.borderSubtle)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), const SizedBox(width: 12), Expanded( child: Text(value, textAlign: TextAlign.right, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.text)), ), ], ), ); return Container( padding: const EdgeInsets.fromLTRB(14, 2, 14, 2), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column(children: [ drow('出品方', '岩美科技 · YANMEI TECH'), drow('多端覆盖', 'Web · macOS · Windows · iOS · 安卓'), drow('产品定位', '酒水进销存 / 财务 / 防伪溯源', last: true), ]), ); } /// 更新日志卡(原型 .tl 时间线:竖线 + 圆点 + 版本/日期 + pill 分组标签)。 Widget _mChangelogCard(dynamic t) { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? ''; final entries = ref.watch(changelogProvider).valueOrNull ?? const []; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: entries.isEmpty ? Text(appVersion.isEmpty ? '暂无更新记录' : '当前版本 $appVersion', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)) : Column(children: [ for (var i = 0; i < entries.length; i++) _mTlItem(t, entries[i], last: i == entries.length - 1), ]), ); } /// 原型 .tl-item:左 2px 竖线 + 8px primary 圆点 + 版本/日期 + 分组。 Widget _mTlItem(dynamic t, ChangelogEntry e, {required bool last}) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 16, child: Column(children: [ Container( width: 8, height: 8, margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration(shape: BoxShape.circle, color: t.primary), ), if (!last) Expanded(child: Container(width: 2, color: t.borderSubtle)), ]), ), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text('v${e.version}', style: TextStyle( fontWeight: FontWeight.w700, fontSize: AppDims.fsBody, color: t.heading)), const SizedBox(width: 8), Text(e.date, style: TextStyle( fontSize: AppDims.fsXs, color: t.faint, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ]), for (final s in e.sections) ...[ const SizedBox(height: 8), _mTlTag(t, s.type), for (final item in s.items) Padding( padding: const EdgeInsets.only(top: 5, left: 2), child: Text('· $item', style: TextStyle( fontSize: AppDims.fsSm, height: 1.5, color: t.text)), ), ], ], ), ), ), ], ), ); } /// 原型 .tlt:pill 标签(feat=success-bg / impr=info-soft / fix=warn-bg)。 Widget _mTlTag(dynamic t, String type) { final (label, bg, fg) = switch (type) { 'feat' || '新功能' => ('新功能', t.successBg, t.success), 'impr' || 'improve' || '改进' => ('改进', t.infoSoft, t.primary), 'fix' || '修复' => ('修复', t.warnBg, t.warn), _ => (type, t.borderSubtle, t.muted), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(AppDims.rPill), ), child: Text(label, style: TextStyle( fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: fg)), ); } /// 意见反馈 sheet(原型 openFb:类型 pill Bug/功能建议/其他 + 描述 + 联系方式)。 void _openFeedbackSheet(String initialType) { showMSheet( context, title: '意见反馈', builder: (_) => _FeedbackSheetBody(initialType: initialType), ); } /// 原型 .card:surface / border / r-lg / pad 20 22 / mb18。 Widget _card(dynamic t, Widget child, {Gradient? gradient}) => Container( margin: const EdgeInsets.only(bottom: 18), padding: const EdgeInsets.fromLTRB(22, 20, 22, 20), decoration: BoxDecoration( color: gradient == null ? t.surface : null, gradient: gradient, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: child, ); /// 卡标题(.card-h 17/700 + primary 图标)。 Widget _cardH(dynamic t, IconData icon, String title) => Padding( padding: const EdgeInsets.only(bottom: 14), child: Row(children: [ Icon(icon, size: 18, color: t.primary), const SizedBox(width: 8), Text(title, style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, color: t.heading)), ]), ); // ── 卡1:Hero(渐变 + 链接 2×2 + 意见反馈)────────────────── Widget _heroCard(dynamic t) { final mobile = context.isMobile; return _card( t, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [t.brand50, t.surface], ), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // .hero-top:62×62 logo(真相源 BrandMark,镜像原型 hero SVG)+ 名称/by Row(children: [ const BrandMark(size: 62), const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('岩美酒库', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w800, color: t.heading)), const SizedBox(height: 4), Text('酒水进销存 · 财务对账 · 防伪溯源一体化 · 岩美科技出品', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ], ), ), ]), // .hero-links:2×2 链接 Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 14), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle))), child: mobile ? Column(children: [ for (final l in _links()) Padding( padding: const EdgeInsets.only(bottom: 10), child: _linkRow(t, l), ), ]) : Column(children: [ for (var row = 0; row < 2; row++) Padding( padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0), child: Row(children: [ for (var col = 0; col < 2; col++) ...[ if (col > 0) const SizedBox(width: 10), Expanded( child: _linkRow(t, _links()[row * 2 + col])), ], ]), ), ]), ), // .hero-acts:意见反馈 Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 14), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle))), child: Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('意见反馈', style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(height: 2), Text('遇到 Bug 或有功能建议,欢迎告诉我们', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ], ), ), DsFeedbackButtons(onOpen: _openFeedback), ]), ), ], ), ); } List<({IconData icon, String label, VoidCallback onTap})> _links() => [ ( icon: LucideIcons.globe, label: '官网', onTap: () => _open(AppInfo.website), ), ( icon: LucideIcons.messageCircle, label: '微信客服', onTap: () { showDsToast(context, '微信客服:${AppInfo.wechat.isNotEmpty ? AppInfo.wechat : '请见官网'}'); }, ), ( // 原型 i-ic03 = lock icon: LucideIcons.lock, label: '隐私政策', onTap: () => _open(AppInfo.privacyUrl.isNotEmpty ? AppInfo.privacyUrl : '${AppInfo.website}/privacy/'), ), ( icon: LucideIcons.fileText, label: '服务条款', onTap: () => _open(AppInfo.termsUrl.isNotEmpty ? AppInfo.termsUrl : '${AppInfo.website}/terms/'), ), ]; Future _open(String url) async { if (url.isEmpty) return; final uri = Uri.parse(url); if (await canLaunchUrl(uri)) launchUrl(uri); } /// 原型 .link-row:icon + 文字 + ›。 Widget _linkRow( dynamic t, ({IconData icon, String label, VoidCallback onTap}) l) { return InkWell( onTap: l.onTap, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.borderSubtle), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Row(children: [ Icon(l.icon, size: 15, color: t.primary), const SizedBox(width: 9), Expanded( child: Text(l.label, style: TextStyle(fontSize: AppDims.fsBody, color: t.text)), ), Text('›', style: TextStyle(fontSize: AppDims.fsTitle, color: t.faint)), ]), ), ); } void _openFeedback({required bool isBug}) { showAppDialog( context: context, builder: (_) => _FeedbackDialog(type: isBug ? 'bug' : 'suggestion'), ); } // ── 卡2:产品信息(2×2 cell)─────────────────────────────── Widget _productCard(dynamic t) { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; final mobile = context.isMobile; final cells = [ _infoCell( t, '当前版本', Row(children: [ // 原型 .iv.mono:fs-title 15 / w700 / mono Text(appVersion, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.heading)), const Spacer(), DsSmallGhostButton( label: '检查更新', icon: LucideIcons.refreshCw, onTap: _checkUpdate), ]), ), _infoCell(t, '出品方', _iv(t, '岩美科技 · YANMEI TECH')), _infoCell(t, '多端覆盖', _iv(t, 'Web · macOS · Windows · iOS · 安卓')), _infoCell(t, '产品定位', _iv(t, '酒水进销存 / 财务 / 防伪溯源')), ]; return _card( t, Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _cardH(t, LucideIcons.box, '产品信息'), if (mobile) Column(children: [ for (final c in cells) Padding(padding: const EdgeInsets.only(bottom: 10), child: c), ]) else Column(children: [ for (var row = 0; row < 2; row++) Padding( padding: EdgeInsets.only(bottom: row == 0 ? 14 : 0), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var col = 0; col < 2; col++) ...[ if (col > 0) const SizedBox(width: 14), Expanded(child: cells[row * 2 + col]), ], ], ), ), ), ]), ]), ); } Widget _iv(dynamic t, String v) => Text(v, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.heading)); /// 原型 .info-cell:bg 底 / border-subtle / r-md / pad 14 16。 Widget _infoCell(dynamic t, String label, Widget value) => Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: t.bg, border: Border.all(color: t.borderSubtle), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), const SizedBox(height: 6), value, ], ), ); Future _checkUpdate() async { showDsToast(context, '正在检查更新…'); await ref.read(updateProvider.notifier).forceCheck(); if (!mounted) return; final r = ref.read(updateProvider).valueOrNull; if (r == null) { showDsToast(context, '检查更新失败,请检查网络'); } else if (r.hasUpdate) { showDsToast(context, '发现新版本 v${r.latestVersion}'); startInAppUpdate(context, r); } else { showDsToast(context, '已是最新版 v${r.latestVersion} ✓'); } } // ── 卡3:更新日志(timeline,默认 2 条 + 展开)─────────────── // (原授权信息卡已迁至 设置 → 授权管理) Widget _changelogCard(dynamic t) { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? ''; final entries = ref.watch(changelogProvider).valueOrNull ?? const []; final shown = _logExpanded ? entries : entries.take(_logShown).toList(); return _card( t, Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _cardH(t, LucideIcons.history, '更新日志'), if (entries.isEmpty) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text(appVersion.isEmpty ? '暂无更新记录' : '当前版本 $appVersion', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ) else ...[ // .timeline:左竖线 + 圆点 for (var i = 0; i < shown.length; i++) _tlItem(t, shown[i], current: i == 0, last: i == shown.length - 1), if (entries.length > _logShown) Padding( padding: const EdgeInsets.only(top: 10), child: Align( alignment: Alignment.centerLeft, child: DsSmallGhostButton( label: _logExpanded ? '收起' : '更多历史更新', onTap: () => setState(() => _logExpanded = !_logExpanded), ), ), ), ], ]), ); } Widget _tlItem(dynamic t, ChangelogEntry e, {required bool current, required bool last}) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 竖线 + 圆点 SizedBox( width: 24, child: Column(children: [ Container( width: 9, height: 9, margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration( shape: BoxShape.circle, color: current ? t.primary : t.surface, border: Border.all(color: t.primary, width: 1.5), ), ), if (!last) Expanded( child: Container(width: 1.5, color: t.borderSubtle), ), ]), ), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text('v${e.version}', style: TextStyle( fontWeight: FontWeight.w700, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: AppDims.fsBody, color: t.heading)), const SizedBox(width: 8), Text(e.date, style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), ]), for (final s in e.sections) ...[ const SizedBox(height: 8), _tlTag(t, s.type), for (final item in s.items) Padding( padding: const EdgeInsets.only(top: 4, left: 2), child: Text('· $item', style: TextStyle( fontSize: AppDims.fsSm, color: t.text)), ), ], ], ), ), ), ], ), ); } /// 原型 .tl-tag:feat=success / impr=info(primary) / fix=warn。 Widget _tlTag(dynamic t, String type) { final (label, color) = switch (type) { 'feat' || '新功能' => ('新功能', t.success), 'impr' || 'improve' || '改进' => ('改进', t.primary), 'fix' || '修复' => ('修复', t.warn), _ => (type, t.muted), }; return Text(label, style: TextStyle( fontSize: AppDims.fsXs, fontWeight: FontWeight.w700, color: color)); } } /// hero-acts 的两个反馈按钮(原型 .btn.primary 反馈 Bug + .btn.ghost 功能建议)。 class DsFeedbackButtons extends StatelessWidget { final void Function({required bool isBug}) onOpen; const DsFeedbackButtons({super.key, required this.onOpen}); @override Widget build(BuildContext context) { final t = context.tokens; Widget btn(String label, IconData icon, bool primary) => InkWell( onTap: () => onOpen(isBug: primary), borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( height: 38, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: primary ? t.primary : t.surface, border: Border.all(color: primary ? t.primary : t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: primary ? t.onPrimary : t.text), const SizedBox(width: 7), Text(label, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: primary ? t.onPrimary : t.text)), ]), ), ); return Row(mainAxisSize: MainAxisSize.min, children: [ btn('反馈 Bug', LucideIcons.bug, true), const SizedBox(width: 10), btn('功能建议', LucideIcons.lightbulb, false), ]); } } /// 小号 ghost 按钮(.btn.ghost.sm,可带前导图标)。 class DsSmallGhostButton extends StatelessWidget { final String label; final IconData? icon; final VoidCallback onTap; const DsSmallGhostButton( {super.key, required this.label, this.icon, required this.onTap}); @override Widget build(BuildContext context) { final t = context.tokens; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), alignment: Alignment.center, child: Row(mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: t.text), const SizedBox(width: 6), ], Text(label, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.text)), ]), ), ); } } /// 窄屏意见反馈 sheet 内容(原型 m-about drawFb:类型 pill + 描述 + 联系方式)。 /// 提交沿用 feedbackRepository.submit(联系方式并入 content,接口无独立字段)。 class _FeedbackSheetBody extends ConsumerStatefulWidget { final String initialType; // Bug / 功能建议 / 其他 const _FeedbackSheetBody({required this.initialType}); @override ConsumerState<_FeedbackSheetBody> createState() => _FeedbackSheetBodyState(); } class _FeedbackSheetBodyState extends ConsumerState<_FeedbackSheetBody> { static const _types = ['Bug', '功能建议', '其他']; late String _type = _types.contains(widget.initialType) ? widget.initialType : _types.first; final _descCtrl = TextEditingController(); final _contactCtrl = TextEditingController(); bool _submitting = false; @override void dispose() { _descCtrl.dispose(); _contactCtrl.dispose(); super.dispose(); } Future _submit() async { if (_descCtrl.text.trim().isEmpty) { showDsToast(context, '请先填写反馈内容'); return; } setState(() => _submitting = true); final apiType = switch (_type) { 'Bug' => 'bug', '功能建议' => 'suggestion', _ => 'other', }; final contact = _contactCtrl.text.trim(); final content = contact.isEmpty ? _descCtrl.text.trim() : '${_descCtrl.text.trim()}\n联系方式:$contact'; try { await ref.read(feedbackRepositoryProvider).submit( type: apiType, content: content, images: const [], ); if (!mounted) return; Navigator.of(context).pop(); showDsToast(context, '感谢反馈,我们会尽快跟进 ✓'); } catch (e) { if (!mounted) return; setState(() => _submitting = false); showDsToast(context, '提交失败:$e'); } } @override Widget build(BuildContext context) { final t = context.tokens; Widget label(String s) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Text(s, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ label('反馈类型'), Wrap(spacing: 8, children: [ for (final v in _types) // 原型 .m-pill:h32 / r-pill / on=brand50+primary InkWell( onTap: () => setState(() => _type = v), borderRadius: BorderRadius.circular(AppDims.rPill), child: Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 13), alignment: Alignment.center, decoration: BoxDecoration( color: v == _type ? t.brand50 : t.surface, border: Border.all(color: v == _type ? t.primary : t.border), borderRadius: BorderRadius.circular(AppDims.rPill), ), child: Text(v, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: v == _type ? FontWeight.w600 : FontWeight.w400, color: v == _type ? t.primary : t.text)), ), ), ]), const SizedBox(height: 14), label('问题描述'), TextField( controller: _descCtrl, maxLines: 4, decoration: const InputDecoration( hintText: '请描述遇到的问题或建议…', border: OutlineInputBorder(), ), ), const SizedBox(height: 14), label('联系方式(选填)'), DsInput(controller: _contactCtrl, hintText: '微信 / 手机号,便于我们回访'), const SizedBox(height: 16), Row(children: [ Expanded( child: DsButton('取消', onPressed: _submitting ? null : () => Navigator.of(context).pop()), ), const SizedBox(width: 10), Expanded( flex: 2, child: DsButton(_submitting ? '提交中…' : '提交', variant: DsBtnVariant.primary, onPressed: _submitting ? null : _submit), ), ]), ], ); } } /// 反馈表单弹窗:文字 + 附图,直接提交后台(沿用原实现)。 class _FeedbackDialog extends ConsumerStatefulWidget { final String type; // bug / suggestion const _FeedbackDialog({required this.type}); @override ConsumerState<_FeedbackDialog> createState() => _FeedbackDialogState(); } class _FeedbackDialogState extends ConsumerState<_FeedbackDialog> { final _ctrl = TextEditingController(); final List _images = []; // 已上传的图片 URL bool _uploading = false; bool _submitting = false; static const _maxImages = 9; @override void dispose() { _ctrl.dispose(); super.dispose(); } Future _addImages() async { final result = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: true, withData: true, ); if (result == null) return; setState(() => _uploading = true); try { final repo = ref.read(feedbackRepositoryProvider); for (final f in result.files) { if (_images.length >= _maxImages) break; if (f.bytes == null) continue; final url = await repo.uploadImage(bytes: f.bytes!, filename: f.name); if (!mounted) return; setState(() => _images.add(url)); } } catch (e) { if (mounted) { showDsToast(context, '图片上传失败:$e'); } } finally { if (mounted) setState(() => _uploading = false); } } Future _submit() async { if (_ctrl.text.trim().isEmpty && _images.isEmpty) { showDsToast(context, '请先填写反馈内容'); return; } setState(() => _submitting = true); try { await ref.read(feedbackRepositoryProvider).submit( type: widget.type, content: _ctrl.text.trim(), images: _images, ); if (!mounted) return; Navigator.pop(context); showDsToast(context, '已提交反馈,感谢你的反馈 ✓'); } catch (e) { if (!mounted) return; setState(() => _submitting = false); showDsToast(context, '提交失败:$e'); } } @override Widget build(BuildContext context) { final t = context.tokens; final isBug = widget.type == 'bug'; return AlertDialog( title: Text(isBug ? '反馈 Bug' : '功能建议'), content: SizedBox( width: context.dialogWidth(480), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _ctrl, maxLines: 6, decoration: InputDecoration( hintText: isBug ? '请描述你遇到的问题或建议,我们会尽快跟进…' : '请描述您希望增加的功能…', border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (int i = 0; i < _images.length; i++) _thumb(t, i), if (_images.length < _maxImages) _addButton(t), ], ), if (_uploading) const Padding( padding: EdgeInsets.only(top: 8), child: Row(children: [ SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), SizedBox(width: 8), Text('图片上传中…', style: TextStyle(fontSize: 12)), ]), ), ], ), ), actions: [ DsButton('取消', onPressed: _submitting ? null : () => Navigator.pop(context)), DsButton( _submitting ? '提交中…' : '提交反馈', variant: DsBtnVariant.primary, onPressed: (_submitting || _uploading) ? null : _submit, ), ], ); } Widget _thumb(dynamic t, int i) { return Stack( clipBehavior: Clip.none, children: [ ClipRRect( borderRadius: BorderRadius.circular(AppDims.rMd), child: Image.network( '${AppConfig.baseUrl}${_images[i]}', width: 64, height: 64, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 64, height: 64, color: t.borderSubtle, child: Icon(LucideIcons.imageOff, size: 20, color: t.muted), ), ), ), Positioned( right: -8, top: -8, child: GestureDetector( onTap: () => setState(() => _images.removeAt(i)), child: Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration(color: t.danger, shape: BoxShape.circle), child: Icon(LucideIcons.x, size: 12, color: t.onPrimary), ), ), ), ], ); } Widget _addButton(dynamic t) { return InkWell( onTap: _uploading ? null : _addImages, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( width: 64, height: 64, decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), color: t.bg, ), child: Icon(LucideIcons.imagePlus, size: 20, color: t.muted), ), ); } }