Files
jiu/client/lib/screens/about/about_screen.dart
T

1156 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends ConsumerState<AboutScreen> {
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)),
),
],
],
),
),
),
],
),
);
}
/// 原型 .tltpill 标签(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<void>(
context,
title: '意见反馈',
builder: (_) => _FeedbackSheetBody(initialType: initialType),
);
}
/// 原型 .cardsurface / 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)),
]),
);
// ── 卡1Hero(渐变 + 链接 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-top62×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-links2×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<void> _open(String url) async {
if (url.isEmpty) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) launchUrl(uri);
}
/// 原型 .link-rowicon + 文字 + ›。
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.monofs-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-cellbg 底 / 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<void> _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-tagfeat=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<void> _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-pillh32 / 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<String> _images = []; // 已上传的图片 URL
bool _uploading = false;
bool _submitting = false;
static const _maxImages = 9;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _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<void> _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),
),
);
}
}