Flutter for OpenHarmony 盲盒抽奖App应用实战:注册与修改密码功能开发
我最近在做一个盲盒抽奖 App,用户体系是绑定手机号的方式。整个认证流程包括登录、注册、忘记密码三个部分。登录功能我们放到下一篇文章讲,这篇先把注册和忘记密码搞定。这套配色是金黄色系的,primary是亮金色,是橙金色,两个颜色搭配做渐变效果特别好看。比较适合盲盒这种带点惊喜感的产品。// 0: 验证手机号, 1: 重置密码Timer?_timer;是关键,0 表示第一步验证手机号,1 表示第二步


在移动应用开发中,用户认证系统是最基础也是最重要的模块之一。今天我们来聊聊如何用 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,
),
),
),
),
按钮有两种状态:
- 可点击状态:渐变色背景,白色文字,显示"获取验证码"
- 倒计时状态:灰色背景,灰色文字,显示剩余秒数
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
更多推荐


所有评论(0)