第2章:API 封装与搜索功能实现
从零开始构建 GitCode 口袋工具 - 实现网络请求和搜索
📚 本章目标
在本章中,你将学习:
- 创建 GitCode API 客户端
- 实现数据模型类
- 实现用户搜索功能
- 实现仓库搜索功能
- 创建用户列表和仓库列表页面
- 实现下拉刷新和上拉加载
第一步:创建 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 基础地址connectTimeout和receiveTimeout: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:头像地址(必需)name、htmlUrl、createdAt:可选字段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_token、q(关键字)、per_page、page - 使用
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 测试步骤
-
获取 Access Token:
- 访问 https://gitcode.com
- 登录后进入设置 → 访问令牌
- 创建新令牌并复制
-
测试用户搜索:
- 进入"搜索"页面
- 选择"用户"模式
- 输入关键字(如:
flutter) - 输入 Access Token
- 点击"开始搜索"
- 验证显示搜索结果
- 点击"查看全部"进入列表页
- 测试下拉刷新
- 滚动到底部测试上拉加载
-
测试仓库搜索:
- 切换到"仓库"模式
- 重复上述步骤
本章总结
🎉 恭喜!你已经完成了第二章的学习。
你学到了什么
- ✅ 创建 API 客户端 - 使用 Dio 封装网络请求
- ✅ 数据模型设计 - fromJson 工厂构造函数
- ✅ 错误处理 - 自定义异常类和分层错误处理
- ✅ 实现搜索 API - 用户搜索和仓库搜索
- ✅ 下拉刷新 - SmartRefresher 组件使用
- ✅ 上拉加载 - 分页加载更多数据
- ✅ 状态管理 - 加载、错误、空数据状态
项目结构更新
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 组件
准备好继续了吗?让我们进入第三章! 🚀

被折叠的 条评论
为什么被折叠?



