第4章:高级功能与项目完善
从零开始构建 GitCode 口袋工具 - 实现仓库动态、贡献者统计和我的仓库
📚 本章目标
在本章中,你将学习:
- 实现仓库动态事件功能
- 实现贡献者统计功能
- 添加"我的仓库"功能
- 创建可复用的 UI 组件
- 完善用户体验细节
- 本章总结
第一步:实现仓库动态功能
1.1 创建事件数据模型
打开 lib/core/gitcode_api.dart,在文件末尾添加:
/// 仓库事件模型
class RepoEvent {
const RepoEvent({
required this.id,
required this.type,
this.actor,
this.repo,
this.payload,
this.createdAt,
});
final String id;
final String type; // PushEvent, IssuesEvent, etc.
final EventActor? actor;
final EventRepo? repo;
final Map<String, dynamic>? payload;
final String? createdAt;
factory RepoEvent.fromJson(Map<String, dynamic> json) {
return RepoEvent(
id: json['id']?.toString() ?? '',
type: json['type'] as String? ?? '',
actor: json['actor'] != null
? EventActor.fromJson(json['actor'] as Map<String, dynamic>)
: null,
repo: json['repo'] != null
? EventRepo.fromJson(json['repo'] as Map<String, dynamic>)
: null,
payload: json['payload'] as Map<String, dynamic>?,
createdAt: json['created_at'] as String?,
);
}
}
/// 事件执行者
class EventActor {
const EventActor({
required this.login,
this.avatarUrl,
this.url,
});
final String login;
final String? avatarUrl;
final String? url;
factory EventActor.fromJson(Map<String, dynamic> json) {
return EventActor(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String?,
url: json['url'] as String?,
);
}
}
/// 事件仓库
class EventRepo {
const EventRepo({
required this.name,
this.url,
});
final String name;
final String? url;
factory EventRepo.fromJson(Map<String, dynamic> json) {
return EventRepo(
name: json['name'] as String? ?? '',
url: json['url'] as String?,
);
}
}
1.2 添加获取仓库事件方法
在 GitCodeApiClient 类中添加:
/// 获取仓库事件
Future<List<RepoEvent>> fetchRepositoryEvents(
String owner,
String repo, {
required String personalToken,
int perPage = 20,
int page = 1,
}) async {
try {
final encodedOwner = Uri.encodeComponent(owner.trim());
final encodedRepo = Uri.encodeComponent(repo.trim());
debugPrint('获取仓库事件: $owner/$repo');
final response = await _dio.get(
'/repos/$encodedOwner/$encodedRepo/events',
queryParameters: {
'access_token': personalToken,
'per_page': perPage.clamp(1, 100),
'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 List) {
return data
.whereType<Map<String, dynamic>>()
.map(RepoEvent.fromJson)
.toList();
}
return [];
} else if (statusCode == 404) {
throw const GitCodeApiException('仓库不存在或没有事件');
} else if (statusCode == 401) {
throw const GitCodeApiException('未授权');
} else {
throw GitCodeApiException('HTTP 错误: $statusCode');
}
} on DioException catch (error) {
debugPrint('获取仓库事件异常: ${error.type}');
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
throw const GitCodeApiException('请求超时');
}
throw GitCodeApiException(error.message ?? '网络错误');
} catch (error) {
if (error is GitCodeApiException) rethrow;
throw GitCodeApiException('获取仓库事件失败: $error');
}
}
1.3 创建仓库事件页面
创建 lib/pages/repository_events_page.dart:
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../core/gitcode_api.dart';
class RepositoryEventsPage extends StatefulWidget {
const RepositoryEventsPage({
super.key,
required this.owner,
required this.repo,
required this.token,
});
final String owner;
final String repo;
final String token;
State<RepositoryEventsPage> createState() => _RepositoryEventsPageState();
}
class _RepositoryEventsPageState extends State<RepositoryEventsPage> {
final _client = GitCodeApiClient();
final _refreshController = RefreshController();
List<RepoEvent> _events = [];
int _currentPage = 1;
bool _hasMore = true;
bool _isLoading = false;
String? _errorMessage;
void initState() {
super.initState();
_loadEvents(refresh: true);
}
void dispose() {
_refreshController.dispose();
super.dispose();
}
Future<void> _loadEvents({bool refresh = false}) async {
if (_isLoading) return;
if (refresh) {
_currentPage = 1;
_hasMore = true;
_events.clear();
}
if (!_hasMore) {
_refreshController.loadNoData();
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final events = await _client.fetchRepositoryEvents(
widget.owner,
widget.repo,
personalToken: widget.token,
perPage: 20,
page: _currentPage,
);
setState(() {
if (refresh) {
_events = events;
} else {
_events.addAll(events);
}
_hasMore = events.length >= 20;
_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.owner}/${widget.repo} - 动态'),
),
body: _buildBody(theme),
);
}
Widget _buildBody(ThemeData theme) {
if (_isLoading && _events.isEmpty && _errorMessage == null) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null && _events.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: () => _loadEvents(refresh: true),
child: const Text('重试'),
),
],
),
);
}
if (_events.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_busy, 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: () => _loadEvents(refresh: true),
onLoading: () => _loadEvents(refresh: false),
child: ListView.builder(
itemCount: _events.length,
itemBuilder: (context, index) {
final event = _events[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getEventColor(event.type),
child: Icon(
_getEventIcon(event.type),
color: Colors.white,
size: 20,
),
),
title: Text(_getEventDescription(event)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (event.actor != null)
Text('由 ${event.actor!.login}'),
if (event.createdAt != null)
Text(
_formatTime(event.createdAt!),
style: const TextStyle(fontSize: 12),
),
],
),
trailing: event.actor?.avatarUrl != null
? CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(event.actor!.avatarUrl!),
)
: null,
),
);
},
),
);
}
IconData _getEventIcon(String type) {
switch (type) {
case 'PushEvent':
return Icons.upload;
case 'IssuesEvent':
return Icons.bug_report;
case 'PullRequestEvent':
return Icons.merge;
case 'CreateEvent':
return Icons.add_circle;
case 'DeleteEvent':
return Icons.remove_circle;
case 'ForkEvent':
return Icons.call_split;
case 'WatchEvent':
return Icons.star;
case 'ReleaseEvent':
return Icons.new_releases;
default:
return Icons.event;
}
}
Color _getEventColor(String type) {
switch (type) {
case 'PushEvent':
return Colors.blue;
case 'IssuesEvent':
return Colors.red;
case 'PullRequestEvent':
return Colors.purple;
case 'CreateEvent':
return Colors.green;
case 'DeleteEvent':
return Colors.orange;
case 'ForkEvent':
return Colors.teal;
case 'WatchEvent':
return Colors.amber;
default:
return Colors.grey;
}
}
String _getEventDescription(RepoEvent event) {
switch (event.type) {
case 'PushEvent':
return '推送了代码';
case 'IssuesEvent':
final action = event.payload?['action'] ?? 'operated';
return '$action Issue';
case 'PullRequestEvent':
final action = event.payload?['action'] ?? 'operated';
return '$action Pull Request';
case 'CreateEvent':
return '创建了分支或标签';
case 'DeleteEvent':
return '删除了分支或标签';
case 'ForkEvent':
return 'Fork 了仓库';
case 'WatchEvent':
return 'Star 了仓库';
case 'ReleaseEvent':
return '发布了新版本';
default:
return event.type;
}
}
String _formatTime(String dateString) {
try {
final date = DateTime.parse(dateString);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24) return '${diff.inHours} 小时前';
if (diff.inDays < 7) return '${diff.inDays} 天前';
return '${date.year}-${date.month.toString().padLeft(2, '0')}'
'-${date.day.toString().padLeft(2, '0')}';
} catch (e) {
return dateString;
}
}
}
第二步:实现贡献者统计功能
2.1 创建贡献者数据模型
在 lib/core/gitcode_api.dart 文件末尾添加:
/// 贡献者统计模型
class ContributorStatistic {
const ContributorStatistic({
this.author,
this.total,
this.weeks,
});
final ContributorAuthor? author;
final int? total;
final List<WeeklyStatistic>? weeks;
factory ContributorStatistic.fromJson(Map<String, dynamic> json) {
return ContributorStatistic(
author: json['author'] != null
? ContributorAuthor.fromJson(json['author'] as Map<String, dynamic>)
: null,
total: _safeInt(json['total']),
weeks: (json['weeks'] as List<dynamic>?)
?.whereType<Map<String, dynamic>>()
.map(WeeklyStatistic.fromJson)
.toList(),
);
}
}
/// 贡献者作者
class ContributorAuthor {
const ContributorAuthor({
required this.login,
this.avatarUrl,
this.htmlUrl,
});
final String login;
final String? avatarUrl;
final String? htmlUrl;
factory ContributorAuthor.fromJson(Map<String, dynamic> json) {
return ContributorAuthor(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String?,
htmlUrl: json['html_url'] as String?,
);
}
}
/// 每周统计
class WeeklyStatistic {
const WeeklyStatistic({
this.week,
this.additions,
this.deletions,
this.commits,
});
final int? week; // Unix 时间戳
final int? additions; // 新增行数
final int? deletions; // 删除行数
final int? commits; // 提交次数
factory WeeklyStatistic.fromJson(Map<String, dynamic> json) {
return WeeklyStatistic(
week: _safeInt(json['w']),
additions: _safeInt(json['a']),
deletions: _safeInt(json['d']),
commits: _safeInt(json['c']),
);
}
}
2.2 添加获取贡献者统计方法
在 GitCodeApiClient 类中添加:
/// 获取仓库贡献者统计
Future<List<ContributorStatistic>> fetchContributorsStatistic(
String owner,
String repo, {
String? personalToken,
}) async {
try {
final encodedOwner = Uri.encodeComponent(owner.trim());
final encodedRepo = Uri.encodeComponent(repo.trim());
debugPrint('获取贡献者统计: $owner/$repo');
final response = await _dio.get(
'/repos/$encodedOwner/$encodedRepo/contributors/statistic',
queryParameters: {
if (personalToken != null && personalToken.isNotEmpty)
'access_token': personalToken,
},
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 List) {
return data
.whereType<Map<String, dynamic>>()
.map(ContributorStatistic.fromJson)
.toList();
}
return [];
} else if (statusCode == 404) {
// 404 时返回空列表而不是抛出异常
debugPrint('仓库没有贡献者统计数据 (404)');
return [];
} else if (statusCode == 401) {
throw const GitCodeApiException('未授权');
} else {
throw GitCodeApiException('HTTP 错误: $statusCode');
}
} on DioException catch (error) {
if (error.response?.statusCode == 404) {
debugPrint('仓库没有贡献者统计数据 (DioException 404)');
return [];
}
debugPrint('获取贡献者统计异常: ${error.type}');
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
throw const GitCodeApiException('请求超时');
}
throw GitCodeApiException(error.message ?? '网络错误');
} catch (error) {
if (error is GitCodeApiException) rethrow;
throw GitCodeApiException('获取贡献者统计失败: $error');
}
}
2.3 创建贡献者统计页面
创建 lib/pages/repository_contributors_page.dart:
import 'package:flutter/material.dart';
import '../core/gitcode_api.dart';
class RepositoryContributorsPage extends StatefulWidget {
const RepositoryContributorsPage({
super.key,
required this.owner,
required this.repo,
required this.token,
});
final String owner;
final String repo;
final String token;
State<RepositoryContributorsPage> createState() =>
_RepositoryContributorsPageState();
}
class _RepositoryContributorsPageState
extends State<RepositoryContributorsPage> {
final _client = GitCodeApiClient();
List<ContributorStatistic> _contributors = [];
bool _isLoading = true;
String? _errorMessage;
void initState() {
super.initState();
_loadContributors();
}
Future<void> _loadContributors() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final contributors = await _client.fetchContributorsStatistic(
widget.owner,
widget.repo,
personalToken: widget.token,
);
setState(() {
// 按总提交数排序
_contributors = contributors
..sort((a, b) => (b.total ?? 0).compareTo(a.total ?? 0));
_isLoading = false;
});
} on GitCodeApiException catch (e) {
setState(() {
// 404 时显示空状态而不是错误
if (e.message.contains('404')) {
_contributors = [];
_errorMessage = null;
} else {
_errorMessage = e.message;
}
_isLoading = false;
});
}
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('${widget.owner}/${widget.repo} - 贡献者'),
),
body: _buildBody(theme),
);
}
Widget _buildBody(ThemeData theme) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
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: _loadContributors,
child: const Text('重试'),
),
],
),
);
}
if (_contributors.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text('暂无贡献者数据', style: TextStyle(color: Colors.grey[600])),
],
),
);
}
return ListView.builder(
itemCount: _contributors.length,
itemBuilder: (context, index) {
final contributor = _contributors[index];
final author = contributor.author;
if (author == null) return const SizedBox.shrink();
// 计算统计数据
final totalAdditions = _getTotalAdditions(contributor);
final totalDeletions = _getTotalDeletions(contributor);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 排名
_buildRankBadge(index, theme),
const SizedBox(width: 16),
// 头像
CircleAvatar(
radius: 24,
backgroundImage: author.avatarUrl != null
? NetworkImage(author.avatarUrl!)
: null,
child: author.avatarUrl == null
? const Icon(Icons.person)
: null,
),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
author.login,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(
'提交',
contributor.total ?? 0,
Icons.commit,
Colors.blue,
),
_buildStatItem(
'新增',
totalAdditions,
Icons.add,
Colors.green,
),
_buildStatItem(
'删除',
totalDeletions,
Icons.remove,
Colors.red,
),
],
),
],
),
),
],
),
),
);
},
);
}
Widget _buildRankBadge(int index, ThemeData theme) {
final colors = [Colors.amber, Colors.grey, Colors.brown];
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: index < 3
? colors[index]
: theme.colorScheme.surfaceVariant,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: index < 3 ? Colors.white : Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
);
}
Widget _buildStatItem(String label, int value, IconData icon, Color color) {
return Column(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(height: 4),
Text(
value.toString(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
label,
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
);
}
int _getTotalAdditions(ContributorStatistic contributor) {
if (contributor.weeks == null) return 0;
return contributor.weeks!.fold<int>(
0,
(sum, week) => sum + (week.additions ?? 0),
);
}
int _getTotalDeletions(ContributorStatistic contributor) {
if (contributor.weeks == null) return 0;
return contributor.weeks!.fold<int>(
0,
(sum, week) => sum + (week.deletions ?? 0),
);
}
}
2.4 更新仓库详情页添加菜单
修改 lib/pages/repository_detail_page.dart,添加导入:
import 'repository_events_page.dart'; // 添加
import 'repository_contributors_page.dart'; // 添加
在 AppBar 的 actions 中添加菜单:
actions: [
IconButton(
icon: const Icon(Icons.folder_open),
tooltip: '浏览文件',
onPressed: () {
// ... 现有代码
},
),
// 添加弹出菜单
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'events') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepositoryEventsPage(
owner: widget.owner,
repo: widget.repo,
token: widget.token,
),
),
);
} else if (value == 'contributors') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepositoryContributorsPage(
owner: widget.owner,
repo: widget.repo,
token: widget.token,
),
),
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'events',
child: Row(
children: [
Icon(Icons.event),
SizedBox(width: 8),
Text('仓库动态'),
],
),
),
const PopupMenuItem(
value: 'contributors',
child: Row(
children: [
Icon(Icons.people),
SizedBox(width: 8),
Text('贡献者统计'),
],
),
),
],
),
],
第三步:实现"我的仓库"功能
3.1 添加获取用户仓库方法
在 GitCodeApiClient 类中添加:
/// 获取授权用户的仓库列表
Future<List<GitCodeRepository>> fetchUserRepositories({
required String personalToken,
String? type,
String? sort,
String? direction,
int perPage = 20,
int page = 1,
}) async {
try {
debugPrint('获取用户仓库');
final queryParameters = <String, dynamic>{
'access_token': personalToken,
'per_page': perPage.clamp(1, 100),
'page': page.clamp(1, 100),
};
if (type != null && type.isNotEmpty) {
queryParameters['type'] = type;
}
if (sort != null && sort.isNotEmpty) {
queryParameters['sort'] = sort;
}
if (direction != null && direction.isNotEmpty) {
queryParameters['direction'] = direction;
}
final response = await _dio.get(
'/user/repos',
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 List) {
return data
.whereType<Map<String, dynamic>>()
.map(GitCodeRepository.fromJson)
.toList();
}
return [];
} else if (statusCode == 401) {
throw const GitCodeApiException('Token 无效或权限不足');
} else {
throw GitCodeApiException('HTTP 错误: $statusCode');
}
} on DioException catch (error) {
debugPrint('获取用户仓库异常: ${error.type}');
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
throw const GitCodeApiException('请求超时');
}
throw GitCodeApiException(error.message ?? '网络错误');
} catch (error) {
if (error is GitCodeApiException) rethrow;
throw GitCodeApiException('获取用户仓库失败: $error');
}
}
3.2 创建我的仓库页面
创建 lib/pages/my_repositories_page.dart:
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../core/gitcode_api.dart';
import 'repository_detail_page.dart';
class MyRepositoriesPage extends StatefulWidget {
const MyRepositoriesPage({
super.key,
required this.token,
});
final String token;
State<MyRepositoriesPage> createState() => _MyRepositoriesPageState();
}
class _MyRepositoriesPageState extends State<MyRepositoriesPage> {
final _client = GitCodeApiClient();
final _refreshController = RefreshController();
List<GitCodeRepository> _repositories = [];
int _currentPage = 1;
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.fetchUserRepositories(
personalToken: widget.token,
perPage: 20,
page: _currentPage,
);
setState(() {
if (refresh) {
_repositories = repos;
} else {
_repositories.addAll(repos);
}
_hasMore = repos.length >= 20;
_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: const Text('我的仓库'),
),
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: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepositoryDetailPage(
owner: repo.ownerLogin ?? '',
repo: repo.fullName.split('/').last,
token: widget.token,
),
),
);
},
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}'),
],
],
),
],
),
),
),
);
},
),
);
}
}
3.3 更新我的页面
修改 lib/pages/main_navigation/profile_page.dart,添加导入:
import '../my_repositories_page.dart'; // 在文件顶部添加
在 _ProfilePageState 类中添加变量和方法:
class _ProfilePageState extends State<ProfilePage> {
final _tokenController = TextEditingController(); // 添加
bool _tokenObscured = true; // 添加
void dispose() {
_tokenController.dispose();
super.dispose();
}
// ... 现有代码
修改 _buildTokenSection 方法:
Widget _buildTokenSection(ThemeData theme) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.folder, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('我的仓库', style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 16),
TextField(
controller: _tokenController,
obscureText: _tokenObscured,
decoration: InputDecoration(
labelText: 'Access Token',
hintText: '输入你的 GitCode Token',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_tokenObscured
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() => _tokenObscured = !_tokenObscured);
},
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
final token = _tokenController.text.trim();
if (token.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入 Access Token')),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MyRepositoriesPage(token: token),
),
);
},
icon: const Icon(Icons.folder_open),
label: const Text('查看我的仓库'),
),
),
],
),
),
);
}
第四步:创建可复用 UI 组件
4.1 创建用户卡片组件
创建 lib/widgets/user_card.dart:
import 'package:flutter/material.dart';
import '../core/gitcode_api.dart';
class UserCard extends StatelessWidget {
const UserCard({
super.key,
required this.user,
this.onTap,
});
final GitCodeSearchUser user;
final VoidCallback? onTap;
Widget build(BuildContext context) {
final theme = Theme.of(context);
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: onTap,
),
);
}
}
4.2 创建仓库卡片组件
创建 lib/widgets/repository_card.dart:
import 'package:flutter/material.dart';
import '../core/gitcode_api.dart';
class RepositoryCard extends StatelessWidget {
const RepositoryCard({
super.key,
required this.repository,
this.onTap,
});
final GitCodeRepository repository;
final VoidCallback? onTap;
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
Icon(
repository.isPrivate == true ? Icons.lock : Icons.folder,
color: theme.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
repository.fullName,
style: theme.textTheme.titleMedium,
),
),
],
),
// 描述
if (repository.description != null) ...[
const SizedBox(height: 8),
Text(
repository.description!,
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
// 统计信息
const SizedBox(height: 12),
Row(
children: [
if (repository.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(
repository.language!,
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
),
),
),
const SizedBox(width: 12),
],
if (repository.stars != null) ...[
const Icon(Icons.star, size: 16, color: Colors.amber),
const SizedBox(width: 4),
Text('${repository.stars}'),
const SizedBox(width: 12),
],
if (repository.forks != null) ...[
const Icon(Icons.call_split, size: 16),
const SizedBox(width: 4),
Text('${repository.forks}'),
],
],
),
],
),
),
),
);
}
}
第五步:测试和优化
5.1 完整功能测试
flutter run
测试清单:
- ✅ 用户搜索和详情
- ✅ 仓库搜索和详情
- ✅ 文件浏览
- ✅ 仓库动态
- ✅ 贡献者统计
- ✅ 我的仓库
- ✅ 下拉刷新和上拉加载
5.2 性能优化建议
- 使用 const 构造函数:
const Icon(Icons.home)
const SizedBox(height: 16)
- 图片缓存:
// NetworkImage 自动缓存
CircleAvatar(backgroundImage: NetworkImage(url))
- 及时释放资源:
void dispose() {
_controller.dispose();
super.dispose();
}
本章总结
🎉 你已经完成了第4章的学习!
当前项目结构
lib/
├── core/
│ ├── app_config.dart # 应用配置
│ └── gitcode_api.dart # API 客户端(15+ 数据模型)
├── pages/
│ ├── main_navigation/
│ │ ├── intro_page.dart # 首页
│ │ ├── search_page.dart # 搜索页
│ │ └── profile_page.dart # 我的页面
│ ├── user_list_page.dart # 用户列表
│ ├── user_detail_page.dart # 用户详情
│ ├── repository_list_page.dart # 仓库列表
│ ├── repository_detail_page.dart # 仓库详情
│ ├── repository_files_page.dart # 文件浏览
│ ├── repository_events_page.dart # 仓库动态
│ ├── repository_contributors_page.dart # 贡献者统计
│ └── my_repositories_page.dart # 我的仓库
├── widgets/
│ ├── user_card.dart # 用户卡片组件
│ └── repository_card.dart # 仓库卡片组件
└── main.dart # 应用入口
已实现功能总结
| 功能 | 实现状态 | 页面数 |
|---|---|---|
| 搜索功能 | ✅ 已完成 | 2 (用户/仓库) |
| 详情展示 | ✅ 已完成 | 2 (用户/仓库) |
| 文件浏览 | ✅ 已完成 | 1 |
| 仓库动态 | ✅ 已完成 | 1 |
| 贡献者统计 | ✅ 已完成 | 1 |
| 我的仓库 | ✅ 已完成 | 1 |
| 下拉刷新 | ✅ 已完成 | 5 页面 |
技术亮点
- 完整的 API 封装 - 15+ 数据模型,10+ API 方法
- Material Design 3 - 现代化 UI 设计
- 分页加载 - SmartRefresher 实现
- 文件浏览 - 目录导航和文件识别
- 错误处理 - 完善的异常处理机制
- 状态管理 - 清晰的加载/错误/空数据状态
本章学到的知识点
Flutter 基础
- StatefulWidget 和 StatelessWidget
- State 管理
- 生命周期(initState、dispose)
- 导航(Navigator.push)
网络请求
- Dio 库使用
- HTTP GET 请求
- 请求头配置
- 超时控制
- 错误处理
UI 组件
- MaterialApp 和 Scaffold
- AppBar、NavigationBar
- ListView.builder
- Card、ListTile
- TextField、Button
- CircleAvatar、Icon
高级特性
- 下拉刷新和上拉加载
- PopScope 拦截返回键
- url_launcher 打开链接
- 图片加载和缓存
- 时间格式化
- 文件大小格式化
代码质量提升
1. 创建可复用组件
本章我们创建了 UserCard 和 RepositoryCard 两个可复用组件,减少了代码重复。
2. 统一的错误处理
在事件和贡献者统计功能中,我们实现了:
- 404 错误时显示空状态而非错误提示
- 详细的调试日志
- 友好的用户提示
3. 性能优化实践
- 使用
const构造函数 - 及时释放 Controller 资源
- 图片自动缓存
🎯 已掌握的技能
通过前4章的学习,你已经掌握:
✅ Flutter 基础
- StatefulWidget 和 StatelessWidget
- State 管理和生命周期
- 导航和路由
✅ 网络编程
- Dio 库使用
- API 封装和数据模型
- 错误处理机制
✅ UI 开发
- Material Design 3 应用
- 复杂页面构建
- 可复用组件设计
✅ 高级特性
- 下拉刷新和上拉加载
- 文件浏览和目录导航
- 时间格式化和数据展示
📝 待实现功能预告
在后续章节中,我们将继续完善项目:
- 🔜 热门项目展示 - 展示 GitCode 热门开源项目
- 🔜 我的组织管理 - 查看和管理加入的组织
- 🔜 高级筛选功能 - 按语言、时间等条件筛选
- 🔜 代码查看器 - 在线查看文件内容
- 🔜 主题切换 - 支持深色模式
- 🔜 本地缓存 - Token 和数据缓存
- 🔜 更多功能 - 持续扩展中…
🚀 下一章预告
在下一章中,我们将实现:
- 热门项目展示功能
- 按语言分类筛选
- 项目排序和搜索优化
- 首页内容完善
准备好继续学习了吗?让我们进入下一章! 🎓

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



