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

743 lines
26 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)。
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)),
),
],
),
),
],
),
),
),
),
);
}
/// 原型 .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)),
]),
),
);
}
}
/// 反馈表单弹窗:文字 + 附图,直接提交后台(沿用原实现)。
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),
),
);
}
}