学习 Flutter(五):玩安卓项目实战 - 下

发布于:2025-07-27 ⋅ 阅读:(16) ⋅ 点赞:(0)

学习 Flutter(五):玩安卓项目实战 - 下

在上一章节,我们完成主界面的首页、体系、导航、项目、界面的编码,这一章节我们将剩下的内容包括我的、我的收藏列表、退出登录、文章详情、搜索界面给完成。

一、我的

我的界面算是叫个人中心界面吧,我这里就显示用户昵称和收藏列表以及退出登录,用户头像的话接口没有,所以自己用本地图片来显示

services/api_service.dart

/// 退出登录
  static Future<Map<String, dynamic>> logout() async {
    final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.logout}");
    print('退出登录 url 为: $url');
    final headers = <String, String>{};
    if (_cookie != null) {
      headers['Cookie'] = _cookie!;
    }
    final response = await http.get(url, headers: headers);
    _cookie = null;
    return _handleResponse(response);
  }

personal/personal_page_view_model.dart

class PersonalPageViewModel extends ChangeNotifier {
  // 调用登录接口,发送用户名和密码,等待响应并转换为模型对象
  Future<bool> logout() async {
    final response = await ApiService.logout();
    final result = EmptyResponse.fromJson(response);
    return result.success;
  }
}

personal/personal_page.dart

class PersonalPage extends StatelessWidget {
  const PersonalPage();

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<PersonalPageViewModel>(
      create: (_) => PersonalPageViewModel(),
      child: _PersonalPageBody(),
    );
  }
}

class _PersonalPageBody extends StatefulWidget {
  const _PersonalPageBody();

  
  _PersonalPageState createState() => _PersonalPageState();
}

class _PersonalPageState extends State<_PersonalPageBody> {
  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          const SizedBox(height: 30),
          // 头像 + 用户名
          Center(
            child: Column(
              children: [
                CircleAvatar(
                  radius: 50,
                  backgroundImage: AssetImage('assets/images/npc_face.png'),
                ),
                SizedBox(height: 12),
                Text(
                  "用户名: ${context.read<LoginResponseProvider>().user?.data?.nickname}",
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                )
              ],
            ),
          ),
          const SizedBox(height: 40),
          // 列表项
          Expanded(
            child: ListView(
              children: [
                ListTile(
                  leading: const Icon(Icons.favorite_border),
                  title: const Text('我的收藏'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {
                    // 跳转到收藏页面
                    Navigator.pushNamed(context, RoutesConstants.collectList);
                  },
                ),
                ListTile(
                  leading: const Icon(Icons.info_outline),
                  title: const Text('关于我'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {
                    ToastUtils.showToast(context, "记录学习的一个打工牛马!");
                  },
                ),
              ],
            ),
          ),
          // 退出登录按钮
          Padding(
            padding: const EdgeInsets.all(20.0),
            child: ElevatedButton.icon(
              icon: const Icon(Icons.logout),
              label: const Text('退出登录'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.red,
                foregroundColor: Colors.white,
                minimumSize: const Size.fromHeight(50),
              ),
              onPressed: () {
                // 执行退出逻辑
                _onLogoutClick();
              },
            ),
          ),
        ],
      ),
    );
  }

  _onLogoutClick() async {
    bool isSuccess = await (context.read<PersonalPageViewModel>().logout());

    if (isSuccess) {
      ToastUtils.showToast(context, "退出登录成功!");
      Navigator.pushNamed(context, RoutesConstants.login);
    }
  }
}

作者实现的效果很简单,退出登录我们就将保存在内部的cookie给重置,并且退出到登录界面,这里作者没有实现持久化登录,读者们可以自己去实现一下,原理也很简单,将登录成功返回的cookie保存在本地,在下次登录的时候查看是否有cookie,有的话就直接登录,没有的话就要求用户进行登录操作。至此我们我的界面就设计完成了,如下所示

在这里插入图片描述

二、我的收藏界面

接下来我们来实现我的收藏界面

services/api_service.dart

/// 我的收藏页面
  static Future<Map<String, dynamic>> collectList(int page) async {
    final url = Uri.parse(
        "${ApiConstants.baseUrl}${ApiConstants.collectList}$page/json");
    print('我的收藏页面 url 为: $url');
    final headers = <String, String>{};
    if (_cookie != null) {
      headers['Cookie'] = _cookie!;
    }
    final response = await http.get(url, headers: headers);
    return _handleResponse(response);
  }

models/collect_list_response.dart

class CollectResponse {
  final Collect? data;
  final int errorCode;
  final String errorMsg;

  CollectResponse({
    required this.data,
    required this.errorCode,
    required this.errorMsg,
  });

  factory CollectResponse.fromJson(Map<String, dynamic> json) {
    return CollectResponse(
      data: json['data'] != null ? Collect.fromJson(json['data']) : null,
      errorCode: json['errorCode'],
      errorMsg: json['errorMsg'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'data': data?.toJson(),
      'errorCode': errorCode,
      'errorMsg': errorMsg,
    };
  }
}
class Collect {
  final int curPage;
  final List<CollectDetail> datas;
  final int offset;
  final bool over;
  final int pageCount;
  final int size;
  final int total;

  Collect({
    required this.curPage,
    required this.datas,
    required this.offset,
    required this.over,
    required this.pageCount,
    required this.size,
    required this.total,
  });

  factory Collect.fromJson(Map<String, dynamic> json) {
    return Collect(
      curPage: json['curPage'],
      datas: (json['datas'] as List)
          .map((e) => CollectDetail.fromJson(e))
          .toList(),
      offset: json['offset'],
      over: json['over'],
      pageCount: json['pageCount'],
      size: json['size'],
      total: json['total'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'curPage': curPage,
      'datas': datas.map((e) => e.toJson()).toList(),
      'offset': offset,
      'over': over,
      'pageCount': pageCount,
      'size': size,
      'total': total,
    };
  }
}

class CollectDetail {
  final String author;
  final int chapterId;
  final String chapterName;
  final int courseId;
  final String desc;
  final String envelopePic;
  final int id;
  final String link;
  final String niceDate;
  final String origin;
  final int originId;
  final int publishTime;
  final String title;
  final int userId;
  final int visible;
  final int zan;

  CollectDetail({
    required this.author,
    required this.chapterId,
    required this.chapterName,
    required this.courseId,
    required this.desc,
    required this.envelopePic,
    required this.id,
    required this.link,
    required this.niceDate,
    required this.origin,
    required this.originId,
    required this.publishTime,
    required this.title,
    required this.userId,
    required this.visible,
    required this.zan,
  });

  factory CollectDetail.fromJson(Map<String, dynamic> json) {
    return CollectDetail(
      author: json['author'] ?? '',
      chapterId: json['chapterId'],
      chapterName: json['chapterName'] ?? '',
      courseId: json['courseId'],
      desc: json['desc'] ?? '',
      envelopePic: json['envelopePic'] ?? '',
      id: json['id'],
      link: json['link'] ?? '',
      niceDate: json['niceDate'] ?? '',
      origin: json['origin'] ?? '',
      originId: json['originId'],
      publishTime: json['publishTime'],
      title: json['title'] ?? '',
      userId: json['userId'],
      visible: json['visible'],
      zan: json['zan'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'author': author,
      'chapterId': chapterId,
      'chapterName': chapterName,
      'courseId': courseId,
      'desc': desc,
      'envelopePic': envelopePic,
      'id': id,
      'link': link,
      'niceDate': niceDate,
      'origin': origin,
      'originId': originId,
      'publishTime': publishTime,
      'title': title,
      'userId': userId,
      'visible': visible,
      'zan': zan,
    };
  }
}

pages/collect/collect_list_page_view_model.dart

class CollectListPageViewModel extends ChangeNotifier {
  // 文章列表数据(私有)
  List<CollectDetail> _collectDetailList = [];

  // 对外暴露文章列表
  List<CollectDetail> get collectDetailList => _collectDetailList;

  // 加载文章数据,page 为页码(0 表示首页刷新)
  Future<void> loadCollectList(int page) async {
    // 调用 API 获取文章列表数据
    final response = await ApiService.collectList(page);

    // 解析响应数据中的文章列表
    final newList = CollectResponse.fromJson(response).data!.datas;

    if (page == 0) {
      // 如果是第一页,重置整个文章列表
      _collectDetailList = newList;
    } else {
      // 否则追加到原有列表后
      _collectDetailList.addAll(newList);
    }

    // 通知 UI 更新
    notifyListeners();
  }
}

pages/collect/collect_list_page.dart

class CollectListPage extends StatelessWidget {
  const CollectListPage({super.key});

  
  Widget build(BuildContext context) {
    // 使用ChangeNotifierProvider提供视图模型
    return ChangeNotifierProvider(
      // 创建视图模型实例并立即加载第一页数据
      create: (_) => CollectListPageViewModel()..loadCollectList(0),
      // 页面主体内容
      child: const _CollectListPageBody(),
    );
  }
}

// 收藏列表页面主体内容(私有组件)
class _CollectListPageBody extends StatefulWidget {
  const _CollectListPageBody();

  
  State<_CollectListPageBody> createState() => _CollectListPageState();
}

// 收藏列表页面状态类,处理滚动和加载更多逻辑
class _CollectListPageState extends State<_CollectListPageBody> {
  late final ScrollController _scrollController;  // 滚动控制器
  int _currentPage = 0;  // 当前页码
  bool _isLoadingMore = false;  // 是否正在加载更多

  
  void initState() {
    super.initState();
    // 初始化滚动控制器并添加滚动监听
    _scrollController = ScrollController()..addListener(_onScroll);
  }

  
  void dispose() {
    // 销毁时清理滚动控制器
    _scrollController.dispose();
    super.dispose();
  }

  // 滚动事件处理函数,实现无限滚动
  void _onScroll() {
    final maxScroll = _scrollController.position.maxScrollExtent;  // 最大滚动距离
    final currentScroll = _scrollController.position.pixels;  // 当前滚动位置

    // 当滚动到底部附近(50px缓冲)且没有正在加载时,触发加载更多
    if (currentScroll >= maxScroll - 50 && !_isLoadingMore) {
      _loadMore();
    }
  }

  // 加载更多数据的异步函数
  Future<void> _loadMore() async {
    setState(() {
      _isLoadingMore = true;  // 设置加载状态
      _currentPage++;  // 页码增加
    });

    // 通过视图模型加载下一页数据
    await context.read<CollectListPageViewModel>().loadCollectList(_currentPage);

    setState(() {
      _isLoadingMore = false;  // 重置加载状态
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("我的收藏")),  // 页面标题
      body: Consumer<CollectListPageViewModel>(
        builder: (context, vm, _) {
          final collectList = vm.collectDetailList;  // 从视图模型获取收藏列表

          // 使用CustomScrollView实现复杂滚动布局
          return CustomScrollView(
            controller: _scrollController,  // 绑定滚动控制器
            slivers: [
              // 如果列表为空且不在加载中,显示加载指示器
              if (collectList.isEmpty && !_isLoadingMore)
                SliverFillRemaining(
                  child: const Center(child: CircularProgressIndicator()),
                )
              else ...[
                // 列表顶部添加8像素间距
                SliverToBoxAdapter(child: const SizedBox(height: 8)),
                // 收藏项列表主体
                SliverList(
                  delegate: SliverChildBuilderDelegate(
                        (context, index) {
                      // 使用CollectItemLayout组件渲染每个收藏项
                      return CollectItemLayout(
                        collectDetail: collectList[index],
                      );
                    },
                    childCount: collectList.length,  // 列表项数量
                  ),
                ),
                // 底部加载指示器(加载时显示)
                SliverToBoxAdapter(
                  child: _isLoadingMore
                      ? const Padding(
                    padding: EdgeInsets.all(16.0),
                    child: Center(child: CircularProgressIndicator()),
                  )
                      : const SizedBox.shrink(),  // 不加载时隐藏
                ),
              ]
            ],
          );
        },
      ),
    );
  }
}

记得要实现 routes 中我的收藏列表界面的配置,至此我们完成了我的收藏列表界面的设计,有些地方还是值得优化的,例如列表为空的时候,这里作者不进行详细的设计了,大致实现ui如下图所示

在这里插入图片描述

三、文章详情界面

接下来我们要实现文字详情界面,包括我们首页、体系、项目、导航、我的收藏列表、搜索,这些界面都需要进行查看文章详情,实现起来非常简单,我们使用 webview 来直接打开文章详情就行了

pubspec.yaml

webview_flutter: ^3.0.0

pages/detail/article_detail_page.dart

// 文章详情页面(直接使用WebView展示)
class ArticleDetailPage extends StatefulWidget {
  final String link; // 接收外部传入的文章链接
  final String title;

  const ArticleDetailPage({super.key, required this.link, required this.title});

  
  State<ArticleDetailPage> createState() => _ArticleDetailPageState();
}

class _ArticleDetailPageState extends State<ArticleDetailPage> {


  
  void initState() {
    super.initState();
    if (Platform.isAndroid) WebView.platform = AndroidWebView();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: WebView(
        initialUrl: widget.link,
      ),
    );
  }
}

app/routes.dart

/// 路由表及跳转管理
class AppRoutes {
  static final routes = <String, WidgetBuilder>{
    RoutesConstants.login: (_) => LoginRegisterPage(),
    RoutesConstants.home: (_) => HomePage(),
    RoutesConstants.treeChild: (context) {
      // 从ModalRoute获取参数
      final args =
          ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
      return TreePageChild(
        chapterId: args['chapterId'],
        childId: args['childId'],
      );
    },
    RoutesConstants.collectList: (_) => CollectListPage(),
    RoutesConstants.linkDetail: (context) {
      final args =
          ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
      return ArticleDetailPage(link: args['link'], title: args['title'],);
    }
  };
}

接着我们给我们对应列表添加点击事件,我这里就只添加一次就行了,其余列表添加点击事件希望读者自己能处理哈,点击事件如下所示

Navigator.pushNamed(
                              context, RoutesConstants.linkDetail,
                              arguments: {
                                'link': collectList[index].link,
                                'title': collectList[index].title,
                              });

至此,文章详情界面就完成了

四、搜索界面

接下来我们要实现最后一个功能,搜索界面

services/api_service.dart

static Future<Map<String, dynamic>> search({
    required int page,
    required int key,
  }) async {
    final url = Uri.parse(
        "${ApiConstants.baseUrl}${ApiConstants.searchForKeyword}$page/json");
    
    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: {
        'k': key,
      },
    );

    return _handleResponse(response);
  }

pages/search/search_page_view_mode.dart

class SearchPageViewModel extends ChangeNotifier {
  final userSearchController = TextEditingController();

  // 文章列表数据(私有)
  List<Article> _articleList = [];

  // 对外暴露文章列表
  List<Article> get articleList => _articleList;

  Future<void> search(int page) async {
    // 调用 API 获取文章列表数据
    final response = await ApiService.search(
      page: page,
      key: userSearchController.text.trim(),
    );

    // 解析响应数据中的文章列表
    final newList = ArticleListResponse.fromJson(response).data.datas;

    if (page == 0) {
      // 如果是第一页,重置整个文章列表
      _articleList = newList;
    } else {
      // 否则追加到原有列表后
      _articleList.addAll(newList);
    }

    // 通知 UI 更新
    notifyListeners();
  }

  // 收藏文章(传入文章 ID),返回是否成功
  Future<bool> collect(int id) async {
    final response = await ApiService.collect(id);
    final result = EmptyResponse.fromJson(response);
    return result.success;
  }

  // 取消收藏文章(传入文章 ID),返回是否成功
  Future<bool> uncollect(int id) async {
    final response = await ApiService.uncollect(id);
    final result = EmptyResponse.fromJson(response);
    return result.success;
  }
}

pages/search/search_page.dart


// 搜索页面外壳组件(Stateless),用于提供 ViewModel 注入
class SearchPage extends StatelessWidget {
  const SearchPage({super.key});

  
  Widget build(BuildContext context) {
    // 使用 Provider 注入 SearchPageViewModel
    return ChangeNotifierProvider<SearchPageViewModel>(
      create: (_) => SearchPageViewModel(),
      child: _SearchPageBody(), // 主页面主体
    );
  }
}

// 搜索页面的具体 UI 和交互逻辑
class _SearchPageBody extends StatefulWidget {
  
  _SearchPageState createState() => _SearchPageState();
}

class _SearchPageState extends State<_SearchPageBody>
    with BasePage<_SearchPageBody> {
  final ScrollController _scrollController = ScrollController(); // 控制列表滚动
  int _page = 0;               // 当前分页页码
  bool _isLoadingMore = false; // 是否正在加载更多数据,避免重复加载

  
  void initState() {
    // 注册滑动监听器,用于实现滑动到底部自动加载更多
    _scrollController.addListener(_onScroll);
  }

  
  void dispose() {
    // 页面销毁时释放滚动控制器资源
    _scrollController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    // 获取 ViewModel 实例,监听数据变化以触发重建
    final vm = context.watch<SearchPageViewModel>();

    return Scaffold(
      appBar: AppBar(title: const Text('搜索界面')),
      body: Column(
        children: [
          // 搜索栏区域
          Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                // 输入框
                Expanded(
                  child: TextField(
                    controller: vm.userSearchController, // 输入控制器
                    decoration: const InputDecoration(
                      hintText: '请输入搜索内容',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 10),
                // 搜索按钮
                ElevatedButton(
                  onPressed: () {
                    vm.search(_page); // 点击搜索,调用 ViewModel 的搜索方法
                  },
                  child: const Text('搜索'),
                ),
              ],
            ),
          ),
          // 搜索结果列表
          Expanded(
            child: ListView.separated(
              controller: _scrollController, // 绑定滚动控制器
              itemCount: vm.articleList.length, // 数据项数量
              separatorBuilder: (_, __) => const Divider(height: 1), // 分隔线
              itemBuilder: (_, i) {
                return ArticleItemLayout(
                  article: vm.articleList[i], // 单篇文章数据
                  onCollectTap: () {
                    _onCollectClick(vm.articleList[i]); // 收藏按钮点击事件
                  },
                  onTap: () {
                    // 点击跳转详情页
                    Navigator.pushNamed(context, RoutesConstants.linkDetail,
                        arguments: {
                          'link': vm.articleList[i].link,
                          'title': vm.articleList[i].title,
                        });
                  },
                  showCollectBtn: true, // 是否显示收藏按钮
                );
              },
            ),
          )
        ],
      ),
    );
  }

  // 处理文章的收藏/取消收藏点击事件
  _onCollectClick(Article article) async {
    bool collected = article.collect;

    // 根据当前收藏状态调用不同接口
    bool isSuccess = await (!collected
        ? context.read<SearchPageViewModel>().collect(article.id) // 收藏
        : context.read<SearchPageViewModel>().uncollect(article.id)); // 取消收藏

    if (isSuccess) {
      // 接口调用成功,更新状态并提示用户
      ToastUtils.showToast(context, collected ? "取消收藏!" : "收藏成功!");
      article.collect = !article.collect; // 本地切换收藏状态
    } else {
      // 接口调用失败,弹出提示
      ToastUtils.showToast(context, collected ? "取消收藏失败 -- " : "收藏失败 -- ");
    }
  }

  // 滚动监听器,用于检测是否接近底部
  void _onScroll() {
    final maxScroll = _scrollController.position.maxScrollExtent; // 最大滚动位置
    final currentScroll = _scrollController.position.pixels;      // 当前滚动位置

    // 若已接近底部,且未处于加载中状态,则加载更多数据
    if (currentScroll >= maxScroll - 50 && !_isLoadingMore) {
      _loadMore();
    }
  }

  /// 加载更多文章数据(分页)
  Future<void> _loadMore() async {
    _isLoadingMore = true; // 标记为加载中
    _page++; // 页码 +1
    await context.read<SearchPageViewModel>().search(_page); // 调用搜索
    _isLoadingMore = false; // 重置加载状态
  }
}

至此我们完成了搜索界面的设计,读者们记得在route表中添加搜索界面,以及在首页搜索按钮中添加点击跳转搜索界面。

在这里插入图片描述

至此Flutter版的玩安卓项目就完成了,还有很多细节没处理好,希望读者能够自行去优化哈~~,最后再次感谢大佬提供的API 玩Android 开放API-玩Android - wanandroid.com

项目地址:zengjinghong/wananadroid_flutter


网站公告

今日签到

点亮在社区的每一天
去签到