Files
jiu/client/lib/screens/settings/settings_screen.dart
T

995 lines
37 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/settings/settings_screen.dart — 系统设置(照原型 settings.html 重建)。
// 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 5 项 =
// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_info.dart';
import '../../core/config/license_copy.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_tokens.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/theme_controller.dart';
import '../../core/utils/clock.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/number_rule.dart';
import '../../models/shop.dart';
import '../../providers/license_provider.dart';
import '../../providers/number_rule_provider.dart';
import '../../providers/shop_provider.dart';
import '../../providers/user_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/write_guard.dart';
import 'purchase_card.dart';
import 'users_screen.dart' show userRoleBadge, userStatusBadge;
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
class SettingsScreen extends ConsumerStatefulWidget {
/// 初始面板:{shop:0, users:1, number:2, license:3, pref:4}
final int initialTab;
const SettingsScreen({super.key, this.initialTab = 0});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
static const _navs = [
// 图标对齐原型 subnav spritei-ic44=house / i-ic45=user-plus /
// i-ic07=trending-up / i-ic46=contrast(编号规则为 app 专属项,用 hash)
(LucideIcons.house, '门店信息'),
(LucideIcons.userPlus, '用户管理'),
(LucideIcons.hash, '编号规则'),
(LucideIcons.trendingUp, '授权管理'),
(LucideIcons.contrast, '偏好设置'),
];
late int _panel = widget.initialTab.clamp(0, _navs.length - 1);
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
return Container(
color: t.bg,
padding:
mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 头部(原型 .head)──
Padding(
padding: mobile
? const EdgeInsets.fromLTRB(
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2)
: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('系统设置',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('门店配置 · 用户与授权 · 个性化偏好',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
),
if (mobile)
Padding(
padding:
const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DsSeg(
items: [for (final n in _navs) n.$2],
index: _panel,
onChanged: (i) => setState(() => _panel = i),
),
),
),
Expanded(
child: mobile
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppDims.sp4, 0, AppDims.sp4, AppDims.sp4),
child: _panelBody(),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_subnav(t),
const SizedBox(width: 22),
Expanded(
child: SingleChildScrollView(child: _panelBody()),
),
],
),
),
],
),
);
}
/// 原型 .subnav200px surface r-lg pad8;项高 40 gap10on=brand50/primary/600。
Widget _subnav(dynamic t) {
return Container(
width: 200,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _navs.length; i++)
InkWell(
onTap: () => setState(() => _panel = i),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: i == _panel ? t.brand50 : null,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Icon(_navs[i].$1,
size: 16, color: i == _panel ? t.primary : t.muted),
const SizedBox(width: 10),
Text(_navs[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight:
i == _panel ? FontWeight.w600 : FontWeight.w400,
color: i == _panel ? t.primary : t.text)),
]),
),
),
],
),
);
}
Widget _panelBody() => switch (_panel) {
0 => const _StoreInfoPanel(),
1 => _usersPanel(),
2 => _numberPanel(),
3 => const _LicensePanel(),
4 => const _PrefPanel(),
_ => const SizedBox(),
};
// ── 面板 2:用户管理(预览表 + 独立页入口)──────────────────
Widget _usersPanel() {
final t = context.tokens;
final async = ref.watch(userListProvider);
return _SettingsCard(
title: '用户管理',
desc: '管理门店成员与权限角色',
trailing: DsButton('管理全部用户',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => context.push('/settings/users')),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
data: (users) {
final preview = users.take(4).toList();
return DsTable(
shrinkWrap: true,
emptyText: '暂无成员',
columns: const [
DsColumn('user', '用户'),
DsColumn('role', '角色'),
DsColumn('status', '状态'),
DsColumn('actions', '操作', action: true),
],
rows: preview.map((u) {
return DsRow(cells: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
u.realName?.isNotEmpty == true
? u.realName!
: u.username,
style: TextStyle(
fontWeight: FontWeight.w600, color: t.heading)),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(u.username,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
),
],
),
userRoleBadge(u.role),
userStatusBadge(u.isActive),
InkWell(
onTap: () => context.push('/settings/users'),
child: Icon(LucideIcons.pencil, size: 16, color: t.muted),
),
]);
}).toList(),
);
},
),
);
}
// ── 面板 3:编号规则(保留真实功能,DsTable 版式)────────────
Widget _numberPanel() {
final t = context.tokens;
final async = ref.watch(numberRuleListProvider);
return _SettingsCard(
title: '编号规则',
desc: '单据编号的前缀 / 日期格式 / 序号',
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Row(children: [
Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
const SizedBox(width: 10),
DsButton('重试',
small: true,
onPressed: () =>
ref.read(numberRuleListProvider.notifier).reload()),
]),
data: (rules) => DsTable(
shrinkWrap: true,
emptyText: '暂无编号规则',
columns: const [
DsColumn('type', '单据类型'),
DsColumn('prefix', '前缀'),
DsColumn('date', '日期格式'),
DsColumn('example', '下一编号示例'),
DsColumn('no', '当前序号', numeric: true),
DsColumn('actions', '操作', action: true),
],
rows: rules.map((r) {
return DsRow(cells: [
Text(r.typeLabel,
style:
TextStyle(fontWeight: FontWeight.w600, color: t.heading)),
Text(r.prefix,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(r.dateFormat,
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: t.muted)),
Text(r.exampleNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text('${r.currentNo}',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
WriteGuard(
child: InkWell(
onTap: () => _editRule(r),
child: Icon(LucideIcons.pencil, size: 16, color: t.muted),
),
),
]);
}).toList(),
),
),
);
}
void _editRule(NumberRule r) {
final prefixCtrl = TextEditingController(text: r.prefix);
final noCtrl = TextEditingController(text: '${r.currentNo}');
showAppDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('编辑规则 · ${r.typeLabel}'),
content: SizedBox(
width: ctx.dialogWidth(360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('前缀',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
DsInput(controller: prefixCtrl),
const SizedBox(height: 14),
Text('当前序号',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
DsInput(controller: noCtrl, keyboardType: TextInputType.number),
],
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.of(ctx).pop()),
DsButton('保存', variant: DsBtnVariant.primary, onPressed: () async {
Navigator.of(ctx).pop();
try {
await ref.read(numberRuleListProvider.notifier).updateRule(r.id, {
'prefix': prefixCtrl.text.trim(),
'current_no': int.tryParse(noCtrl.text) ?? r.currentNo,
});
if (mounted) {
showDsToast(context, '规则已更新 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
}
}),
],
),
);
}
}
/// 原型右侧 .cardsurface / 1px border / r-lg / padding 22 24 / mb18
/// h2 17 heading + desc 12 muted(可带右上角按钮)。
class _SettingsCard extends StatelessWidget {
final String title;
final String desc;
final Widget child;
final Widget? trailing;
const _SettingsCard(
{required this.title,
required this.desc,
required this.child,
this.trailing});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
margin: const EdgeInsets.only(bottom: 18),
padding: const EdgeInsets.fromLTRB(24, 22, 24, 22),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(height: 3),
Text(desc,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
],
),
),
if (trailing != null) trailing!,
]),
const SizedBox(height: 16),
child,
],
),
);
}
}
// ── 面板 1:门店信息(内联表单)──────────────────────────────
class _StoreInfoPanel extends ConsumerStatefulWidget {
const _StoreInfoPanel();
@override
ConsumerState<_StoreInfoPanel> createState() => _StoreInfoPanelState();
}
class _StoreInfoPanelState extends ConsumerState<_StoreInfoPanel> {
final _nameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _wechatCtrl = TextEditingController();
final _addressCtrl = TextEditingController();
ShopInfo? _loaded;
bool _saving = false;
bool _uploadingLogo = false;
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_wechatCtrl.dispose();
_addressCtrl.dispose();
super.dispose();
}
void _fill(ShopInfo shop) {
_loaded = shop;
_nameCtrl.text = shop.name;
_phoneCtrl.text = shop.phone;
_wechatCtrl.text = shop.wechatId;
_addressCtrl.text = shop.address;
}
Future<void> _save() async {
final shop = _loaded;
if (shop == null) return;
setState(() => _saving = true);
try {
await ref.read(shopRepositoryProvider).updateInfo({
'name': _nameCtrl.text.trim(),
'address': _addressCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'manager_name': shop.managerName,
'wechat_id': _wechatCtrl.text.trim(),
if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl,
'custom_fields': shop.customFields,
});
ref.invalidate(shopInfoProvider);
if (mounted) {
showDsToast(context, '门店信息已保存 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _uploadLogo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
final file = result?.files.firstOrNull;
if (file == null || file.bytes == null || !mounted) return;
setState(() => _uploadingLogo = true);
try {
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
ref.invalidate(shopInfoProvider);
if (mounted) {
showDsToast(context, 'Logo 已更新 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '上传失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _uploadingLogo = false);
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final async = ref.watch(shopInfoProvider);
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final canEdit = (role == 'admin' || role == 'superadmin') &&
!WriteGuard.isReadonly(ref) &&
// 授权过期 >7 天同样禁编辑(后端 LicenseGuard 会 403,这里不误导)
!WriteGuard.licenseBlocked(ref);
return _SettingsCard(
title: '门店信息',
desc: '用于单据抬头、对账与多端展示',
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
data: (shop) {
if (_loaded?.code != shop.code || _loaded?.name != shop.name) {
_fill(shop);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 原型 .form-grid 2 列 gap16
_formRow([
_field(
'门店名称', DsInput(controller: _nameCtrl, enabled: canEdit)),
_field(
'门店编号',
DsInput(
controller: TextEditingController(text: shop.code),
enabled: false),
hint: '系统分配,不可修改',
),
]),
_formRow([
_field('联系电话',
DsInput(controller: _phoneCtrl, enabled: canEdit)),
_field(
'微信',
DsInput(
controller: _wechatCtrl,
enabled: canEdit,
hintText: '微信号 / 客服微信')),
]),
_field(
'门店地址',
DsInput(
controller: _addressCtrl,
enabled: canEdit,
hintText: '省 / 市 / 详细地址')),
const SizedBox(height: 14),
// Logo(现有能力保留)
Row(children: [
Container(
width: 46,
height: 46,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: shop.logoUrl.isNotEmpty
? Image.network(shop.logoUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(LucideIcons.image, color: t.faint))
: Icon(LucideIcons.image, size: 18, color: t.faint),
),
const SizedBox(width: 12),
if (canEdit)
DsButton(_uploadingLogo ? '上传中…' : '更换 Logo',
small: true,
icon: LucideIcons.upload,
onPressed: _uploadingLogo ? null : _uploadLogo),
]),
const SizedBox(height: 18),
if (canEdit)
Row(children: [
DsButton(_saving ? '保存中…' : '保存',
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
const SizedBox(width: 10),
DsButton('撤销', onPressed: () {
_fill(shop);
setState(() {});
showDsToast(context, '已撤销修改');
}),
]),
],
),
);
},
),
);
}
Widget _formRow(List<Widget> fields) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(child: fields[i]),
],
],
),
);
Widget _field(String label, Widget input, {String? hint}) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
input,
if (hint != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
// 原型 i-ic03 = lock(系统分配,不可修改)
Icon(LucideIcons.lock, size: 12, color: t.faint),
const SizedBox(width: 4),
Text(hint,
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
]),
),
],
);
}
}
// ── 面板 4:授权管理 ───────────────────────────────────────
class _LicensePanel extends ConsumerStatefulWidget {
const _LicensePanel();
@override
ConsumerState<_LicensePanel> createState() => _LicensePanelState();
}
class _LicensePanelState extends ConsumerState<_LicensePanel> {
final _voucherCtrl = TextEditingController();
bool _redeeming = false;
@override
void dispose() {
_voucherCtrl.dispose();
super.dispose();
}
Future<void> _redeem() async {
final code = _voucherCtrl.text.trim();
if (code.isEmpty) {
showDsToast(context, '请输入兑换券短码');
return;
}
setState(() => _redeeming = true);
try {
await ref.read(licenseRepositoryProvider).activate(code);
ref.invalidate(licenseProvider);
_voucherCtrl.clear();
if (mounted) {
showDsToast(context, '兑换成功,已续期 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '兑换失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _redeeming = false);
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final lic = ref.watch(licenseProvider).valueOrNull;
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final isAdmin = role == 'admin' || role == 'superadmin';
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
// 状态/剩余天数走可注入时钟(golden 确定化),不用 model 内的 DateTime.now()
final active = lic?.isActive == true &&
(lic?.expiresAt == null || lic!.expiresAt!.isAfter(appNow()));
final days = lic?.expiresAt != null
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null;
final licCells = [
_licCell(t, '授权状态', active ? '已授权' : '未授权',
color: active ? t.success : t.warn),
_licCell(t, '到期日', expires),
_licCell(t, '剩余天数', days != null ? '$days' : ''),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 卡1:授权信息(3 cell,自「关于我们」迁入)──
_SettingsCard(
title: '授权信息',
desc: '当前门店的授权状态与有效期,续期时长在现有到期时间上叠加',
child: context.isMobile
? Column(children: [
for (final c in licCells)
Padding(
padding: const EdgeInsets.only(bottom: 10), child: c),
])
: Row(children: [
for (var i = 0; i < licCells.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(child: licCells[i]),
],
]),
),
const SizedBox(height: 16),
// ── 卡2:在线购买 / 续费(后端 purchase 接口 admin only
// 非管理员引导看官网价格页)──
if (isAdmin)
const _SettingsCard(
title: '在线购买 / 续费',
desc: '选择套餐支付宝支付,到账后授权自动续期',
child: PurchaseCard(),
)
else
_SettingsCard(
title: '在线购买 / 续费',
desc: '在线购买仅门店管理员可操作',
child: Row(children: [
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () =>
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]),
),
const SizedBox(height: 16),
// ── 卡3:兑换券(原型 .redeeminput + 按钮,max 520)──
_SettingsCard(
title: '兑换券',
desc: '输入兑换券短码续期',
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('兑换券短码',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
DsInput(
controller: _voucherCtrl,
hintText: '输入兑换券短码',
),
],
),
),
const SizedBox(width: 10),
WriteGuard(
child: DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
),
],
),
),
),
const SizedBox(height: 16),
// ── 卡4:到期后的降级规则(现有能力保留)──
_SettingsCard(
title: '到期后的降级规则',
desc: '授权到期后系统各功能的可用性说明',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('· $note',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
),
],
);
}
/// 原型 .lic-cell(与「关于我们」同款):lk 11 muted + lv 17/700/mono。
Widget _licCell(dynamic t, String label, String value, {Color? color}) =>
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),
Text(value,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.heading)),
],
),
);
}
// ── 面板 5:偏好设置(主题三卡 + 默认仓库)───────────────────
class _PrefPanel extends ConsumerWidget {
const _PrefPanel();
// (key, 名称, 说明)——原型 tcard 文案
static const _themes = [
('a', 'A 经典蓝', '浅色 · 默认'),
('b', 'B 琥珀', '深色'),
('c', 'C 酒窖', '暖浅色'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final current = ref.watch(themeControllerProvider);
final warehouses = ref.watch(warehouseListProvider).valueOrNull ?? const [];
final defaultWh = warehouses.where((w) => w.isDefault).firstOrNull;
return _SettingsCard(
title: '偏好设置',
desc: '主题与默认值,仅影响当前账号在本端的体验',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('主题选择',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: context.isMobile
? Column(children: [
for (final th in _themes)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _tcard(context, ref, th, current == th.$1),
),
])
: Row(children: [
for (var i = 0; i < _themes.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(
child: _tcard(context, ref, _themes[i],
current == _themes[i].$1)),
],
]),
),
const SizedBox(height: 18),
Text('默认仓库',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Builder(
builder: (bctx) => WriteGuard(
child: InkWell(
onTap: () async {
final v = await showDsMenu<int>(bctx, items: [
for (final w in warehouses)
DsMenuItem(
value: w.id, label: w.name, selected: w.isDefault),
]);
if (v == null) return;
try {
await ref
.read(warehouseListProvider.notifier)
.updateWarehouse(v, {'is_default': true});
if (bctx.mounted) {
showDsToast(bctx, '默认仓库已更新 ✓');
}
} catch (e) {
if (bctx.mounted) {
showDsToast(bctx, '设置失败:$e');
}
}
},
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(defaultWh?.name ?? '选择默认仓库…',
style: TextStyle(
fontSize: AppDims.fsBody,
color:
defaultWh != null ? t.text : t.faint))),
Icon(LucideIcons.chevronDown, size: 14, color: t.faint),
]),
),
),
),
),
),
],
),
);
}
/// 原型 .tcard1.5px 边 r-lg pad14;选中 primary 边 + brand50 ring
/// swatch 高 54 四色等分(primary/primary-dark/bg/accent+ 名称/说明 + 勾。
Widget _tcard(BuildContext context, WidgetRef ref,
(String, String, String) theme, bool on) {
final t = context.tokens;
final preview = appTokensOf(theme.$1);
return InkWell(
onTap: () {
ref.read(themeControllerProvider.notifier).set(theme.$1);
showDsToast(context, '已切换为 ${theme.$2} 主题');
},
borderRadius: BorderRadius.circular(AppDims.rLg),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: on ? t.primary : t.border, width: 1.5),
borderRadius: BorderRadius.circular(AppDims.rLg),
boxShadow: on ? [BoxShadow(color: t.brand50, spreadRadius: 3)] : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// .swatch 高54 四色等分
ClipRRect(
borderRadius: BorderRadius.circular(AppDims.rSm),
child: SizedBox(
height: 54,
child: Row(children: [
Expanded(child: Container(color: preview.primary)),
Expanded(child: Container(color: preview.primaryDark)),
Expanded(child: Container(color: preview.page)),
Expanded(child: Container(color: preview.accent)),
]),
),
),
const SizedBox(height: 8),
Row(children: [
Expanded(
child: Text(theme.$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
),
if (on) Icon(LucideIcons.check, size: 14, color: t.primary),
]),
Text(theme.$3,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
],
),
),
);
}
}