学习 Flutter (三):玩安卓项目实战 - 上

学习 Flutter(三)

在上一章节,我们对 Flutter 中常用的组件和架构有了一定的了解,那么现在我们既有轮子(基础UI),又有框架了,是时候开始造车了,那么本章将开始进行Android项目实战练习,具体实战什么看作者想要实战什么(无规划,难易不定)...遇到啥就针对的去实战,篇幅会比较长,尽量保证原生,不使用第三方插件,跟着一篇文章可以实现一个项目的完整运行!!!

在此声明,感谢大佬提供的开发API,玩Android 开放API-玩Android - wanandroid.com

一、项目准备

  • 搭建项目结构

    cpp 复制代码
    lib/
     └─ app /// 应用配置层
     └─ base /// 基础抽象层
     └─ models /// 数据结构层
     └─ pages /// UI 层
     └─ providers /// 状态管理层
     └─ services /// 业务接口层
     └─ utils /// 工具类库层
     └─ widgets /// 通用组件层
    └─ main.dart /// 入口文件
  • 创建资源文件夹

    lib 同级目录下创建 assets/images 文件夹,并且在 pubspec.yaml 中进行添加

    yaml 复制代码
    flutter:
      uses-material-design: true
    
      assets:
        - assets/images/
  • 添加 http 相关

    首先在 android 包中找到 AndroidManifest.xml 文件并声明网络请求权限,如果是要有 ioslinuxwindows (作者不是很懂),也都声明一下对应包下的权限,并且我们要在 pubspec.yaml 中进行添加 http 相关包

    yaml 复制代码
    dependencies:
      flutter:
        sdk: flutter
      http: ^0.13.5

    由于作者的 Flutter 版本只能支持到这个 http 版本,懒的去升级版本了,希望读者们能自行进行调整哈,主要是公司项目的版本就是这么低,作者也懒得去升级,还要更新一堆依赖,太麻烦了,又不是不能用!

  • 添加 provider 相关

    pubspec.yaml 中进行添加 provider 相关包

    yaml 复制代码
    dependencies:
      flutter:
        sdk: flutter
      provider: ^6.1.1

    provider 是 Flutter 官方推荐的状态管理方案之一,是基于 InheritedWidget 封装的简单、轻量级依赖注入和状态管理工具。它的设计理念是提供一种优雅且高效的手段,让 Widget 树中的各个组件能够访问和响应状态的变化,避免手动传递数据(避免"Prop Drilling"),满足中小型及大型项目的状态管理需求。简单点可以理解为 Android 中的 LiveData, 当前不是完全相同的还是有些差别的。

至此我们基础项目架构已经搭建完成了,我们接下来将逐步完成项目的实现。

二、app包

  • constant.dart

    常量配置,如接口地址、主题色等

    dart 复制代码
    class ApiConstants {
      static const String baseUrl = "https://www.wanandroid.com/";
    
      /// 首页文章
      static const String homePageArticle = "article/list/";
    
      /// 置顶文章
      static const String topArticle = "article/top/json";
    
      /// 获取banner
      static const String banner = "banner/json";
    
      /// 登录
      static const String login = "user/login";
    
      /// 注册
      static const String register = "user/register";
    
      /// 退出登录
      static const String logout = "user/logout/json";
    
      /// 项目分类
      static const String projectCategory = "project/tree/json";
    
      /// 项目列表
      static const String projectList = "project/list/";
    
      /// 搜索
      static const String searchForKeyword = "article/query/";
    
      /// 广场页列表
      static const String plazaArticleList = "user_article/list/";
    
      /// 点击收藏
      static const String collectArticle = "lg/collect/";
    
      /// 取消收藏
      static const String uncollectArticel = "lg/uncollect_originId/";
    
      /// 获取搜索热词
      static const String hotKeywords = "hotkey/json";
    
      /// 获取收藏文章列表
      static const String collectList = "lg/collect/list/";
    
      /// 收藏网站列表
      static const String collectWebaddressList = "lg/collect/usertools/json";
    
      /// 我的分享
      static const String sharedList = "user/lg/private_articles/";
    
      /// 分享文章 post
      static const String shareArticle = "lg/user_article/add/json";
    
      /// todoList
      static const String todoList = "lg/todo/v2/list/";
    }
    class RoutesConstants {
     /// 登录注册界面
     static const String login = "/login_register";
     /// 首页
     static const String home = "/home";
     }
  • routes.dart

    路由表及跳转管理

    dart 复制代码
    class AppRoutes{
      static final routes = <String, WidgetBuilder>{
        RoutesConstants.login: (_) => LoginRegisterPage(),
        RoutesConstants.home: (_) => HomePage(),
      };
    }

三、base 包

  • base.dart

    BasePage 是一个 Mixin,可用于所有继承 State 的类(例如 StatefulWidget 页面)

    dart 复制代码
    mixin BasePage<T extends StatefulWidget> on State<T> {
      /// 是否正在显示loading弹窗
      bool showingLoading = false;
    
      /// 显示加载中弹窗(如果已经显示过了,就不重复显示)
      Future<void> showLoadingDialog() async {
        if (showingLoading) {
          return;
        }
    
        /// 清除焦点,隐藏键盘
        FocusManager.instance.primaryFocus?.unfocus();
    
        showingLoading = true;
        await showDialog<int>(
            context: context,
            barrierDismissible: true, // 允许点击背景关闭弹窗
            builder: (context) {
              return const AlertDialog(
                content: Column(
                  mainAxisSize: MainAxisSize.min, // 内容高度根据子内容压缩
                  children: [
                    CircularProgressIndicator(), // 加载进度条
                    Padding(
                      padding: EdgeInsets.only(top: 24),
                      child: Text("请稍等...."), // 加载提示文字
                    )
                  ],
                ),
              );
            });
        showingLoading = false; // 弹窗关闭后,恢复状态
      }
    
      dismissLoading() {
        if (showingLoading) {
    
          /// 清除焦点,隐藏键盘
          FocusManager.instance.primaryFocus?.unfocus();
    
          showingLoading = false;
          Navigator.of(context).pop(); // 关闭当前弹窗
        }
      }
    }
    
    /// 用于显示加载失败,并提供"点击重试"的组件
    class RetryWidget extends StatelessWidget {
      const RetryWidget({super.key, required this.onTapRetry});
      /// 点击"重试"的回调函数(由外部传入)
      final void Function() onTapRetry;
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
            behavior: HitTestBehavior.opaque, // 允许点击透明区域
            onTap: onTapRetry, // 用户点击整个区域触发重试
            child: const SizedBox(
              width: double.infinity,
              height: double.infinity,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center, // 居中显示
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Padding(
                      padding: EdgeInsets.only(bottom: 16),
                      child: Icon(Icons.refresh)), // 刷新图标
                  Text("加载失败,点击重试")  // 文本提示
                ],
              ),
            ));
      }
    }
    
    /// 用于显示"无数据"的提示组件
    class EmptyWidget extends StatelessWidget {
      const EmptyWidget({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const SizedBox(
          width: double.infinity,
          height: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center, // 内容垂直居中
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Padding(
                  padding: EdgeInsets.only(bottom: 16), child: Icon(Icons.book)),
              Text("无数据")
            ],
          ),
        );
      }
    }

四、services 包

  • api_service.dart

    dart 复制代码
    class ApiService {
    
      /// 登录
      static Future<Map<String, dynamic>> login({
        required String username,
        required String password,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.login);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
          },
        );
        return _handleResponse(response);
      }
    
      /// 注册
      static Future<Map<String, dynamic>> register({
        required String username,
        required String password,
        required String repassword,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.register);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
            'repassword': repassword,
          },
        );
        return _handleResponse(response);
      }
    
      /// 通用处理响应
      static Map<String, dynamic> _handleResponse(http.Response response) {
        if (response.statusCode == 200) {
          print("请求结果为: ${response.body}");
          return jsonDecode(response.body);
        } else {
          throw Exception('请求失败:${response.statusCode}');
        }
      }
    }

五、utils 包

toast_utils.dart

工具类:用于显示原生风格的 Toast 弹窗

dart 复制代码
class ToastUtils {
  // 当前显示的 OverlayEntry (移除时引用)
  static OverlayEntry? _overlayEntry;
  // 标记是否正在显示 Toast ,避免重复弹出
  static bool _isShowing = false;

  /// 显示 Toast 弹窗
  /// [context]: 上下文弹窗
  /// [message]: 要显示的提示文字
  /// [duration]: 持续显示的时间(默认为 2 秒)
  static void showToast(BuildContext context, String message,
      {Duration duration = const Duration(seconds: 2)}) {
    // 如果已经有 Toast 正在显示, 直接返回
    if (_isShowing) return;

    _isShowing = true;

    // 创建 OverlayEntry(悬浮层)
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        bottom: MediaQuery.of(context).size.height * 0.5, // 离底部距离
        left: MediaQuery.of(context).size.width * 0.2, // 离左边距离
        width: MediaQuery.of(context).size.width * 0.6, // 离右边距离
        child: _ToastWidget(message: message), // 自定义 Toast 样式
      ),
    );

    // 插入到 Overlay 中显示
    Overlay.of(context).insert(_overlayEntry!);

    // 延时关闭 Toast
    Future.delayed(duration, () {
      _overlayEntry?.remove();
      _overlayEntry = null;
      _isShowing = false;
    });
  }
}
/// 私有 Toast 组件,用于实现带动画的样式
class _ToastWidget extends StatefulWidget {
  final String message;

  const _ToastWidget({Key? key, required this.message}) : super(key: key);

  @override
  State<_ToastWidget> createState() => _ToastWidgetState();
}

class _ToastWidgetState extends State<_ToastWidget>
    with SingleTickerProviderStateMixin {
  // 控制透明度动画的控制器
  late AnimationController _controller;
  // 透明度动画
  late Animation<double> _opacityAnimation;

  @override
  void initState() {
    super.initState();
    // 初始化动画控制器
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300), // 动画时长 300ms
      vsync: this,
    );
    // 使用 CurvedAnimation 包裹,使用 easeInOut 曲线
    _opacityAnimation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    // 播放动画
    _controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _opacityAnimation, // 使用透明度动画包裹整个 Toast
      child: Material(
        color: Colors.transparent, // 背景透明
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), // 内边距
          margin: const EdgeInsets.symmetric(horizontal: 16), // 外边距
          decoration: BoxDecoration(
            color: Colors.black.withOpacity(0.8), // 半透明黑背景
            borderRadius: BorderRadius.circular(20), // 圆角
          ),
          child: Text(
            widget.message,
            textAlign: TextAlign.center,
            style: const TextStyle(color: Colors.white, fontSize: 14),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    // 释放动画资源
    _controller.dispose();
    super.dispose();
  }
}

六、主入口

main.dart

dart 复制代码
void main() {
  // 保证 Flutter 与平台(Android/iOS)进行绑定初始化,确保调用平台通道、使用插件前初始化完毕
  WidgetsFlutterBinding.ensureInitialized();

  // 隐藏系统状态栏和底部导航栏(全屏模式)
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);

  // 启动应用,并注册全局的 Provider 状态管理
  runApp(
    MultiProvider(
      providers: [
        // 注册 LoginResponseProvider 到全局,可以在整个应用中访问该登录状态
        ChangeNotifierProvider(create: (_) => LoginResponseProvider()),
      ],
      child: MyApp(), // 根组件
    ),
  );
}

/// 应用根组件
class MyApp extends StatelessWidget {
  const MyApp();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '玩安卓 Flutter 版', // 应用标题
      debugShowCheckedModeBanner: false, // 关闭右上角 Debug 标签
      theme: ThemeData(
        primarySwatch: Colors.blue, // 设置主题颜色为蓝色
      ),
      initialRoute: RoutesConstants.login, // 应用启动时的初始路由(跳转登录页)
      routes: AppRoutes.routes, // 注册路由表,定义页面跳转路径
    );
  }
}

七、登录界面相关

  • models/login_register_response 登录注册相关数据类,由于 API 接口登录注册数据相同,所以就共用一个

    dart 复制代码
    class LoginRegisterResponse {
      final int errorCode;
      final String errorMsg;
      final UserInfo? data;
    
      LoginRegisterResponse({
        required this.errorCode,
        required this.errorMsg,
        required this.data,
      });
      
      /// 将 JSON 转换为对象
      factory LoginRegisterResponse.fromJson(Map<String, dynamic> json) {
        return LoginRegisterResponse(
          errorCode: json['errorCode'],
          errorMsg: json['errorMsg'] ?? '',
          data: json['data'] != null ? UserInfo.fromJson(json['data']) : null,
        );
      }
      
      /// 将对象转换为 JSON
      Map<String, dynamic> toJson() {
        return {
          'errorCode': errorCode,
          'errorMsg': errorMsg,
          'data': data?.toJson(), // 注意 data 可能为空
        };
      }
    
    }
    
    class UserInfo {
      final bool admin;
      final List<dynamic> chapterTops;
      final int coinCount;
      final List<int> collectIds;
      final String email;
      final String icon;
      final int id;
      final String nickname;
      final String password;
      final String publicName;
      final String token;
      final int type;
      final String username;
    
      UserInfo({
        required this.admin,
        required this.chapterTops,
        required this.coinCount,
        required this.collectIds,
        required this.email,
        required this.icon,
        required this.id,
        required this.nickname,
        required this.password,
        required this.publicName,
        required this.token,
        required this.type,
        required this.username,
      });
    
      /// 将 JSON 转换为对象
      factory UserInfo.fromJson(Map<String, dynamic> json) {
        return UserInfo(
          admin: json['admin'],
          chapterTops: json['chapterTops'] ?? [],
          coinCount: json['coinCount'],
          collectIds: List<int>.from(json['collectIds']),
          email: json['email'] ?? '',
          icon: json['icon'] ?? '',
          id: json['id'],
          nickname: json['nickname'] ?? '',
          password: json['password'] ?? '',
          publicName: json['publicName'] ?? '',
          token: json['token'] ?? '',
          type: json['type'],
          username: json['username'] ?? '',
        );
      }
      
      /// 将对象转换为 JSON
      Map<String, dynamic> toJson() {
        return {
          'admin': admin,
          'chapterTops': chapterTops,
          'coinCount': coinCount,
          'collectIds': collectIds,
          'email': email,
          'icon': icon,
          'id': id,
          'nickname': nickname,
          'password': password,
          'publicName': publicName,
          'token': token,
          'type': type,
          'username': username,
        };
      }
    }
  • services/api_service.dart

    dart 复制代码
    /// 登录
      static Future<Map<String, dynamic>> login({
        required String username,
        required String password,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.login);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
          },
        );
        return _handleResponse(response);
      }
    
      /// 注册
      static Future<Map<String, dynamic>> register({
        required String username,
        required String password,
        required String repassword,
      }) async {
        final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.register);
        final response = await http.post(
          url,
          headers: {'Content-Type': 'application/x-www-form-urlencoded'},
          body: {
            'username': username,
            'password': password,
            'repassword': repassword,
          },
        );
        return _handleResponse(response);
      }
  • providers/login_response_provider.dart

    dart 复制代码
    class LoginResponseProvider extends ChangeNotifier {
      LoginRegisterResponse? _user;
    
      LoginRegisterResponse? get user => _user;
    
      bool get isLoggedIn => _user != null;
    
      void login(LoginRegisterResponse user) {
        _user = user;
        notifyListeners();
      }
    
      void logout() {
        _user = null;
        notifyListeners();
      }
    }

    LoginResponseProvider 承自 ChangeNotifier,这使得该类可以用于 Provider 框架中进行状态监听和刷新界面。

    • 私有属性 _user:用于存储登录成功后的用户数据。

    • 使用 LoginRegisterResponse 类型,结构清晰,可访问完整的登录响应(如 user.data?.usernameerrorMsg 等)。

  • pages/login_register/login_register_view_model.dart

    dart 复制代码
    class LoginViewModel extends ChangeNotifier {
      // 用于用户名输入框的控制器,管理文本内容
      final usernameController = TextEditingController();
      // 用于密码输入框的控制器,管理文本内容
      final passwordController = TextEditingController();
      // 用于确认密码输入框的控制器,管理文本内容(注册模式时使用)
      final repasswordController = TextEditingController();
    
      // 当前是否处于登录模式,true表示登录,false表示注册
      bool _isLogin = true;
      bool get isLogin => _isLogin;
    
      // 当前是否处于加载状态(显示loading)
      bool _loading = false;
      bool get isLoading => _loading;
    
      // 切换登录/注册模式,切换时通知监听者刷新UI
      void toggleMode() {
        _isLogin = !_isLogin;
        notifyListeners();
      }
    
      // 调用登录接口,发送用户名和密码,等待响应并转换为模型对象
      Future<LoginRegisterResponse> login() async {
        _setLoading(true); // 设置loading状态为true
        final response = await ApiService.login(
          username: usernameController.text.trim(),
          password: passwordController.text.trim(),
        );
        _setLoading(false); // 加载完成,设置loading状态为false
        return LoginRegisterResponse.fromJson(response);
      }
    
      // 调用注册接口,发送用户名、密码和确认密码,等待响应并转换为模型对象
      Future<LoginRegisterResponse> register() async {
        _setLoading(true); // 设置loading状态为true
        final response = await ApiService.register(
          username: usernameController.text.trim(),
          password: passwordController.text.trim(),
          repassword: repasswordController.text.trim(),
        );
        _setLoading(false); // 加载完成,设置loading状态为false
        return LoginRegisterResponse.fromJson(response);
      }
    
      // 私有方法,更新加载状态并通知监听者刷新UI
      void _setLoading(bool value) {
        _loading = value;
        notifyListeners();
      }
    
      // 释放文本控制器资源,防止内存泄漏,建议在页面销毁时调用
      void disposeControllers() {
        usernameController.dispose();
        passwordController.dispose();
        repasswordController.dispose();
      }
    }
  • pages/login_register/login_register_page.dart

    dart 复制代码
    class LoginRegisterPage extends StatelessWidget {
      const LoginRegisterPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        // 使用 ChangeNotifierProvider 提供 LoginViewModel 给子组件使用
        return ChangeNotifierProvider<LoginViewModel>(
          create: (_) => LoginViewModel(),
          child: const _LoginRegisterBody(),
        );
      }
    }
    
    class _LoginRegisterBody extends StatefulWidget {
      const _LoginRegisterBody({super.key});
    
      @override
      State<_LoginRegisterBody> createState() => _LoginRegisterBodyState();
    }
    
    class _LoginRegisterBodyState extends State<_LoginRegisterBody>
        with BasePage<_LoginRegisterBody> {
      @override
      void dispose() {
        // 页面销毁时释放 LoginViewModel 中的控制器资源,防止内存泄漏
        context.read<LoginViewModel>().disposeControllers();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        // 监听 LoginViewModel 的变化,刷新UI
        final vm = context.watch<LoginViewModel>();
        final isLogin = vm.isLogin; // 判断当前是登录模式还是注册模式
    
        return Scaffold(
          appBar: AppBar(
            title: const Text("登录/注册"),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                // 用户名输入框,绑定到 ViewModel 的控制器
                TextField(
                  controller: vm.usernameController,
                  decoration: const InputDecoration(hintText: "用户名"),
                ),
                const SizedBox(height: 12),
                // 密码输入框,绑定到 ViewModel 的控制器,且密码隐藏
                TextField(
                  obscureText: true,
                  controller: vm.passwordController,
                  decoration: const InputDecoration(hintText: "密码"),
                ),
                // 如果是注册模式,显示确认密码输入框
                if (!isLogin) ...[ /// ... 是 Dart 语言中的 扩展操作符(spread operator)。
                  const SizedBox(height: 12),
                  TextField(
                    obscureText: true,
                    controller: vm.repasswordController,
                    decoration: const InputDecoration(hintText: "确认密码"),
                  ),
                ],
                const SizedBox(height: 24),
                // 登录或注册按钮,点击触发提交事件
                ElevatedButton(
                  onPressed: () => _onSubmit(context),
                  child: Text(isLogin ? "登录" : "注册"),
                ),
                // 登录或注册按钮,点击触发提交事件
                TextButton(
                  onPressed: () => vm.toggleMode(),
                  child: Text(isLogin ? "没有账号?去注册" : "已有账号?去登录"),
                ),
              ],
            ),
          ),
        );
      }
    
      /// 点击提交按钮时的处理逻辑
      Future<void> _onSubmit(BuildContext context) async {
        FocusScope.of(context).unfocus();
        // 关闭软键盘
        final vm = context.read<LoginViewModel>();
    
        final username = vm.usernameController.text.trim();
        final password = vm.passwordController.text.trim();
        final repassword = vm.repasswordController.text.trim();
    
        // 简单校验用户名和密码是否为空
        if (username.isEmpty || password.isEmpty) {
          ToastUtils.showToast(context, '请输入用户名和密码');
          return;
        }
    
        showLoadingDialog(); // 显示加载弹窗
    
        // 根据当前模式调用登录或注册接口
        final response = vm.isLogin
            ? await vm.login()
            : await (password == repassword
                ? vm.register()
                : Future.error("两次密码不一致"));
    
        dismissLoading(); // 关闭加载弹窗
    
        if (response.errorCode == 0) {
    
          ToastUtils.showToast(
              context, vm.isLogin ? "登录成功 ${response.data?.username}" : "注册成功,请登录");
          if (vm.isLogin) { // 登录成功
            /// 保存用户数据
            context.read<LoginResponseProvider>().login(response);
            /// 跳转至首页
            Navigator.pushNamed(context, RoutesConstants.home);
          }
        } else {
          ToastUtils.showToast(context, "失败: ${response.errorMsg}");
        }
      }
    }

    ... 是 Dart 语言中的 扩展操作符(spread operator)

    它的作用是把一个集合(比如 List)中的所有元素"展开"放到另一个集合中

    在我们的代码中

    • if (!isLogin) 判断是否是注册模式(不是登录)。

    • ...[someList] 就是把这个列表里的所有 widget 展开,作为父级 Column 的直接子元素。

    如果没有 ...,你只能写一个单独的 widget,不能写一个列表。

至此,我们登录注册界面和功能都已完成,如下所示


八、首页相关

首先我们要在 pages 包下创建 home、navi、project、top、tree、personal 包,分别对应的主页面、导航、项目、首页、体系、个人中心模块,考虑到篇幅问题,这里先各自创建一个 helloworld 界面,之后在逐步实现,我们先看主页面设计

  • app/constants.dart

    在配置信息中 RoutesConstants 添加 home_page 相关

    dart 复制代码
    class RoutesConstants {
      /// 登录注册界面
      static const String login = "/login_register";
      /// 首页
      static const String home = "/home";
    }
  • app/routes.dart

    添加路由表及跳转管理

    dart 复制代码
    class AppRoutes{
      static final routes = <String, WidgetBuilder>{
        RoutesConstants.login: (_) => LoginRegisterPage(),
        RoutesConstants.home: (_) => HomePage(),
      };
    }
  • widgets/custom_appbar.dart

    dart 复制代码
    // 自定义 AppBar 组件,实现带标题和可选搜索图标的 AppBar
    class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
      final String title; // 标题文字
      final bool showSearchIcon; // 是否显示右侧的搜索图标
    
      const CustomAppBar({
        Key? key,
        required this.title,
        this.showSearchIcon = true, // 默认显示搜索图标
      }) : super(key: key);
    
      @override
      State<CustomAppBar> createState() => _CustomAppBarState();
    
      // 指定 AppBar 的高度
      @override
      Size get preferredSize => const Size.fromHeight(kToolbarHeight);
    }
    
    class _CustomAppBarState extends State<CustomAppBar> {
      @override
      Widget build(BuildContext context) {
        return AppBar(
          // 设置 AppBar 标题
          title: Text(widget.title),
    
          // 关闭默认的返回按钮(返回箭头),适用于首页等不需要返回的场景
          automaticallyImplyLeading: false,
    
          // 设置 AppBar 背景颜色
          backgroundColor: Colors.blue,
    
          // 自定义标题文字样式
          titleTextStyle: TextStyle(color: Colors.white, fontSize: 20),
    
          // 如果需要显示搜索图标,则构建 IconButton;否则不显示 actions
          actions: widget.showSearchIcon
              ? [
                  IconButton(
                    icon: const Icon(
                      Icons.search,
                      color: Colors.white,
                    ),
                    onPressed: () {
                      // 这里是搜索按钮点击后的回调,可根据需要添加跳转或弹窗逻辑
                      print("搜索图标点击");
                    },
                  )
                ]
              : null,
        );
      }
    }
  • page/home/home_page.dart

    dart 复制代码
    class HomePage extends StatefulWidget {
      @override
      _HomePageState createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      // 当前底部导航选中的索引
      int _currentIndex = 0;
    
      // 用于缓存页面实例,避免每次切换都重新创建
      List<Widget?> _pages = List<Widget?>.filled(5, null, growable: false);
    
      static const List<String> _labels = [
        "首页",
        "体系",
        "导航",
        "项目",
        "我的",
      ];
    
      // 根据索引构建对应页面
      Widget _buildPage(int index) {
        switch (index) {
          case 0:
            return const TopPage(); // 首页页面
          case 1:
            return const TreePage(); // 体系页面
          case 2:
            return const NaviPage(); // 导航页面
          case 3:
            return const ProjectPage(); // 项目页面
          case 4:
            return const PersonalPage(); // 个人中心页面
          default:
            return const SizedBox(); // 默认空白页面
        }
      }
    
      @override
      void initState() {
        super.initState();
    
        // 读取登录状态Provider,获取当前用户信息
        final loginProvider = context.read<LoginResponseProvider>();
        final user = loginProvider.user;
        if (user != null) {
          // 打印当前用户用户名,方便调试
          print('首页获取用户信息: ${user.data?.username}');
        }
      }
    
      @override
      Widget build(BuildContext context) {
        final showSearchIcon = _currentIndex != 4; // 除了"我的"页,其他页显示搜索图标
    
        return Scaffold(
          appBar: CustomAppBar(
            title: _labels[_currentIndex],
            showSearchIcon: showSearchIcon,
          ),
          // 使用 IndexedStack 保持所有页面状态,同时显示当前选中的页面
          body: IndexedStack(
            index: _currentIndex,
            children: List.generate(_pages.length, (index) {
              // 如果缓存中该页面为空,则创建并缓存
              if (_pages[index] == null) {
                _pages[index] = _buildPage(index);
              }
              // 返回缓存的页面实例
              return _pages[index]!;
            }),
          ),
          // 底部导航栏,切换时更新当前索引并刷新界面
          bottomNavigationBar: BottomNavigationBar(
            currentIndex: _currentIndex,
            // 当前选中索引
            onTap: (index) {
              setState(() {
                _currentIndex = index; // 更新选中索引,触发重建
              });
            },
            selectedItemColor: Colors.blue,
            // 选中项颜色
            unselectedItemColor: Colors.grey,
            // 未选中项颜色
            items: const [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
              BottomNavigationBarItem(icon: Icon(Icons.account_tree), label: "体系"),
              BottomNavigationBarItem(icon: Icon(Icons.navigation), label: "导航"),
              BottomNavigationBarItem(icon: Icon(Icons.article), label: "项目"),
              BottomNavigationBarItem(icon: Icon(Icons.person), label: "我的"),
            ],
          ),
          backgroundColor: Colors.white60, // 整体背景色
        );
      }
    }

至此,我们首页已经搭建完成,我们自定义了一个标题栏组件,当在 '我的' 界面时,搜索按钮不进行显示,页面效果如下所示


相关推荐
砖厂小工3 小时前
用 GLM + OpenClaw 打造你的 AI PR Review Agent — 让龙虾帮你审代码
android·github
张拭心4 小时前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心4 小时前
Android 17 来了!新特性介绍与适配建议
android·前端
shankss5 小时前
Flutter 下拉刷新库 pull_to_refresh_plus 设计与实现分析
flutter
Kapaseker6 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴6 小时前
Android17 为什么重写 MessageQueue
android
忆江南21 小时前
iOS 深度解析
flutter·ios
明君8799721 小时前
Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅
前端·flutter
恋猫de小郭1 天前
移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据
前端·flutter·ai编程
MakeZero1 天前
Flutter那些事-交互式组件
flutter