24afb3b5fd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
743 lines
26 KiB
Dart
743 lines
26 KiB
Dart
// screens/about/about_screen.dart — 关于我们(照原型 about.html 重建)。
|
||
// 居中 col(max760) 四卡:Hero(渐变+链接+反馈) / 产品信息(2×2 cell) /
|
||
// 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release
|
||
// 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除,
|
||
// 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。
|
||
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';
|
||
|
||
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;
|
||
return Container(
|
||
color: t.bg,
|
||
child: SingleChildScrollView(
|
||
padding: context.isMobile
|
||
? const EdgeInsets.all(AppDims.sp4)
|
||
: 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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 原型 .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<void> _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<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-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)),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 反馈表单弹窗:文字 + 附图,直接提交后台(沿用原实现)。
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
}
|