请添加图片描述
请添加图片描述

在移动应用开发中,用户认证系统是最基础也是最重要的模块之一。今天我们来聊聊如何用 Flutter 实现一个完整的注册和忘记密码功能。这篇文章会结合实际项目代码,带你一步步完成这两个核心功能的开发。

前言

我最近在做一个盲盒抽奖 App,用户体系是绑定手机号的方式。整个认证流程包括登录、注册、忘记密码三个部分。登录功能我们放到下一篇文章讲,这篇先把注册和忘记密码搞定。

开始之前,先把项目的颜色配置贴出来,后面代码会用到:

class AppColors {
  static const Color primary = Color(0xFFFFD700);
  static const Color primaryDark = Color(0xFFFFA500);
  static const Color black = Color(0xFF333333);
  static const Color grey = Color(0xFF999999);
}

这套配色是金黄色系的,primary 是亮金色,primaryDark 是橙金色,两个颜色搭配做渐变效果特别好看。比较适合盲盒这种带点惊喜感的产品。


注册页面开发

页面结构设计

注册页面需要收集用户的手机号、验证码、密码这些信息。我把页面拆成了几个部分:

  • 顶部标题区 - 放 Logo 和欢迎语
  • 表单输入区 - 手机号、验证码、密码、确认密码
  • 协议勾选区 - 用户协议和隐私政策
  • 按钮区 - 注册按钮和登录入口

先看 State 类里需要定义哪些变量:

class _RegisterPageState extends State<RegisterPage> {
  final _phoneController = TextEditingController();
  final _codeController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  
  bool _obscurePassword = true;
  bool _obscureConfirmPassword = true;
  bool _agreeTerms = false;
  int _countdown = 0;
  Timer? _timer;

四个 TextEditingController 分别管理四个输入框的内容。_obscurePassword_obscureConfirmPassword 控制密码是否显示为圆点。_countdown 是验证码倒计时的秒数,_timer 是定时器对象。

小提示:TextEditingController 用完记得在 dispose 里释放,不然会内存泄漏。Timer 也一样,页面销毁时要 cancel 掉。

dispose 方法这样写:


void dispose() {
  _phoneController.dispose();
  _codeController.dispose();
  _passwordController.dispose();
  _confirmPasswordController.dispose();
  _timer?.cancel();
  super.dispose();
}

页面布局实现

build 方法里用 SingleChildScrollView 包裹整个表单:

body: SafeArea(
  child: SingleChildScrollView(
    padding: const EdgeInsets.symmetric(horizontal: 24),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SizedBox(height: 20),
        _buildHeader(),
        const SizedBox(height: 40),
        _buildPhoneInput(),
        const SizedBox(height: 16),
        _buildCodeInput(),
        // ... 其他组件
      ],
    ),
  ),
),

为什么要用 SingleChildScrollView?因为键盘弹起时,如果内容超出屏幕高度,用户就没法滚动看到被遮住的输入框了。加上这个组件,内容就能滚动了。

SafeArea 是为了避开刘海屏和底部安全区域。padding 设成左右各 24,让内容不会贴着屏幕边缘。

头部标题区

头部放一个带渐变色的图标和欢迎文案:

Widget _buildHeader() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Container(
        width: 60,
        height: 60,
        decoration: BoxDecoration(
          gradient: const LinearGradient(
            colors: [AppColors.primary, AppColors.primaryDark],
          ),
          borderRadius: BorderRadius.circular(16),
        ),
        child: const Icon(Icons.person_add, color: Colors.white, size: 32),
      ),
      const SizedBox(height: 20),
      const Text('注册账号', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
    ],
  );
}

LinearGradient 做渐变背景,从金黄色过渡到橙色。borderRadius 设成 16,让方块的四个角圆润一些。图标用 person_add,表示新用户注册的意思。

手机号输入框

手机号输入框前面加了 +86 的国际区号,中间用竖线分隔:

Widget _buildPhoneInput() {
  return Container(
    height: 56,
    decoration: BoxDecoration(
      color: const Color(0xFFF5F5F5),
      borderRadius: BorderRadius.circular(12),
    ),
    child: Row(
      children: [
        const SizedBox(width: 16),
        const Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
        const SizedBox(width: 8),
        Container(width: 1, height: 20, color: const Color(0xFFDDDDDD)),
        const SizedBox(width: 12),
        Expanded(
          child: TextField(
            controller: _phoneController,
            keyboardType: TextInputType.phone,
            decoration: const InputDecoration(
              hintText: '请输入手机号',
              border: InputBorder.none,
            ),
          ),
        ),
      ],
    ),
  );
}

这里有几个细节:

  • keyboardType: TextInputType.phone 让键盘弹出数字键盘,方便用户输入
  • border: InputBorder.none 去掉 TextField 默认的下划线
  • 外层 Container 用浅灰色背景 0xFFF5F5F5,圆角 12,整体风格比较柔和

验证码输入框

验证码输入框右边带一个获取验证码的按钮:

GestureDetector(
  onTap: _countdown == 0 ? _startCountdown : null,
  child: Container(
    padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
    decoration: BoxDecoration(
      gradient: _countdown == 0
          ? const LinearGradient(colors: [AppColors.primary, AppColors.primaryDark])
          : null,
      color: _countdown == 0 ? null : const Color(0xFFDDDDDD),
      borderRadius: BorderRadius.circular(20),
    ),
    child: Text(
      _countdown == 0 ? '获取验证码' : '${_countdown}s',
      style: TextStyle(
        color: _countdown == 0 ? Colors.white : AppColors.grey,
        fontWeight: FontWeight.w500,
      ),
    ),
  ),
),

按钮有两种状态:

  1. 可点击状态:渐变色背景,白色文字,显示"获取验证码"
  2. 倒计时状态:灰色背景,灰色文字,显示剩余秒数

onTap 那里用了三元表达式,倒计时期间传 null 就相当于禁用点击。

倒计时的逻辑用 Timer.periodic 实现:

void _startCountdown() {
  if (_phoneController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('请先输入手机号')),
    );
    return;
  }
  setState(() => _countdown = 60);
  _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
    setState(() {
      if (_countdown > 0) {
        _countdown--;
      } else {
        _timer?.cancel();
      }
    });
  });
}

点击之前先检查手机号是否填了,没填就弹个 SnackBar 提示。然后把 _countdown 设成 60,启动定时器每秒减一。减到 0 就取消定时器。

注意:每次 setState 都会触发 UI 重建,所以倒计时数字会实时更新。

密码输入框

密码输入框右边有个眼睛图标,点击可以切换密码的显示隐藏:

Widget _buildPasswordInput() {
  return Container(
    height: 56,
    decoration: BoxDecoration(
      color: const Color(0xFFF5F5F5),
      borderRadius: BorderRadius.circular(12),
    ),
    padding: const EdgeInsets.symmetric(horizontal: 16),
    child: Row(
      children: [
        Expanded(
          child: TextField(
            controller: _passwordController,
            obscureText: _obscurePassword,
            decoration: const InputDecoration(
              hintText: '请设置密码(至少6位)',
              border: InputBorder.none,
            ),
          ),
        ),
        GestureDetector(
          onTap: () => setState(() => _obscurePassword = !_obscurePassword),
          child: Icon(
            _obscurePassword ? Icons.visibility_off : Icons.visibility,
            color: AppColors.grey,
          ),
        ),
      ],
    ),
  );
}

obscureText 属性控制密码是否显示为圆点。点击眼睛图标时,用 setState 切换 _obscurePassword 的值,图标也会跟着变化:

  • visibility_off - 密码隐藏状态,显示划掉的眼睛
  • visibility - 密码显示状态,显示正常的眼睛

确认密码输入框的实现方式一样,就是换个 controller 和状态变量。

用户协议勾选

用户协议这块用了一个自定义的勾选框:

GestureDetector(
  onTap: () => setState(() => _agreeTerms = !_agreeTerms),
  child: Container(
    width: 20,
    height: 20,
    decoration: BoxDecoration(
      gradient: _agreeTerms
          ? const LinearGradient(colors: [AppColors.primary, AppColors.primaryDark])
          : null,
      borderRadius: BorderRadius.circular(4),
      border: Border.all(
        color: _agreeTerms ? AppColors.primary : AppColors.grey,
        width: 1.5,
      ),
    ),
    child: _agreeTerms ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
  ),
),

选中后显示渐变色背景和白色勾,未选中就是透明背景加灰色边框。为什么不用系统的 Checkbox?因为系统的样式不好自定义,自己画一个更灵活。

协议文字用 Text.rich 实现富文本:

Text.rich(
  TextSpan(
    text: '我已阅读并同意',
    style: const TextStyle(fontSize: 12, color: AppColors.grey),
    children: [
      TextSpan(text: '《用户协议》', style: TextStyle(color: AppColors.primaryDark)),
      const TextSpan(text: '和'),
      TextSpan(text: '《隐私政策》', style: TextStyle(color: AppColors.primaryDark)),
    ],
  ),
),

TextSpan 可以嵌套,每个 span 可以设置不同的样式。这样协议链接就能显示成橙色,和普通文字区分开。

注册按钮和表单验证

注册按钮用渐变色背景,加上阴影让按钮更有立体感:

Container(
  width: double.infinity,
  height: 50,
  decoration: BoxDecoration(
    gradient: const LinearGradient(colors: [AppColors.primary, AppColors.primaryDark]),
    borderRadius: BorderRadius.circular(25),
    boxShadow: [
      BoxShadow(
        color: AppColors.primary.withOpacity(0.3),
        blurRadius: 10,
        offset: const Offset(0, 4),
      ),
    ],
  ),
  child: const Center(child: Text('注册', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))),
),

boxShadow 用主题色的 30% 透明度,模糊半径 10,向下偏移 4 像素。这样按钮下面会有一层淡淡的金色阴影,看起来像是浮在页面上。

点击注册按钮时要做表单验证:

void _register() {
  if (!_agreeTerms) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先同意用户协议和隐私政策')));
    return;
  }
  if (_phoneController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入手机号')));
    return;
  }
  if (_codeController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入验证码')));
    return;
  }
  if (_passwordController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入密码')));
    return;
  }
  if (_passwordController.text != _confirmPasswordController.text) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('两次密码输入不一致')));
    return;
  }
  if (_passwordController.text.length < 6) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码长度不能少于6位')));
    return;
  }
  // 验证通过,执行注册逻辑
  ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('注册成功')));
  Navigator.pop(context);
}

验证逻辑按顺序检查:协议勾选 → 手机号 → 验证码 → 密码 → 确认密码 → 密码长度。每一步不通过都弹提示并 return,全部通过后返回登录页。

为什么用 SnackBar 而不是 Dialog? SnackBar 是轻量级的提示,不会打断用户操作流程。Dialog 需要用户手动关闭,体验上没那么顺畅。


忘记密码功能开发

忘记密码功能分两步:第一步验证手机号,第二步设置新密码。我用一个 _currentStep 变量来控制当前显示哪一步。

状态定义

class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
  final _phoneController = TextEditingController();
  final _codeController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  
  int _currentStep = 0; // 0: 验证手机号, 1: 重置密码
  bool _obscurePassword = true;
  bool _obscureConfirmPassword = true;
  int _countdown = 0;
  Timer? _timer;

_currentStep 是关键,0 表示第一步验证手机号,1 表示第二步重置密码。根据这个值来决定显示哪个界面。

步骤指示器

页面顶部有个步骤指示器,让用户知道当前进度:

Widget _buildStepIndicator() {
  return Row(
    children: [
      _buildStepItem(0, '验证手机'),
      Expanded(
        child: Container(
          height: 2,
          margin: const EdgeInsets.symmetric(horizontal: 8),
          color: _currentStep >= 1 ? AppColors.primary : const Color(0xFFDDDDDD),
        ),
      ),
      _buildStepItem(1, '重置密码'),
    ],
  );
}

中间的连接线用 Expanded 包裹,会自动填满两个圆圈之间的空间。线的颜色根据 _currentStep 变化:到了第二步就变成主题色,否则是灰色。

每个步骤圆圈的实现:

Widget _buildStepItem(int step, String label) {
  final isActive = _currentStep >= step;
  return Column(
    children: [
      Container(
        width: 32,
        height: 32,
        decoration: BoxDecoration(
          gradient: isActive
              ? const LinearGradient(colors: [AppColors.primary, AppColors.primaryDark])
              : null,
          color: isActive ? null : const Color(0xFFDDDDDD),
          shape: BoxShape.circle,
        ),
        child: Center(
          child: isActive && _currentStep > step
              ? const Icon(Icons.check, color: Colors.white, size: 18)
              : Text('${step + 1}', style: TextStyle(color: isActive ? Colors.white : AppColors.grey)),
        ),
      ),
      const SizedBox(height: 8),
      Text(label, style: TextStyle(color: isActive ? AppColors.black : AppColors.grey)),
    ],
  );
}

这里的逻辑稍微有点绕:

  • isActive 表示当前步骤是否已激活(当前步骤或已完成的步骤)
  • 激活状态显示渐变色背景,未激活显示灰色
  • 已完成的步骤(_currentStep > step)显示勾,当前步骤显示数字

第一步:验证手机号

第一步的界面和注册页差不多,就是手机号加验证码:

Widget _buildVerifyStep() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text('验证手机号', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
      const SizedBox(height: 8),
      const Text('请输入您注册时使用的手机号', style: TextStyle(color: AppColors.grey)),
      const SizedBox(height: 30),
      _buildPhoneInput(),
      const SizedBox(height: 16),
      _buildCodeInput(),
      const SizedBox(height: 40),
      _buildNextButton(),
    ],
  );
}

点击下一步按钮验证通过后进入第二步:

void _verifyPhone() {
  if (_phoneController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入手机号')));
    return;
  }
  if (_codeController.text.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入验证码')));
    return;
  }
  setState(() => _currentStep = 1);
}

就一行 setState(() => _currentStep = 1),UI 就会自动切换到第二步的界面。Flutter 的声明式 UI 就是这么简洁。

第二步:设置新密码

第二步让用户输入新密码,还加了个密码提示框:

Widget _buildPasswordTips() {
  return Container(
    padding: const EdgeInsets.all(12),
    decoration: BoxDecoration(
      color: AppColors.primary.withOpacity(0.1),
      borderRadius: BorderRadius.circular(8),
    ),
    child: Row(
      children: [
        Icon(Icons.info_outline, size: 18, color: AppColors.primaryDark),
        const SizedBox(width: 8),
        const Expanded(
          child: Text('密码需要至少6位,建议包含字母和数字', style: TextStyle(fontSize: 12, color: AppColors.grey)),
        ),
      ],
    ),
  );
}

用主题色 10% 透明度做背景,配上 info 图标,给用户一个友好的提示。这种设计比直接在输入框下面写小字要醒目。

重置成功弹窗

密码重置成功后弹一个对话框:

void _showSuccessDialog() {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (context) => AlertDialog(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 60,
            height: 60,
            decoration: const BoxDecoration(
              gradient: LinearGradient(colors: [AppColors.primary, AppColors.primaryDark]),
              shape: BoxShape.circle,
            ),
            child: const Icon(Icons.check, color: Colors.white, size: 32),
          ),
          const SizedBox(height: 16),
          const Text('密码重置成功', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          const SizedBox(height: 8),
          const Text('请使用新密码登录', style: TextStyle(color: AppColors.grey)),
        ],
      ),
      actions: [
        SizedBox(
          width: double.infinity,
          child: TextButton(
            onPressed: () {
              Navigator.pop(context); // 关闭弹窗
              Navigator.pop(context); // 返回登录页
            },
            style: TextButton.styleFrom(
              backgroundColor: AppColors.primary,
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
            ),
            child: const Text('返回登录', style: TextStyle(color: Colors.white)),
          ),
        ),
      ],
    ),
  );
}

几个要点:

  • barrierDismissible: false 防止用户点击弹窗外部关闭,必须点按钮
  • mainAxisSize: MainAxisSize.min 让 Column 高度自适应内容
  • 点击按钮时连续 pop 两次,先关闭弹窗再返回登录页

返回按钮处理

AppBar 的返回按钮需要特殊处理:

leading: IconButton(
  icon: const Icon(Icons.arrow_back_ios, color: AppColors.black, size: 20),
  onPressed: () {
    if (_currentStep == 1) {
      setState(() => _currentStep = 0);
    } else {
      Navigator.pop(context);
    }
  },
),

如果在第二步点返回,应该回到第一步而不是直接退出页面。这样用户可以修改手机号重新验证。


小结

这篇文章实现了注册和忘记密码两个功能,代码都是从实际项目里拿出来的,可以直接跑。

主要涉及到这些知识点:

  • TextEditingController - 管理输入框状态
  • Timer.periodic - 实现验证码倒计时
  • LinearGradient - 实现渐变色效果
  • BoxShadow - 实现按钮阴影
  • AlertDialog - 自定义弹窗样式
  • 多步骤表单 - 用状态变量控制显示哪一步

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐