【开源鸿蒙跨平台开发--3.2】GitCode口袋工具API 封装与搜索功能实现

第2章:API 封装与搜索功能实现

从零开始构建 GitCode 口袋工具 - 实现网络请求和搜索

📚 本章目标

在本章中,你将学习:

  1. 创建 GitCode API 客户端
  2. 实现数据模型类
  3. 实现用户搜索功能
  4. 实现仓库搜索功能
  5. 创建用户列表和仓库列表页面
  6. 实现下拉刷新和上拉加载

第一步:创建 API 客户端基础框架

1.1 创建 API 客户端文件

创建 lib/core/gitcode_api.dart

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

/// GitCode API 异常类
class GitCodeApiException implements Exception {
  const GitCodeApiException(this.message);
  
  final String message;
  
  
  String toString() => 'GitCodeApiException: $message';
}

/// GitCode API 客户端
class GitCodeApiClient {
  GitCodeApiClient({Dio? dio})
      : _dio = dio ??
            Dio(
              BaseOptions(
                baseUrl: 'https://api.gitcode.com/api/v5',
                connectTimeout: const Duration(seconds: 5),
                receiveTimeout: const Duration(seconds: 5),
              ),
            );

  final Dio _dio;

  /// 构建请求头
  Map<String, String> _buildHeaders(String? personalToken) {
    return {
      if (personalToken != null && personalToken.isNotEmpty)
        'Authorization': 'Bearer $personalToken',
    };
  }
}

代码说明

  • GitCodeApiException:自定义异常类,用于包装 API 错误
  • GitCodeApiClient:API 客户端,使用 Dio 发送 HTTP 请求
  • baseUrl:GitCode API v5 基础地址
  • connectTimeoutreceiveTimeout:5 秒超时
  • _buildHeaders:构建请求头,支持 Bearer Token 认证

第二步:创建数据模型

2.1 用户搜索模型

lib/core/gitcode_api.dart 文件末尾添加:

/// 搜索用户结果模型
class GitCodeSearchUser {
  const GitCodeSearchUser({
    required this.login,
    required this.avatarUrl,
    this.name,
    this.htmlUrl,
    this.createdAt,
  });

  final String login;        // 登录名
  final String avatarUrl;    // 头像 URL
  final String? name;        // 显示名称
  final String? htmlUrl;     // 主页链接
  final String? createdAt;   // 创建时间

  /// 从 JSON 创建对象
  factory GitCodeSearchUser.fromJson(Map<String, dynamic> json) {
    return GitCodeSearchUser(
      login: json['login'] as String? ?? '',
      avatarUrl: json['avatar_url'] as String? ?? '',
      name: json['name'] as String?,
      htmlUrl: json['html_url'] as String?,
      createdAt: json['created_at'] as String?,
    );
  }
}

代码说明

  • login:用户登录名(必需)
  • avatarUrl:头像地址(必需)
  • namehtmlUrlcreatedAt:可选字段
  • fromJson:工厂构造函数,从 JSON 创建对象

2.2 仓库搜索模型

继续在文件末尾添加:

/// 仓库模型
class GitCodeRepository {
  const GitCodeRepository({
    required this.fullName,
    required this.webUrl,
    this.description,
    this.language,
    this.updatedAt,
    this.stars,
    this.forks,
    this.watchers,
    this.ownerLogin,
    this.isPrivate,
    this.id,
    this.projectId,
  });

  final String fullName;     // 完整名称(owner/repo)
  final String webUrl;       // Web URL
  final String? description; // 描述
  final String? language;    // 主要语言
  final String? updatedAt;   // 更新时间
  final int? stars;          // Star 数
  final int? forks;          // Fork 数
  final int? watchers;       // Watch 数
  final String? ownerLogin;  // 所有者
  final bool? isPrivate;     // 是否私有
  final int? id;             // 仓库 ID
  final int? projectId;      // 项目 ID

  factory GitCodeRepository.fromJson(Map<String, dynamic> json) {
    return GitCodeRepository(
      fullName: json['full_name'] as String? ?? json['path_with_namespace'] as String? ?? '',
      webUrl: json['web_url'] as String? ?? json['html_url'] as String? ?? '',
      description: json['description'] as String?,
      language: json['language'] as String?,
      updatedAt: json['updated_at'] as String?,
      stars: _safeInt(json['stargazers_count'] ?? json['star_count']),
      forks: _safeInt(json['forks_count'] ?? json['forks']),
      watchers: _safeInt(json['watchers_count'] ?? json['watchers']),
      ownerLogin: (json['owner'] as Map<String, dynamic>?)?['login'] as String?,
      isPrivate: _safeBool(json['private'] ?? json['visibility'] == 'private'),
      id: _safeInt(json['id']),
      projectId: _safeInt(json['project_id']),
    );
  }
}

/// 安全地将 dynamic 转换为 int
int? _safeInt(dynamic value) {
  if (value == null) return null;
  if (value is int) return value;
  if (value is String) return int.tryParse(value);
  return null;
}

/// 安全地将 dynamic 转换为 bool
bool? _safeBool(dynamic value) {
  if (value == null) return null;
  if (value is bool) return value;
  if (value is int) return value != 0;
  if (value is String) {
    return value == '1' || value.toLowerCase() == 'true';
  }
  return null;
}

第三步:实现搜索用户 API

3.1 添加搜索用户方法

GitCodeApiClient 类中添加:

/// 搜索用户
Future<List<GitCodeSearchUser>> searchUsers({
  required String keyword,
  required String personalToken,
  int perPage = 10,
  int page = 1,
}) async {
  try {
    debugPrint('搜索用户: $keyword, page: $page');
    
    final response = await _dio.get(
      '/search/users',
      queryParameters: {
        'access_token': personalToken,
        'q': keyword.trim(),
        'per_page': perPage.clamp(1, 50),
        'page': page.clamp(1, 100),
      },
      options: Options(
        headers: _buildHeaders(personalToken),
        validateStatus: (status) => status != null && status < 500,
      ),
    );

    final statusCode = response.statusCode ?? 0;
    debugPrint('搜索用户响应状态码: $statusCode');

    if (statusCode == 200) {
      final data = response.data;
      if (data is Map<String, dynamic>) {
        final items = data['items'] as List<dynamic>?;
        if (items != null) {
          return items
              .whereType<Map<String, dynamic>>()
              .map(GitCodeSearchUser.fromJson)
              .toList();
        }
      }
      return [];
    } else if (statusCode == 401) {
      throw const GitCodeApiException('未授权,请检查 Token 是否正确');
    } else if (statusCode == 404) {
      throw const GitCodeApiException('未找到用户');
    } else {
      throw GitCodeApiException('HTTP 错误: $statusCode');
    }
  } on DioException catch (error) {
    debugPrint('搜索用户 DioException: ${error.type}, ${error.message}');
    
    if (error.type == DioExceptionType.connectionTimeout ||
        error.type == DioExceptionType.receiveTimeout) {
      throw const GitCodeApiException('请求超时,请检查网络连接');
    }
    
    if (error.response?.statusCode == 401) {
      throw const GitCodeApiException('Token 无效或权限不足');
    }
    
    throw GitCodeApiException(error.message ?? '未知网络错误');
  } catch (error) {
    debugPrint('搜索用户异常: $error');
    throw GitCodeApiException('搜索失败: $error');
  }
}

代码说明

  • 使用 _dio.get 发送 GET 请求
  • 路径:/search/users
  • 参数:access_tokenq(关键字)、per_pagepage
  • 使用 clamp 限制参数范围
  • 返回 List<GitCodeSearchUser>
  • 完善的错误处理:401(未授权)、404(未找到)、超时等

第四步:实现搜索仓库 API

4.1 添加搜索仓库方法

GitCodeApiClient 类中继续添加:

/// 搜索仓库
Future<List<GitCodeRepository>> searchRepositories({
  required String keyword,
  required String personalToken,
  String? language,
  String? sort,
  String? order,
  int perPage = 10,
  int page = 1,
}) async {
  try {
    debugPrint('搜索仓库: $keyword, page: $page');
    
    final queryParameters = <String, dynamic>{
      'access_token': personalToken,
      'q': keyword.trim(),
      'per_page': perPage.clamp(1, 50),
      'page': page.clamp(1, 100),
    };
    
    // 添加可选参数
    if (language != null && language.isNotEmpty) {
      queryParameters['language'] = language;
    }
    if (sort != null && sort.isNotEmpty) {
      queryParameters['sort'] = sort;
    }
    if (order != null && order.isNotEmpty) {
      queryParameters['order'] = order;
    }

    final response = await _dio.get(
      '/search/repositories',
      queryParameters: queryParameters,
      options: Options(
        headers: _buildHeaders(personalToken),
        validateStatus: (status) => status != null && status < 500,
      ),
    );

    final statusCode = response.statusCode ?? 0;
    debugPrint('搜索仓库响应状态码: $statusCode');

    if (statusCode == 200) {
      final data = response.data;
      if (data is Map<String, dynamic>) {
        final items = data['items'] as List<dynamic>?;
        if (items != null) {
          return items
              .whereType<Map<String, dynamic>>()
              .map(GitCodeRepository.fromJson)
              .toList();
        }
      }
      return [];
    } else if (statusCode == 401) {
      throw const GitCodeApiException('未授权,请检查 Token 是否正确');
    } else if (statusCode == 404) {
      throw const GitCodeApiException('未找到仓库');
    } else {
      throw GitCodeApiException('HTTP 错误: $statusCode');
    }
  } on DioException catch (error) {
    debugPrint('搜索仓库 DioException: ${error.type}');
    
    if (error.type == DioExceptionType.connectionTimeout ||
        error.type == DioExceptionType.receiveTimeout) {
      throw const GitCodeApiException('请求超时,请检查网络连接');
    }
    
    throw GitCodeApiException(error.message ?? '未知网络错误');
  } catch (error) {
    debugPrint('搜索仓库异常: $error');
    throw GitCodeApiException('搜索失败: $error');
  }
}

第五步:更新搜索页面实现真实搜索

5.1 修改 search_page.dart

打开 lib/pages/main_navigation/search_page.dart,添加导入:

import 'package:flutter/material.dart';
import '../../core/gitcode_api.dart';  // 添加这行

_SearchPageState 类中添加变量:

class _SearchPageState extends State<SearchPage> {
  final _client = GitCodeApiClient();  // 添加 API 客户端
  final _keywordController = TextEditingController();
  final _tokenController = TextEditingController();
  
  SearchMode _searchMode = SearchMode.user;
  bool _tokenObscured = true;
  
  // 添加搜索结果相关变量
  bool _isSearching = false;
  String? _errorMessage;
  List<GitCodeSearchUser> _userResults = [];
  List<GitCodeRepository> _repoResults = [];

修改 _performSearch 方法:

/// 执行搜索
Future<void> _performSearch() async {
  final keyword = _keywordController.text.trim();
  final token = _tokenController.text.trim();
  
  // 输入验证
  if (keyword.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('请输入搜索关键字')),
    );
    return;
  }
  
  if (token.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('请输入 Access Token')),
    );
    return;
  }
  
  // 开始搜索
  setState(() {
    _isSearching = true;
    _errorMessage = null;
  });
  
  try {
    if (_searchMode == SearchMode.user) {
      // 搜索用户
      final users = await _client.searchUsers(
        keyword: keyword,
        personalToken: token,
        perPage: 3,  // 预览只显示 3 条
      );
      
      setState(() {
        _userResults = users;
        _isSearching = false;
      });
      
      if (users.isEmpty) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('未找到用户')),
        );
      }
    } else {
      // 搜索仓库
      final repos = await _client.searchRepositories(
        keyword: keyword,
        personalToken: token,
        perPage: 3,
      );
      
      setState(() {
        _repoResults = repos;
        _isSearching = false;
      });
      
      if (repos.isEmpty) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('未找到仓库')),
        );
      }
    }
  } on GitCodeApiException catch (e) {
    setState(() {
      _errorMessage = e.message;
      _isSearching = false;
    });
    
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(e.message)),
    );
  }
}

5.2 添加搜索结果展示

build 方法的 Column 中,_buildUsageTips(theme) 之前添加:

// 搜索结果
if (_isSearching)
  const Center(
    child: Padding(
      padding: EdgeInsets.all(32),
      child: CircularProgressIndicator(),
    ),
  )
else if (_errorMessage != null)
  _buildErrorView(theme)
else if (_searchMode == SearchMode.user && _userResults.isNotEmpty)
  _buildUserResults(theme)
else if (_searchMode == SearchMode.repo && _repoResults.isNotEmpty)
  _buildRepoResults(theme),

const SizedBox(height: 16),

添加结果展示方法:

/// 用户搜索结果
Widget _buildUserResults(ThemeData theme) {
  return Card(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                '搜索结果(${_userResults.length})',
                style: theme.textTheme.titleMedium,
              ),
              TextButton(
                onPressed: () {
                  // TODO: 跳转到完整列表页
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('即将跳转到用户列表')),
                  );
                },
                child: const Text('查看全部'),
              ),
            ],
          ),
        ),
        ...List.generate(_userResults.length, (index) {
          final user = _userResults[index];
          return ListTile(
            leading: CircleAvatar(
              backgroundImage: NetworkImage(user.avatarUrl),
            ),
            title: Text(user.name ?? user.login),
            subtitle: Text('@${user.login}'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              // TODO: 跳转到用户详情
            },
          );
        }),
      ],
    ),
  );
}

/// 仓库搜索结果
Widget _buildRepoResults(ThemeData theme) {
  return Card(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                '搜索结果(${_repoResults.length})',
                style: theme.textTheme.titleMedium,
              ),
              TextButton(
                onPressed: () {
                  // TODO: 跳转到完整列表页
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('即将跳转到仓库列表')),
                  );
                },
                child: const Text('查看全部'),
              ),
            ],
          ),
        ),
        ...List.generate(_repoResults.length, (index) {
          final repo = _repoResults[index];
          return ListTile(
            leading: Icon(
              repo.isPrivate == true ? Icons.lock : Icons.folder,
              color: theme.colorScheme.primary,
            ),
            title: Text(repo.fullName),
            subtitle: repo.description != null 
                ? Text(
                    repo.description!,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  )
                : null,
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              // TODO: 跳转到仓库详情
            },
          );
        }),
      ],
    ),
  );
}

/// 错误视图
Widget _buildErrorView(ThemeData theme) {
  return Card(
    child: Padding(
      padding: const EdgeInsets.all(24),
      child: Column(
        children: [
          Icon(
            Icons.error_outline,
            size: 48,
            color: theme.colorScheme.error,
          ),
          const SizedBox(height: 16),
          Text(
            _errorMessage ?? '搜索失败',
            style: TextStyle(color: theme.colorScheme.error),
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 16),
          OutlinedButton(
            onPressed: _performSearch,
            child: const Text('重试'),
          ),
        ],
      ),
    ),
  );
}

第六步:创建用户列表页面

6.1 创建用户列表页文件

创建 lib/pages/user_list_page.dart

import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../core/gitcode_api.dart';

class UserListPage extends StatefulWidget {
  const UserListPage({
    super.key,
    required this.keyword,
    required this.token,
  });

  final String keyword;
  final String token;

  
  State<UserListPage> createState() => _UserListPageState();
}

class _UserListPageState extends State<UserListPage> {
  final _client = GitCodeApiClient();
  final _refreshController = RefreshController();
  
  List<GitCodeSearchUser> _users = [];
  int _currentPage = 1;
  final int _perPage = 20;
  bool _hasMore = true;
  bool _isLoading = false;
  String? _errorMessage;

  
  void initState() {
    super.initState();
    _loadUsers(refresh: true);
  }

  
  void dispose() {
    _refreshController.dispose();
    super.dispose();
  }

  /// 加载用户数据
  Future<void> _loadUsers({bool refresh = false}) async {
    if (_isLoading) return;

    if (refresh) {
      _currentPage = 1;
      _hasMore = true;
      _users.clear();
    }

    if (!_hasMore) {
      _refreshController.loadNoData();
      return;
    }

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

    try {
      final users = await _client.searchUsers(
        keyword: widget.keyword,
        personalToken: widget.token,
        perPage: _perPage,
        page: _currentPage,
      );

      setState(() {
        if (refresh) {
          _users = users;
        } else {
          _users.addAll(users);
        }
        _hasMore = users.length >= _perPage;
        _currentPage++;
        _isLoading = false;
      });

      if (refresh) {
        _refreshController.refreshCompleted();
      } else {
        _hasMore
            ? _refreshController.loadComplete()
            : _refreshController.loadNoData();
      }
    } on GitCodeApiException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isLoading = false;
      });

      refresh
          ? _refreshController.refreshFailed()
          : _refreshController.loadFailed();
    }
  }

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('用户搜索: ${widget.keyword}'),
      ),
      body: _buildBody(theme),
    );
  }

  Widget _buildBody(ThemeData theme) {
    // 加载中(首次)
    if (_isLoading && _users.isEmpty && _errorMessage == null) {
      return const Center(child: CircularProgressIndicator());
    }

    // 错误状态
    if (_errorMessage != null && _users.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
            const SizedBox(height: 16),
            Text(_errorMessage!, style: TextStyle(color: Colors.red[700])),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => _loadUsers(refresh: true),
              child: const Text('重试'),
            ),
          ],
        ),
      );
    }

    // 空状态
    if (_users.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.person_off, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            Text('未找到用户', style: TextStyle(color: Colors.grey[600])),
          ],
        ),
      );
    }

    // 列表
    return SmartRefresher(
      controller: _refreshController,
      enablePullDown: true,
      enablePullUp: _hasMore,
      header: const ClassicHeader(
        refreshingText: '刷新中...',
        completeText: '刷新完成',
        idleText: '下拉刷新',
        releaseText: '释放刷新',
      ),
      footer: const ClassicFooter(
        loadingText: '加载中...',
        noDataText: '没有更多数据了',
        idleText: '上拉加载更多',
        canLoadingText: '释放加载',
      ),
      onRefresh: () => _loadUsers(refresh: true),
      onLoading: () => _loadUsers(refresh: false),
      child: ListView.builder(
        itemCount: _users.length,
        itemBuilder: (context, index) {
          final user = _users[index];
          return Card(
            margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: ListTile(
              leading: CircleAvatar(
                backgroundImage: NetworkImage(user.avatarUrl),
              ),
              title: Text(user.name ?? user.login),
              subtitle: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('@${user.login}'),
                  if (user.createdAt != null)
                    Text(
                      '加入于 ${user.createdAt!.substring(0, 10)}',
                      style: theme.textTheme.bodySmall,
                    ),
                ],
              ),
              trailing: const Icon(Icons.chevron_right),
              onTap: () {
                // TODO: 跳转到用户详情
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('点击了 ${user.login}')),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

代码说明

  • 使用 SmartRefresher 实现下拉刷新和上拉加载
  • _currentPage_hasMore 管理分页状态
  • 完整的状态处理:加载中、错误、空数据、成功
  • RefreshController 需要在 dispose 中释放

6.2 更新搜索页面跳转

修改 search_page.dart 中的 _buildUserResults 方法:

TextButton(
  onPressed: () {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => UserListPage(
          keyword: _keywordController.text.trim(),
          token: _tokenController.text.trim(),
        ),
      ),
    );
  },
  child: const Text('查看全部'),
),

同时添加导入:

import '../user_list_page.dart';  // 在文件顶部添加

第七步:创建仓库列表页面

7.1 创建仓库列表页文件

创建 lib/pages/repository_list_page.dart

import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../core/gitcode_api.dart';

class RepositoryListPage extends StatefulWidget {
  const RepositoryListPage({
    super.key,
    required this.keyword,
    required this.token,
  });

  final String keyword;
  final String token;

  
  State<RepositoryListPage> createState() => _RepositoryListPageState();
}

class _RepositoryListPageState extends State<RepositoryListPage> {
  final _client = GitCodeApiClient();
  final _refreshController = RefreshController();
  
  List<GitCodeRepository> _repositories = [];
  int _currentPage = 1;
  final int _perPage = 20;
  bool _hasMore = true;
  bool _isLoading = false;
  String? _errorMessage;

  
  void initState() {
    super.initState();
    _loadRepositories(refresh: true);
  }

  
  void dispose() {
    _refreshController.dispose();
    super.dispose();
  }

  Future<void> _loadRepositories({bool refresh = false}) async {
    if (_isLoading) return;

    if (refresh) {
      _currentPage = 1;
      _hasMore = true;
      _repositories.clear();
    }

    if (!_hasMore) {
      _refreshController.loadNoData();
      return;
    }

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

    try {
      final repos = await _client.searchRepositories(
        keyword: widget.keyword,
        personalToken: widget.token,
        perPage: _perPage,
        page: _currentPage,
      );

      setState(() {
        if (refresh) {
          _repositories = repos;
        } else {
          _repositories.addAll(repos);
        }
        _hasMore = repos.length >= _perPage;
        _currentPage++;
        _isLoading = false;
      });

      if (refresh) {
        _refreshController.refreshCompleted();
      } else {
        _hasMore
            ? _refreshController.loadComplete()
            : _refreshController.loadNoData();
      }
    } on GitCodeApiException catch (e) {
      setState(() {
        _errorMessage = e.message;
        _isLoading = false;
      });

      refresh
          ? _refreshController.refreshFailed()
          : _refreshController.loadFailed();
    }
  }

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('仓库搜索: ${widget.keyword}'),
      ),
      body: _buildBody(theme),
    );
  }

  Widget _buildBody(ThemeData theme) {
    if (_isLoading && _repositories.isEmpty && _errorMessage == null) {
      return const Center(child: CircularProgressIndicator());
    }

    if (_errorMessage != null && _repositories.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
            const SizedBox(height: 16),
            Text(_errorMessage!, style: TextStyle(color: Colors.red[700])),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => _loadRepositories(refresh: true),
              child: const Text('重试'),
            ),
          ],
        ),
      );
    }

    if (_repositories.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.folder_off, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            Text('未找到仓库', style: TextStyle(color: Colors.grey[600])),
          ],
        ),
      );
    }

    return SmartRefresher(
      controller: _refreshController,
      enablePullDown: true,
      enablePullUp: _hasMore,
      header: const ClassicHeader(
        refreshingText: '刷新中...',
        completeText: '刷新完成',
        idleText: '下拉刷新',
        releaseText: '释放刷新',
      ),
      footer: const ClassicFooter(
        loadingText: '加载中...',
        noDataText: '没有更多数据了',
        idleText: '上拉加载更多',
        canLoadingText: '释放加载',
      ),
      onRefresh: () => _loadRepositories(refresh: true),
      onLoading: () => _loadRepositories(refresh: false),
      child: ListView.builder(
        itemCount: _repositories.length,
        itemBuilder: (context, index) {
          final repo = _repositories[index];
          return Card(
            margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // 标题行
                  Row(
                    children: [
                      Icon(
                        repo.isPrivate == true ? Icons.lock : Icons.folder,
                        color: theme.colorScheme.primary,
                        size: 20,
                      ),
                      const SizedBox(width: 8),
                      Expanded(
                        child: Text(
                          repo.fullName,
                          style: theme.textTheme.titleMedium,
                        ),
                      ),
                    ],
                  ),
                  
                  // 描述
                  if (repo.description != null) ...[
                    const SizedBox(height: 8),
                    Text(
                      repo.description!,
                      style: theme.textTheme.bodyMedium,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ],
                  
                  // 统计信息
                  const SizedBox(height: 12),
                  Row(
                    children: [
                      if (repo.language != null) ...[
                        Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 8,
                            vertical: 4,
                          ),
                          decoration: BoxDecoration(
                            color: Colors.blue.withOpacity(0.1),
                            borderRadius: BorderRadius.circular(4),
                          ),
                          child: Text(
                            repo.language!,
                            style: const TextStyle(
                              fontSize: 12,
                              color: Colors.blue,
                            ),
                          ),
                        ),
                        const SizedBox(width: 12),
                      ],
                      if (repo.stars != null) ...[
                        const Icon(Icons.star, size: 16, color: Colors.amber),
                        const SizedBox(width: 4),
                        Text('${repo.stars}'),
                        const SizedBox(width: 12),
                      ],
                      if (repo.forks != null) ...[
                        const Icon(Icons.call_split, size: 16),
                        const SizedBox(width: 4),
                        Text('${repo.forks}'),
                      ],
                    ],
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

7.2 更新搜索页面跳转

修改 search_page.dart 中的 _buildRepoResults 方法:

TextButton(
  onPressed: () {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => RepositoryListPage(
          keyword: _keywordController.text.trim(),
          token: _tokenController.text.trim(),
        ),
      ),
    );
  },
  child: const Text('查看全部'),
),

同时添加导入:

import '../repository_list_page.dart';  // 在文件顶部添加

第八步:测试搜索功能

8.1 运行应用

flutter run

8.2 测试步骤

  1. 获取 Access Token

    • 访问 https://gitcode.com
    • 登录后进入设置 → 访问令牌
    • 创建新令牌并复制
  2. 测试用户搜索

    • 进入"搜索"页面
    • 选择"用户"模式
    • 输入关键字(如:flutter
    • 输入 Access Token
    • 点击"开始搜索"
    • 验证显示搜索结果
    • 点击"查看全部"进入列表页
    • 测试下拉刷新
    • 滚动到底部测试上拉加载
  3. 测试仓库搜索

    • 切换到"仓库"模式
    • 重复上述步骤

本章总结

🎉 恭喜!你已经完成了第二章的学习。

你学到了什么

  1. 创建 API 客户端 - 使用 Dio 封装网络请求
  2. 数据模型设计 - fromJson 工厂构造函数
  3. 错误处理 - 自定义异常类和分层错误处理
  4. 实现搜索 API - 用户搜索和仓库搜索
  5. 下拉刷新 - SmartRefresher 组件使用
  6. 上拉加载 - 分页加载更多数据
  7. 状态管理 - 加载、错误、空数据状态

项目结构更新

lib/
├── core/
│   ├── app_config.dart
│   └── gitcode_api.dart          ← 新增
├── pages/
│   ├── main_navigation/
│   │   ├── intro_page.dart
│   │   ├── search_page.dart      ← 更新
│   │   └── profile_page.dart
│   ├── user_list_page.dart        ← 新增
│   └── repository_list_page.dart  ← 新增
└── main.dart

下一章预告

在第三章中,我们将:

  • 🔨 实现用户详情页面
  • 🔨 实现仓库详情页面
  • 🔨 添加文件浏览功能
  • 🔨 创建可复用的 UI 组件

准备好继续了吗?让我们进入第三章! 🚀

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值