Flutter---登录弹窗

效果图
登录功能的4层架构
复制代码
┌─────────────────────────────────────────────────────────┐
│                    1️⃣ UI 层 (LoginDialog)               │
│  - 显示输入框、按钮                                      │
│  - 收集用户输入的手机号和验证码                          │
│  - 显示加载动画、倒计时                                  │
└─────────────────────────────────────────────────────────┘
                              ↓ 传递数据
┌─────────────────────────────────────────────────────────┐
│                  2️⃣ 逻辑控制层 (State)                   │
│  - 验证手机号格式                                        │
│  - 控制按钮防重复点击                                    │
│  - 管理倒计时                                            │
│  - 调用下一层获取数据                                    │
└─────────────────────────────────────────────────────────┘
                              ↓ 请求数据
┌─────────────────────────────────────────────────────────┐
│                  3️⃣ 网络层 (PetHttp)                    │
│  - 发送验证码请求到服务器                                │
│  - 发送登录请求到服务器                                  │
│  - 接收服务器返回的用户数据                              │
└─────────────────────────────────────────────────────────┘
                              ↓ 保存数据
┌─────────────────────────────────────────────────────────┐
│                   4️⃣ 缓存层 (Caches)                    │
│  - 保存用户信息到本地                                    │
│  - 保存手机号                                            │
│  - 保存token过期时间                                     │
└─────────────────────────────────────────────────────────┘

具体涉及的类为4个类:UI类,数据结构类,HTTP访问类,存储类。由于我的逻辑层和UI类是耦合在一起的,这边就没有单独的逻辑类

登录流程

Dart 复制代码
1. 用户点击"登录"按钮
   ↓
2. UI层拿到手机号和验证码
   ↓
3. 逻辑层调用:PetHttp.loginWithPhone(手机号, 验证码)
   ↓
4. 网络层发请求到服务器:POST /api/v1/auth/login
   ↓
5. 服务器返回:{access_token: "xxx", user_id: "xxx"}
   ↓
6. 网络层包装成 UserData 对象返回
   ↓
7. 逻辑层拿到 UserData,调用:Caches.caches.saveUserData(userData)
   ↓
8. 缓存层保存到手机本地(SharedPreferences)
   ↓
9. 逻辑层通知UI层:登录成功,关闭弹窗

要点:

使用双token认证机制:

Dart 复制代码
┌─────────────────────────────────────────────────────────────┐
│                     双Token机制                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   Access Token(访问令牌)     Refresh Token(刷新令牌)     │
│   ├─ 有效期短(如2小时)        ├─ 有效期长(如30天)         │
│   ├─ 用于访问API资源            ├─ 只用于换取新的Access Token │
│   └─ 放在请求头中携带            └─ 保存在本地(更安全)       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

使用双token的时间线

Dart 复制代码
时间线:登录 ──────→ 2小时后 ──────→ 继续使用 ──────→ 30天后
         │              │                │              │
         ▼              ▼                ▼              ▼
    获得双Token    Access过期       刷新成功       Refresh过期
                  │                              │
                  ▼                              ▼
              用Refresh                      需要重新登录
              换新Access

正则表达式(需要更详细的学习)

当然这里面还有更复杂的东西,比如_request请求服务器方法的实现,和处理服务器返回的数据的泛型类,理解起来就更难。这里先不做解释,等后期再看看怎么写。

代码实例

数据结构类
Dart 复制代码
import 'dart:convert';
import 'package:code/sdk/chat_item.dart';
import '../http/message_list_model.dart';


class UserData {

  String userId = "";
  String? accessToken;//主token
  int? expiresIn; //主token过期时间
  String? phone;
  String? refreshToken; //刷新token
  int? refreshExpiresIn;//刷新token的过期时间


  bool get isLogin {
    if(accessToken == null || accessToken!.isEmpty) {
      return false;
    }else{
      return true;
    }
  }

  List<MessageListModel>? sessions; //会话列表

  List<BaseItem>? lastChatHistory;//最新一次的聊天记录
  String? currentSessionId;//当前会话ID

  // 无参构造函数
  UserData();

  UserData.from(Map<String, dynamic> map) {
    userId = map["user_id"] ?? "";
    accessToken = map["access_token"];
    expiresIn = map["expires_in"];
    phone = map["phone"];
    refreshToken = map["refresh_token"] ?? "";
    refreshExpiresIn = map["refresh_expires_in"] ?? "";

    // 读取缓存的聊天记录
    if (map["last_chat_history"] != null) {
      final List<dynamic> list = map["last_chat_history"];
      final List<BaseItem> history = [];

      for (var item in list) {
        if (item is Map<String, dynamic>) {
          final type = item['type'];
          if (type == 'chat') {
            history.add(ChatItemModel.fromJson(item));
          } else if (type == 'recording') {
            history.add(RecordingItemModel.fromJson(item));
          }
        }
      }

      lastChatHistory = history;
    }

    currentSessionId = map["current_session_id"];
  }

  //保存会话列表
  void setSessions(List<MessageListModel> sessionsList) {
    sessions = sessionsList;
  }

  //添加会话列表
  void addSessions(List<MessageListModel> newSessions) {
    if (sessions == null) {
      sessions = [];
    }
    sessions!.addAll(newSessions);
  }

  //保存聊天记录
  void setChatHistory(List<BaseItem> history, String sessionId) {
    lastChatHistory = history;
    currentSessionId = sessionId;
  }

  //清空聊天记录
  void clearChatHistory() {
    lastChatHistory = null;
    currentSessionId = null;
  }


  Map<String,dynamic> toMap() {
    final map =  <String, dynamic>{
      "user_id":userId,
    };

    if(accessToken != null) {
      map["access_token"] = accessToken!;
    }

    if(expiresIn != null) {
      map["expires_in"] = expiresIn!;
    }

    if(phone != null) {
      map["phone"] = phone!;
    }

    if(refreshToken != null) {
      map["refresh_token"] = refreshToken!;
    }

    if(refreshExpiresIn != null) {
      map["refresh_expires_in"] = refreshExpiresIn!;
    }


    // 保存聊天记录到缓存
    if (lastChatHistory != null && lastChatHistory!.isNotEmpty) {
      map["last_chat_history"] = lastChatHistory!.map((e) => e.toJson()).toList();
    }
    if (currentSessionId != null) {
      map["current_session_id"] = currentSessionId!;
    }

    return map;
  }


  @override
  String toString() {
    return jsonEncode(toMap());
  }


  //用来更新某个属性
  UserData copyWith({
    String? userId,
    String? accessToken,
    int? expiresIn,
    String? phone,
    String? refreshToken,
    int? refreshExpiresIn,
    List<MessageListModel>? sessions,
    List<BaseItem>? lastChatHistory,
    String? currentSessionId,
  }) {
    final newUser = UserData();
    newUser.userId = userId ?? this.userId;
    newUser.accessToken = accessToken ?? this.accessToken;
    newUser.expiresIn = expiresIn ?? this.expiresIn;
    newUser.phone = phone ?? this.phone;
    newUser.refreshToken = refreshToken ?? this.refreshToken;
    newUser.refreshExpiresIn = refreshExpiresIn ?? this.refreshExpiresIn;
    newUser.sessions = sessions ?? this.sessions;
    newUser.lastChatHistory = lastChatHistory ?? this.lastChatHistory;
    newUser.currentSessionId = currentSessionId ?? this.currentSessionId;
    return newUser;
  }

}
HTTP访问类
Dart 复制代码
//=========================获取验证码==============================
  static Future<bool> getSmsCode(String phone) async  {

    final param = {"phone":phone};

    final rs = await _requestPost(_apiCheckCode, param);

    Log.d(rs.toString());

    if(rs.isSuccess()) {
      Log.d("获取验证码成功");
      return true;
    }

    Log.d("获取验证码失败,服务器返回的信息为$rs");
    return false;
  }


  //===========================验证码登录==========================
  static Future<UserData?> loginWithPhone(String phone,String code) async {

    final param = {"phone":phone,"code":code};

    final rs = await _requestPost(_apiLogin, param);

    if(rs.isSuccess()) {

      return UserData.from(rs.data);
    }
    return null;
  }


  //=================================刷新token=================================
  ///使用post
  static Future<bool> refreshToken(String refreshToken) async{

    Log.d("开始刷新token,refresh_token为$refreshToken");

    final param = {"refresh_token":refreshToken};
    final rs = await _requestPost(_apiRefreshToken, param,isLogin: true,isNeedToken: true);

    if(rs.isSuccess()){
      final newAccessToken = rs.data["access_token"];
      final newExpiresIn = rs.data["expires_in"];
      final newRefreshToken = rs.data["refresh_token"];
      final newRefreshExpiresIn = rs.data["refresh_expires_in"];

      //替换原有的token
      UserData? user = Caches.caches.getUserData();
      if(user != null){
        UserData updateUser = user.copyWith(accessToken: newAccessToken,expiresIn: newExpiresIn,refreshToken:newRefreshToken,refreshExpiresIn: newRefreshExpiresIn );
        Caches.caches.saveUserData(updateUser);
        return true;
      }else{
        Log.d("刷新token失败,未找到用户数据");
        return false;
      }
    }
    return false;
  }
存储类
Dart 复制代码
  //取用户类
  UserData? getUserData() {

    if(_userData == null){
      final json = _sp.getString(_keyUserData);
      if(json == null) {
        return null;
      }

      final userdata = UserData.from(jsonDecode(json));
      _userData =  userdata;
    }

    return _userData;
  }

  //存用户数据类
  void saveUserData(UserData userdata) {
    _userData = userdata;
    final jsonStr = jsonEncode(userdata.toMap());
    _sp.setString(_keyUserData, jsonStr);
  }

  //删除用户类
  Future<void> deleteUserData() async{
    _userData = null;
    await _sp.remove(_keyUserData);
  }

  //存电话号码
  void saveNumber(String number){
    _sp.setString(_keyNumber, number);
  }

  //取电话号码
  String? getNumber(){
    return _sp.getString(_keyNumber);
  }
UI类
Dart 复制代码
import 'dart:async';
import 'package:code/caches/user_data.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:code/caches/caches.dart';
import 'package:code/http/pet_http.dart';
import '../caches/console.dart';
import '../generated/l10n.dart';
import '../privacy_page.dart';


///登录弹窗

class LoginDialog extends StatefulWidget {
  // 登录成功后的回调
  final VoidCallback? onLoginSuccess;

  const LoginDialog({
    super.key,
    this.onLoginSuccess,
  });

  @override
  State<StatefulWidget> createState() => _LoginDialogState();

  /// 静态方法:显示底部登录弹窗
  static Future<void> show(
      BuildContext context, {
        VoidCallback? onLoginSuccess,
      })
  {
    return showModalBottomSheet(
      context: context,
      isScrollControlled: true, // 允许控制高度
      backgroundColor: Colors.transparent,
      builder: (context) => LoginDialog(
        onLoginSuccess: () {
          Navigator.of(context).pop(); // 关闭弹窗
          onLoginSuccess?.call();
        },
      ),
    );
  }
}

class _LoginDialogState extends State<LoginDialog> {


  final numberController = TextEditingController(); //手机号输入
  final codeController = TextEditingController(); //验证码输入
  final tapPolicy = TapGestureRecognizer(); //手势识别

  String? phoneError; //手机号错误信息
  String? phoneNumber; //存储的手机号

  DateTime? expireTime; //accessToken过期时间
  DateTime? refreshExpireTime; //refreshToken过期时间

  // 标志位
  var isBusy = false; //是否正在处理请求,防止重复点击
  var isRead = false; //是否已阅读隐私协议
  var _isLoading = false;//登录按钮加载状态

  // 倒计时
  Timer? timer;
  var count = 60;
  bool _isCounting = false;//正在倒计时

  //文本资源
  String sendCodeFail = "发送验证码出现错误";

  @override
  void initState() {
    super.initState();

    //从缓存中读取已保存的手机号,自动填充
    numberController.text = Caches.caches.getPhoneNumber();
    phoneNumber = numberController.text;
  }

  @override
  void dispose() {
    super.dispose();
    timer?.cancel();
    tapPolicy.dispose();
  }



  //===============================发送短信验证码===============================
  void onSendCode() async {
    //防重复点击
    if (isBusy) return;

    // 检查手机号合法
    final reg = RegExp(r'^1[3-9]\d{9}$');
    if (!reg.hasMatch(numberController.text)) {
      phoneError = S.current.validPhoneNumber;
      setState(() {});
      return;
    }

    // 隐私协议检查
    if (!isRead) {
      Fluttertoast.showToast(msg: S.current.readPrivacy);
      return;
    }

    //清除错误信息
    phoneError = null;
    setState(() {});

    //保存手机号到缓存
    Caches.caches.savePhoneNumber(numberController.text);
    phoneNumber = numberController.text;


    //调用接口发送验证码
    final rs = await PetHttp.getSmsCode(phoneNumber!);
    isBusy = false;

    if (rs) {
      if (mounted) startTimer(); //发送成功则开始倒计时
    } else {
      Fluttertoast.showToast(msg:sendCodeFail);
    }
  }

  //================================登录====================================
  void onLogin() async {

    //防重复点击/号码为空
    if (isBusy || phoneNumber == null) return;

    // 检查手机号合法
    final reg = RegExp(r'^1[3-9]\d{9}$');
    if (!reg.hasMatch(numberController.text)) {
      phoneError = S.current.validPhoneNumber;
      setState(() {
        Fluttertoast.showToast(msg: S.current.validPhoneNumber);
      });
      return;
    }

    isBusy = true;
    //获取手机号
    phoneNumber = numberController.text;

    setState(() {
      _isLoading = true;
    });

    Log.d("🔔login_dialog:电话号码为:$phoneNumber,验证码为:${codeController.text}");

    //调用登录API,检查验证码的正确性
    UserData? userdata = await PetHttp.loginWithPhone(phoneNumber!, codeController.text);

    //解除状态
    setState(() {
      isBusy = false;
      _isLoading = false;
    });


    if (userdata == null) { //登录失败
      Fluttertoast.showToast(msg: S.current.loginFailed);
    } else { //登录成功

      //保存用户基本信息
      Caches.caches.saveUserData(userdata); //存用户信息
      Caches.caches.saveNumber(userdata.phone!);//存电话号码

      //保存accessToken过期时间
      DateTime now = DateTime.now();
      //accessToken过期时间 = 当前时间 + 服务器返回的过期秒数
      expireTime = now.add(Duration(seconds: userdata.expiresIn!));
      Caches.caches.saveExpireTime(expireTime!);

      //保存refreshToken过期时间
      refreshExpireTime = now.add(Duration(seconds:userdata.refreshExpiresIn! ));
      Caches.caches.saveRefreshExpireTime(refreshExpireTime!);

      //回调通知父组件登录成功
      widget.onLoginSuccess?.call();

    }
  }




  //===============================验证码的倒计时===================================
  void startTimer() {
    count = 60; //倒计时初始值

    _isCounting = true; //设置标志位

    timer?.cancel();//清理旧定时器

    if (mounted) setState(() {});

    //创建周期性定时器
    timer = Timer.periodic(Duration(seconds: 1), (_) {

      if (mounted) {

        count--;
        if (count > 0) { //倒计时未结束
          setState(() {});
        } else { //倒计时结束
          timer?.cancel(); //停止定时器

          //重置参数
          setState(() {
            _isCounting = false;
            count = 60;
          });
        }

      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Color(0xFFF8F8F8),
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(20),
          topRight: Radius.circular(20),
        ),
      ),
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom, // 键盘弹出时自适应
      ),
      child: SingleChildScrollView(
        physics: const ClampingScrollPhysics(),
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [

              // 标题
              _buildTitle(),

              const SizedBox(height: 30),

              // 手机号
              _buildPhoneNumber(),


              const SizedBox(height: 20),

              // 验证码
              _buildCode(),

              const SizedBox(height: 40),

              // 登录按钮
              _buildLoginButton(),

              const SizedBox(height: 20),

              // 隐私协议
              _buildPrivacyAgreement(),

              const SizedBox(height: 20),
            ],
          ),
        ),
      ),
    );
  }


  //===========================标题===========================
  Widget _buildTitle(){
    return Column(
      children: [

        Text(
          S.current.partBaby,
          style: const TextStyle(
            color: Colors.black,
            fontSize: 24,
            fontWeight: FontWeight.bold,
          ),
        ),
        Text(
          S.current.loginContext,
          style: const TextStyle(
            color: Colors.black,
            fontSize: 14,
          ),
        ),


      ],
    );
  }


  //===========================手机号===========================
  Widget _buildPhoneNumber(){
    return Column(
      children: [
        Row(
          children: [
            Image.asset("assets/images/phone_icon.png"),
            const SizedBox(width: 10),
            Text(S.current.number),
          ],
        ),

        // 手机号输入框
        Padding(
          padding: const EdgeInsets.only(left: 10, right: 10),
          child: Container(
            decoration: BoxDecoration(
              border: Border(
                bottom: BorderSide(
                  color: phoneError != null
                      ? Colors.red
                      : Colors.black.withOpacity(0.3),
                  width: 1,
                ),
              ),
            ),
            child: Row(
              spacing: 10,
              children: [
                Expanded(
                  child: TextField(
                    controller: numberController,
                    keyboardType: TextInputType.number,
                    decoration: InputDecoration(
                      errorText: phoneError,
                      border: InputBorder.none,
                      contentPadding: const EdgeInsets.symmetric(
                        horizontal: 10,
                        vertical: 12,
                      ),
                    ),
                    cursorColor: Colors.black.withOpacity(0.8),
                  ),
                ),
                GestureDetector(
                  onTap: () {
                    numberController.clear();
                  },
                  child: Container(
                    height: 20,
                    width: 20,
                    decoration: const BoxDecoration(
                      color: Colors.grey,
                      shape: BoxShape.circle,
                    ),
                    child: const Center(
                      child: Icon(
                        Icons.close,
                        color: Colors.white,
                        size: 15,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }


  //===========================验证码===========================
  Widget _buildCode(){
    return Column(
      children: [
        Row(
          children: [
            Image.asset("assets/images/safe_icon.png"),
            const SizedBox(width: 10),
            Text(S.current.code),
          ],
        ),

        // 验证码输入框
        Padding(
          padding: const EdgeInsets.only(left: 10, right: 10),
          child: Container(
            decoration: BoxDecoration(
              border: Border(
                bottom: BorderSide(
                  color: Colors.black.withOpacity(0.3),
                  width: 1,
                ),
              ),
            ),
            child: Row(
              spacing: 10,
              children: [
                Expanded(
                  child: TextField(
                    controller: codeController,
                    keyboardType: TextInputType.phone,
                    decoration: const InputDecoration(
                      border: InputBorder.none,
                      enabledBorder: InputBorder.none,
                      focusedBorder: InputBorder.none,
                      errorBorder: InputBorder.none,
                      focusedErrorBorder: InputBorder.none,
                      contentPadding: EdgeInsets.symmetric(
                        horizontal: 10,
                        vertical: 12,
                      ),
                    ),
                    cursorColor: Colors.black.withOpacity(0.8),
                  ),
                ),
                GestureDetector(
                  onTap: () {
                    onSendCode();
                  },
                  child: Container(
                    height: 35,
                    width: 100,
                    margin: const EdgeInsets.only(bottom: 2),
                    decoration: BoxDecoration(
                      color: const Color(0xFF2187EF),
                      borderRadius: BorderRadius.circular(25),
                    ),
                    child: Center(
                      child: Text(
                        _isCounting? "$count 秒" : S.current.getCode,
                        style: const TextStyle(color: Colors.white),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),

      ],
    );
  }


  //===========================登录按钮===========================
  Widget _buildLoginButton(){
    return Container(
      height: 50,
      width: 200,
      decoration: BoxDecoration(
        color: const Color(0xFF2187EF),
        borderRadius: BorderRadius.circular(25),
      ),
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          borderRadius: BorderRadius.circular(25),
          onTap: _isLoading ? null : onLogin,//登录
          child: Center(
            child: _isLoading
                ? const SizedBox(
              width: 24,
              height: 24,
              child: CircularProgressIndicator(
                color: Colors.white,
                strokeWidth: 2,
              ),
            )
                : Text(
              S.current.login,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 18,
              ),
            ),
          ),
        ),
      ),
    );
  }


  //===============================隐私协议===========================
  Widget _buildPrivacyAgreement(){
    return  TextButton(
      onPressed: () {
        setState(() {
          isRead = !isRead;
        });
      },
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          isRead
              ? const Icon(Icons.check_circle, color: Color(0xFF4AB6FD))
              : const Icon(Icons.circle_outlined, color: Colors.grey),
          const SizedBox(width: 5),
          RichText(
            text: TextSpan(
              style: const TextStyle(color: Colors.black),
              text: S.current.hasRead,
              children: [
                TextSpan(
                  recognizer: tapPolicy
                    ..onTap = () {
                      Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (builder) => const PrivacyPage(),
                        ),
                      );
                    },
                  text: S.current.privacyLogin,
                  style: const TextStyle(
                    color: Color(0xFF4AB6FD),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

}
相关推荐
G_dou_2 小时前
# Flutter+OpenHarmony 实战:ToDo待办清单
flutter·harmonyos
不爱吃糖的程序媛10 小时前
Flutter 三方库适配鸿蒙教程
flutter·华为·harmonyos
2501_9197490314 小时前
鸿蒙 Flutter 实战:video_compress 3.1.4 适配 3.27-ohos 全流程
flutter·华为·harmonyos
h64648564h16 小时前
Flutter 国际化(i18n)全指南:一键切换中/英/日多语言
前端·javascript·flutter
kTR2hD1qb21 小时前
Flutter 复杂拖拽排序实战:同源排序 + 跨容器拖拽完整落地
flutter
jingling5551 天前
Flutter | Dio网络请求实战
android·开发语言·前端·flutter
stringwu1 天前
Flutter 复杂拖拽排序实战:同源排序 + 跨容器拖拽完整落地
flutter
UnicornDev1 天前
【Flutter x HarmonyOS 6】设置页面的UI设计
flutter·ui·华为·harmonyos·鸿蒙
G_dou_1 天前
Flutter+OpenHarmony实战:XMB Tracke
flutter·harmonyos·鸿蒙