// screens/auth/register_screen.dart — 自助注册新门店,照原型 register.html 1:1 重建。 // 结构:--page 底居中 .auth 两栏卡片(左 .brand 380px:logo+开通门店+tag+三步时间线; // 右 .form:h1/lead + grid2 门店字段 + 分隔线 + grid2 管理员字段 + 协议 + 主按钮 + foot) // + 右上角 A/B/C 主题切换器。窄屏折叠单列、隐藏步骤时间线。 // 与原型的差异(记 design/CONTRACT.md): // · 无「门店编号」「授权兑换券」字段——后端 RegisterInput 暂不支持(编号自动分配、 // 兑换券注册后在 系统设置·授权兑换券 激活,与 step3 文案一致); // · 门店地址为必填(后端 binding:required;原型为选填);密码下限 6 位(后端 min=6)。 // 校验为原型式:提交时逐项 toast。对应后端 POST /api/v1/register。 import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_chrome.g.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/app_fonts.dart'; import '../../core/theme/context_tokens.dart'; import '../../repositories/auth_repository.dart'; import '../../widgets/brand_mark.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; import 'auth_shared.dart'; class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); @override ConsumerState createState() => _RegisterScreenState(); } class _RegisterScreenState extends ConsumerState { final _shopName = TextEditingController(); final _phone = TextEditingController(); final _address = TextEditingController(); final _manager = TextEditingController(); final _username = TextEditingController(); final _password = TextEditingController(); final _confirm = TextEditingController(); bool _agree = true; bool _obscure = true; bool _submitting = false; @override void dispose() { for (final c in [ _shopName, _phone, _address, _manager, _username, _password, _confirm ]) { c.dispose(); } super.dispose(); } Future _submit() async { // 原型 submitReg 式校验:逐项 toast(无内联错误文案) if (_shopName.text.trim().isEmpty) { showDsToast(context, '请填写门店名称'); return; } if (_phone.text.trim().isEmpty) { showDsToast(context, '请填写联系电话'); return; } if (_address.text.trim().isEmpty) { showDsToast(context, '请填写门店地址'); return; } if (_manager.text.trim().isEmpty || _username.text.trim().isEmpty) { showDsToast(context, '请填写管理员姓名与登录账号'); return; } if (_password.text.length < 6) { showDsToast(context, '密码至少 6 位'); return; } if (_password.text != _confirm.text) { showDsToast(context, '两次密码不一致'); return; } if (!_agree) { showDsToast(context, '请先同意服务条款与隐私政策'); return; } setState(() => _submitting = true); try { final r = await AuthRepository.register( shopName: _shopName.text.trim(), phone: _phone.text.trim(), address: _address.text.trim(), managerName: _manager.text.trim(), username: _username.text.trim(), password: _password.text, ); if (!mounted) return; await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('注册成功'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('门店「${r.shopName}」已创建。'), const SizedBox(height: 12), Text('门店编号(登录时需用):', style: TextStyle( color: context.tokens.muted, fontSize: AppDims.fsBody)), const SizedBox(height: 4), SelectableText(r.shopCode, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: 18, fontWeight: FontWeight.w700)), ], ), actions: [ DsButton('去登录', variant: DsBtnVariant.primary, onPressed: () { Navigator.of(ctx).pop(); context.go('/login'); }), ], ), ); } catch (e) { if (mounted) { showDsToast(context, e.toString().replaceFirst('AuthException: ', ''), bg: context.tokens.danger); } } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { return AuthPageScaffold( maxWidth: 940, narrowMaxWidth: 480, brandWidth: 380, // 原型 grid:minmax(0,380px) minmax(0,1fr) brand: _brand(context), form: _form(context), ); } // ── 左栏 .brand:logo + 开通门店 + tag + 三步时间线 ─────────────────────── Widget _brand(BuildContext context) { final t = context.tokens; final narrow = context.isMobile; return AuthBrandPanel( padding: narrow ? const EdgeInsets.symmetric(horizontal: 32, vertical: 34) : const EdgeInsets.symmetric(horizontal: 38, vertical: 46), minHeight: narrow ? null : 560, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const BrandMark(size: 60), const SizedBox(height: 22), Text('开通门店', style: TextStyle( fontSize: AppDims.fsDisplay, fontWeight: FontWeight.w800, letterSpacing: 1, color: t.sideActiveFg)), const SizedBox(height: 8), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 260), child: Text('几分钟创建你的酒水进销存门店,立即开始入库出库', style: TextStyle( fontSize: AppDims.fsBody, color: t.sideFg, height: 1.7)), ), if (!narrow) ...[ const SizedBox(height: 36), const _Step( n: 1, done: true, title: '填写门店信息', sub: '店名、编号与联系人', last: false), const _Step( n: 2, done: false, title: '创建管理员账号', sub: '用于登录与管理成员', last: false), const _Step( n: 3, done: false, title: '激活授权', sub: '输入兑换券,或先试用', last: true), ], ], ), ); } // ── 右栏 .form ─────────────────────────────────────────────────────────── Widget _form(BuildContext context) { final t = context.tokens; final narrow = context.isMobile; Widget input(TextEditingController c, String hint, {bool obscure = false, TextInputType? keyboard}) => DsInput( controller: c, hintText: hint, obscureText: obscure, keyboardType: keyboard); // grid2:宽屏两列 gap14,窄屏单列 Widget row2(Widget a, Widget b) => narrow ? Column(children: [a, const SizedBox(height: 14), b]) : Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: a), const SizedBox(width: 14), Expanded(child: b), ]); return Padding( padding: narrow ? const EdgeInsets.symmetric(horizontal: 30, vertical: 32) : const EdgeInsets.symmetric(horizontal: 42, vertical: 44), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('注册新门店', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(height: 6), Text.rich( TextSpan( text: '已有门店账号?', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted), children: [ TextSpan( text: '直接登录 →', style: TextStyle( color: t.primary, fontWeight: FontWeight.w600), recognizer: TapGestureRecognizer() ..onTap = () => context.go('/login'), ), ]), ), const SizedBox(height: 24), // 门店信息 DsField('门店名称', required: true, input: input(_shopName, '例如 鼎晟酒行')), const SizedBox(height: 14), row2( DsField('联系电话', required: true, input: input(_phone, '11 位手机号', keyboard: TextInputType.phone)), DsField('门店地址', required: true, input: input(_address, '省 / 市 / 区 详细地址')), ), const SizedBox(height: 6), Container(height: 1, color: t.borderSubtle), const SizedBox(height: 16), // 管理员账号 row2( DsField('管理员姓名', required: true, input: input(_manager, '例如 王经理')), DsField('登录账号', required: true, input: input(_username, '字母 / 数字')), ), const SizedBox(height: 14), row2( DsField('登录密码', required: true, input: DsInput( controller: _password, hintText: '至少 6 位', obscureText: _obscure, suffix: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => setState(() => _obscure = !_obscure), child: Icon( _obscure ? LucideIcons.eyeOff : LucideIcons.eye, size: 16, color: t.muted), ), ), )), DsField('确认密码', required: true, input: input(_confirm, '再次输入', obscure: _obscure)), ), // .agree:协议勾选(margin 6 0 20) const SizedBox(height: 16), DsCheck( value: _agree, alignTop: true, onChanged: (v) => setState(() => _agree = v), label: Text.rich( TextSpan( text: '我已阅读并同意 ', style: TextStyle(fontSize: AppDims.fsSm, color: t.text), children: [ TextSpan( text: '服务条款', style: TextStyle(color: t.primary), recognizer: TapGestureRecognizer() ..onTap = () => showDsToast(context, '服务条款请见官网底部链接')), const TextSpan(text: ' 与 '), TextSpan( text: '隐私政策', style: TextStyle(color: t.primary), recognizer: TapGestureRecognizer() ..onTap = () => showDsToast(context, '隐私政策请见官网底部链接')), ]), ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: DsButton(_submitting ? '创建中…' : '创建门店并进入', variant: DsBtnVariant.primary, large: true, onPressed: _submitting ? null : _submit), ), const SizedBox(height: 20), Center( child: Text('创建即代表你将成为该门店的管理员', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), ], ), ); } } /// 原型 .step:29px 序号圆(done=side-active-fg 底反色)+ 标题/副文案 + /// 节点间 2px hero-glass 连接线(padding-bottom 22)。 class _Step extends StatelessWidget { final int n; final bool done; final String title; final String sub; final bool last; const _Step( {required this.n, required this.done, required this.title, required this.sub, required this.last}); @override Widget build(BuildContext context) { final t = context.tokens; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 29, child: Column(children: [ Container( width: 29, height: 29, alignment: Alignment.center, decoration: BoxDecoration( color: done ? t.sideActiveFg : AppChrome.heroGlass, shape: BoxShape.circle, ), child: Text('$n', style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w700, color: done ? t.sideActiveBg : t.sideActiveFg)), ), if (!last) Expanded( child: Center( child: Container(width: 2, color: AppChrome.heroGlass)), ), ]), ), const SizedBox(width: 13), Expanded( child: Padding( padding: EdgeInsets.only(bottom: last ? 0 : 22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.sideActiveFg, height: 1.45)), Text(sub, style: TextStyle( fontSize: AppDims.fsSm, color: t.sideFg, height: 1.45)), ], ), ), ), ], ), ); } }