Files
jiu/client/lib/screens/settings/license_panel.dart
T
wangjia 02cffcb9ba feat(client): 切页滑动过渡 + 二级屏返回手势方向修正 + 授权三格横排
- 窄屏切页加方向感知滑入过渡(240ms easeOutCubic + 渐显):tab 间按左右
  次序、进二级屏从右滑入、返回从左滑入;桌面无动画。控制器在 initState
  创建(惰性 late 到 dispose 才求值会在已卸载树上找 Ticker 崩溃)
- 二级屏返回手势方向修正:从左往右滑=退出(iOS 返回习惯),向左滑无响应
- 授权信息三格(状态/到期日/剩余天数)窄屏改横排:间距收窄 + FittedBox
  缩放值文本防溢出,桌面不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:39:31 +08:00

218 lines
8.2 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/license_panel.dart — 授权管理面板(自 settings_screen.dart 抽取为公共组件)。
// 四卡:授权信息 / 在线购买续费(PurchaseCard, admin only) / 兑换券 / 到期降级规则。
// 桌面:SettingsScreen 授权 tab 内嵌;移动:独立屏 LicenseScreen/me/license)复用。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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_fonts.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/write_guard.dart';
import 'purchase_card.dart';
import 'settings_card.dart';
class LicensePanel extends ConsumerStatefulWidget {
const LicensePanel({super.key});
@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: Row(children: [
for (var i = 0; i < licCells.length; i++) ...[
if (i > 0) SizedBox(width: context.isMobile ? 8 : 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(
// 窄屏三格横排时收窄内距,值文本按格宽缩放防溢出(如 2026-06-25
padding: context.isMobile
? const EdgeInsets.fromLTRB(10, 12, 10, 12)
: 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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(value,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.heading)),
),
],
),
);
}