第3章:详情页面与文件浏览功能
从零开始构建 GitCode 口袋工具 - 实现详情展示和文件浏览
📚 本章目标
在本章中,你将学习:
- 实现用户详情页面
- 实现仓库详情页面
- 添加获取用户和仓库详情的 API
- 实现文件浏览功能(目录树)
- 添加文件相关的 API 和数据模型
- 实现目录导航和文件类型识别
第一步:添加用户详情 API
1.1 创建用户详情数据模型
打开 lib/core/gitcode_api.dart,在文件末尾添加:
/// 用户详情模型
class GitCodeUser {
const GitCodeUser({
required this.login,
required this.avatarUrl,
this.name,
this.bio,
this.htmlUrl,
this.publicRepos,
this.followers,
this.following,
this.createdAt,
});
final String login; // 登录名
final String avatarUrl; // 头像
final String? name; // 显示名称
final String? bio; // 个人简介
final String? htmlUrl; // 主页链接
final int? publicRepos; // 公开仓库数
final int? followers; // 粉丝数
final int? following; // 关注数
final String? createdAt; // 创建时间
factory GitCodeUser.fromJson(Map<String, dynamic> json) {
return GitCodeUser(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String? ?? '',
name: json['name'] as String?,
bio: json['bio'] as String?,
htmlUrl: json['html_url'] as String?,
publicRepos: _safeInt(json['public_repos']),
followers: _safeInt(json['followers']),
following: _safeInt(json['following']),
createdAt: json['created_at'] as String?,
);
}
}
1.2 添加获取用户详情方法
在 GitCodeApiClient 类中添加:
/// 获取用户详情
Future<GitCodeUser> fetchUser(
String username, {
String? personalToken,
}) async {
try {
final trimmed = username.trim();
if (trimmed.isEmpty) {
throw const GitCodeApiException('用户名不能为空');
}
debugPrint('获取用户详情: $trimmed');
final response = await _dio.get(
'/users/${Uri.encodeComponent(trimmed)}',
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 Map<String, dynamic>) {
return GitCodeUser.fromJson(data);
}
throw const GitCodeApiException('响应数据格式错误');
} else if (statusCode == 404) {
throw const GitCodeApiException('用户不存在');
} 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');
}
}
第二步:创建用户详情页面
2.1 创建用户详情页文件
创建 lib/pages/user_detail_page.dart:
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../core/gitcode_api.dart';
class UserDetailPage extends StatefulWidget {
const UserDetailPage({
super.key,
required this.username,
required this.token,
});
final String username;
final String token;
State<UserDetailPage> createState() => _UserDetailPageState();
}
class _UserDetailPageState extends State<UserDetailPage> {
final _client = GitCodeApiClient();
GitCodeUser? _user;
bool _isLoading = true;
String? _errorMessage;
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final user = await _client.fetchUser(
widget.username,
personalToken: widget.token,
);
setState(() {
_user = user;
_isLoading = false;
});
} on GitCodeApiException catch (e) {
setState(() {
_errorMessage = e.message;
_isLoading = false;
});
}
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.username),
),
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: _loadUser,
child: const Text('重试'),
),
],
),
);
}
if (_user == null) {
return const Center(child: Text('用户不存在'));
}
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 24),
// 头像
_buildAvatar(theme),
const SizedBox(height: 24),
// 基本信息
_buildBasicInfo(theme),
const SizedBox(height: 16),
// 统计数据
_buildStatistics(theme),
const SizedBox(height: 16),
// 其他信息
_buildOtherInfo(theme),
const SizedBox(height: 24),
],
),
);
}
Widget _buildAvatar(ThemeData theme) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.3),
width: 3,
),
),
child: ClipOval(
child: Image.network(
_user!.avatarUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.person,
size: 60,
color: theme.colorScheme.primary,
);
},
),
),
);
}
Widget _buildBasicInfo(ThemeData theme) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
if (_user!.name != null)
Text(
_user!.name!,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'@${_user!.login}',
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
),
if (_user!.bio != null) ...[
const SizedBox(height: 16),
Text(
_user!.bio!,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
],
),
);
}
Widget _buildStatistics(ThemeData theme) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(
'仓库',
_user!.publicRepos ?? 0,
Icons.folder,
theme,
),
_buildStatItem(
'粉丝',
_user!.followers ?? 0,
Icons.people,
theme,
),
_buildStatItem(
'关注',
_user!.following ?? 0,
Icons.person_add,
theme,
),
],
),
),
);
}
Widget _buildStatItem(
String label,
int value,
IconData icon,
ThemeData theme,
) {
return Column(
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Text(
value.toString(),
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
);
}
Widget _buildOtherInfo(ThemeData theme) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
if (_user!.createdAt != null)
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('注册时间'),
subtitle: Text(_user!.createdAt!.substring(0, 10)),
),
if (_user!.htmlUrl != null)
ListTile(
leading: const Icon(Icons.link),
title: const Text('主页链接'),
subtitle: Text(_user!.htmlUrl!),
trailing: const Icon(Icons.open_in_new),
onTap: () => _launchUrl(_user!.htmlUrl!),
),
],
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法打开链接')),
);
}
}
}
}
2.2 更新用户列表页跳转
修改 lib/pages/user_list_page.dart,添加导入:
import 'user_detail_page.dart'; // 在文件顶部添加
修改 onTap 回调:
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserDetailPage(
username: user.login,
token: widget.token,
),
),
);
},
第三步:添加仓库详情 API
3.1 创建仓库详情数据模型
在 lib/core/gitcode_api.dart 文件末尾添加:
/// 仓库详情模型
class GitCodeRepositoryDetail {
const GitCodeRepositoryDetail({
required this.id,
required this.name,
required this.fullName,
required this.htmlUrl,
this.description,
this.language,
this.stars,
this.forks,
this.watchers,
this.openIssues,
this.defaultBranch,
this.createdAt,
this.updatedAt,
this.owner,
});
final int id;
final String name;
final String fullName;
final String htmlUrl;
final String? description;
final String? language;
final int? stars;
final int? forks;
final int? watchers;
final int? openIssues;
final String? defaultBranch;
final String? createdAt;
final String? updatedAt;
final RepositoryOwner? owner;
factory GitCodeRepositoryDetail.fromJson(Map<String, dynamic> json) {
return GitCodeRepositoryDetail(
id: _safeInt(json['id']) ?? 0,
name: json['name'] as String? ?? '',
fullName: json['full_name'] as String? ?? json['path_with_namespace'] as String? ?? '',
htmlUrl: json['html_url'] as String? ?? json['web_url'] as String? ?? '',
description: json['description'] as String?,
language: json['language'] as String?,
stars: _safeInt(json['stargazers_count'] ?? json['star_count']),
forks: _safeInt(json['forks_count'] ?? json['forks']),
watchers: _safeInt(json['watchers_count'] ?? json['watchers']),
openIssues: _safeInt(json['open_issues_count'] ?? json['open_issues']),
defaultBranch: json['default_branch'] as String?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
owner: json['owner'] != null
? RepositoryOwner.fromJson(json['owner'] as Map<String, dynamic>)
: null,
);
}
}
/// 仓库所有者模型
class RepositoryOwner {
const RepositoryOwner({
required this.login,
this.avatarUrl,
this.htmlUrl,
});
final String login;
final String? avatarUrl;
final String? htmlUrl;
factory RepositoryOwner.fromJson(Map<String, dynamic> json) {
return RepositoryOwner(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String?,
htmlUrl: json['html_url'] as String?,
);
}
}
3.2 添加获取仓库详情方法
在 GitCodeApiClient 类中添加:
/// 获取仓库详情
Future<GitCodeRepositoryDetail> fetchRepository(
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',
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 Map<String, dynamic>) {
return GitCodeRepositoryDetail.fromJson(data);
}
throw const GitCodeApiException('响应数据格式错误');
} else if (statusCode == 404) {
throw const GitCodeApiException('仓库不存在');
} 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');
}
}
第四步:创建仓库详情页面
4.1 创建仓库详情页文件
创建 lib/pages/repository_detail_page.dart:
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../core/gitcode_api.dart';
class RepositoryDetailPage extends StatefulWidget {
const RepositoryDetailPage({
super.key,
required this.owner,
required this.repo,
required this.token,
});
final String owner;
final String repo;
final String token;
State<RepositoryDetailPage> createState() => _RepositoryDetailPageState();
}
class _RepositoryDetailPageState extends State<RepositoryDetailPage> {
final _client = GitCodeApiClient();
GitCodeRepositoryDetail? _repository;
bool _isLoading = true;
String? _errorMessage;
void initState() {
super.initState();
_loadRepository();
}
Future<void> _loadRepository() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final repo = await _client.fetchRepository(
widget.owner,
widget.repo,
personalToken: widget.token,
);
setState(() {
_repository = repo;
_isLoading = false;
});
} on GitCodeApiException catch (e) {
setState(() {
_errorMessage = e.message;
_isLoading = false;
});
}
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('${widget.owner}/${widget.repo}'),
actions: [
IconButton(
icon: const Icon(Icons.folder_open),
tooltip: '浏览文件',
onPressed: () {
// TODO: 浏览文件功能(稍后实现)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('文件浏览功能即将添加')),
);
},
),
],
),
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: _loadRepository,
child: const Text('重试'),
),
],
),
);
}
if (_repository == null) {
return const Center(child: Text('仓库不存在'));
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 基本信息
_buildHeader(theme),
const SizedBox(height: 16),
// 统计数据
_buildStatistics(theme),
const SizedBox(height: 16),
// 其他信息
_buildOtherInfo(theme),
const SizedBox(height: 16),
// 操作按钮
_buildActions(theme),
const SizedBox(height: 24),
],
),
);
}
Widget _buildHeader(ThemeData theme) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder,
color: theme.colorScheme.primary,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_repository!.name,
style: theme.textTheme.titleLarge,
),
Text(
_repository!.fullName,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
],
),
if (_repository!.description != null) ...[
const SizedBox(height: 16),
Text(
_repository!.description!,
style: theme.textTheme.bodyMedium,
),
],
],
),
),
);
}
Widget _buildStatistics(ThemeData theme) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(
Icons.star,
'Star',
_repository!.stars ?? 0,
Colors.amber,
),
_buildStatItem(
Icons.call_split,
'Fork',
_repository!.forks ?? 0,
Colors.blue,
),
_buildStatItem(
Icons.visibility,
'Watch',
_repository!.watchers ?? 0,
Colors.green,
),
],
),
if (_repository!.openIssues != null) ...[
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.bug_report, size: 20),
const SizedBox(width: 8),
Text('${_repository!.openIssues} 个未解决问题'),
],
),
],
],
),
),
);
}
Widget _buildStatItem(IconData icon, String label, int value, Color color) {
return Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(
value.toString(),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildOtherInfo(ThemeData theme) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
if (_repository!.language != null)
ListTile(
leading: const Icon(Icons.code),
title: const Text('主要语言'),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_repository!.language!,
style: const TextStyle(color: Colors.blue),
),
),
),
if (_repository!.defaultBranch != null)
ListTile(
leading: const Icon(Icons.account_tree),
title: const Text('默认分支'),
trailing: Text(_repository!.defaultBranch!),
),
if (_repository!.createdAt != null)
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('创建时间'),
trailing: Text(_repository!.createdAt!.substring(0, 10)),
),
if (_repository!.updatedAt != null)
ListTile(
leading: const Icon(Icons.update),
title: const Text('更新时间'),
trailing: Text(_repository!.updatedAt!.substring(0, 10)),
),
],
),
);
}
Widget _buildActions(ThemeData theme) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {
// TODO: 浏览文件功能(稍后实现)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('文件浏览功能即将添加')),
);
},
icon: const Icon(Icons.folder_open),
label: const Text('浏览文件'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _launchUrl(_repository!.htmlUrl),
icon: const Icon(Icons.open_in_new),
label: const Text('打开页面'),
),
),
],
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法打开链接')),
);
}
}
}
}
4.2 更新仓库列表页跳转
修改 lib/pages/repository_list_page.dart,添加导入:
import 'repository_detail_page.dart'; // 在文件顶部添加
在 Card 中添加 onTap:
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(
// ... 现有代码
),
),
)
第五步:添加文件浏览 API
5.1 创建文件相关数据模型
在 lib/core/gitcode_api.dart 文件末尾添加:
/// 仓库内容模型
class RepoContent {
const RepoContent({
required this.name,
required this.path,
required this.type,
required this.sha,
this.size,
this.url,
this.htmlUrl,
});
final String name;
final String path;
final String type; // file 或 dir
final String sha;
final int? size;
final String? url;
final String? htmlUrl;
bool get isDirectory => type == 'dir';
bool get isFile => type == 'file';
factory RepoContent.fromJson(Map<String, dynamic> json) {
return RepoContent(
name: json['name'] as String? ?? '',
path: json['path'] as String? ?? '',
type: json['type'] as String? ?? '',
sha: json['sha'] as String? ?? '',
size: _safeInt(json['size']),
url: json['url'] as String?,
htmlUrl: json['html_url'] as String?,
);
}
}
5.2 添加获取仓库内容方法
在 GitCodeApiClient 类中添加:
/// 获取仓库内容(文件或目录)
Future<dynamic> fetchContents(
String owner,
String repo,
String path, {
String? personalToken,
String? ref,
}) async {
try {
final encodedOwner = Uri.encodeComponent(owner.trim());
final encodedRepo = Uri.encodeComponent(repo.trim());
final encodedPath = Uri.encodeComponent(path);
debugPrint('获取仓库内容: $owner/$repo/$path');
final queryParameters = <String, dynamic>{
if (personalToken != null && personalToken.isNotEmpty)
'access_token': personalToken,
if (ref != null && ref.isNotEmpty) 'ref': ref,
};
final response = await _dio.get(
'/repos/$encodedOwner/$encodedRepo/contents/$encodedPath',
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(RepoContent.fromJson)
.toList();
}
// 如果是对象,返回单个文件
if (data is Map<String, dynamic>) {
return RepoContent.fromJson(data);
}
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');
}
}
第六步:创建文件浏览页面
6.1 创建文件浏览页文件
创建 lib/pages/repository_files_page.dart:
import 'package:flutter/material.dart';
import '../core/gitcode_api.dart';
class RepositoryFilesPage extends StatefulWidget {
const RepositoryFilesPage({
super.key,
required this.owner,
required this.repo,
required this.token,
this.defaultBranch,
});
final String owner;
final String repo;
final String token;
final String? defaultBranch;
State<RepositoryFilesPage> createState() => _RepositoryFilesPageState();
}
class _RepositoryFilesPageState extends State<RepositoryFilesPage> {
final _client = GitCodeApiClient();
final List<String> _pathStack = []; // 路径栈
List<RepoContent>? _contents;
String _currentPath = '';
bool _isLoading = true;
String? _errorMessage;
void initState() {
super.initState();
_loadContents();
}
Future<void> _loadContents({String path = ''}) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final result = await _client.fetchContents(
widget.owner,
widget.repo,
path,
personalToken: widget.token,
ref: widget.defaultBranch,
);
setState(() {
_currentPath = path;
if (result is List<RepoContent>) {
// 排序:目录在前,文件在后
_contents = result..sort(_sortContents);
} else {
_contents = [];
}
_isLoading = false;
});
} on GitCodeApiException catch (e) {
setState(() {
_errorMessage = e.message;
_isLoading = false;
});
}
}
int _sortContents(RepoContent a, RepoContent b) {
// 目录在前
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
// 按名称排序
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
}
void _enterDirectory(RepoContent content) {
if (content.isDirectory) {
_pathStack.add(content.name);
_loadContents(path: content.path);
}
}
void _navigateBack() {
if (_pathStack.isNotEmpty) {
_pathStack.removeLast();
final newPath = _pathStack.join('/');
_loadContents(path: newPath);
}
}
void _navigateToRoot() {
_pathStack.clear();
_loadContents();
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: _pathStack.isEmpty,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _pathStack.isNotEmpty) {
_navigateBack();
}
},
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${widget.owner}/${widget.repo}'),
if (_currentPath.isNotEmpty)
Text(
_currentPath,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
if (_pathStack.isNotEmpty)
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回根目录',
onPressed: _navigateToRoot,
),
],
),
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: () => _loadContents(path: _currentPath),
child: const Text('重试'),
),
],
),
);
}
if (_contents == null || _contents!.isEmpty) {
return const Center(child: Text('目录为空'));
}
return ListView.builder(
itemCount: _contents!.length,
itemBuilder: (context, index) {
final content = _contents![index];
return ListTile(
leading: Icon(
_getFileIcon(content),
color: _getFileColor(content),
),
title: Text(content.name),
subtitle: content.isFile && content.size != null
? Text(_formatFileSize(content.size!))
: null,
trailing: content.isDirectory
? const Icon(Icons.chevron_right)
: null,
onTap: () {
if (content.isDirectory) {
_enterDirectory(content);
} else {
// 文件点击(暂时只显示提示)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('文件: ${content.name}')),
);
}
},
);
},
);
}
IconData _getFileIcon(RepoContent content) {
if (content.isDirectory) return Icons.folder;
final ext = content.name.split('.').last.toLowerCase();
switch (ext) {
case 'dart':
case 'java':
case 'kt':
case 'py':
case 'js':
case 'ts':
case 'go':
return Icons.code;
case 'md':
case 'txt':
return Icons.description;
case 'json':
case 'xml':
case 'yaml':
case 'yml':
return Icons.data_object;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'svg':
return Icons.image;
default:
return Icons.insert_drive_file;
}
}
Color _getFileColor(RepoContent content) {
if (content.isDirectory) return Colors.blue;
final ext = content.name.split('.').last.toLowerCase();
switch (ext) {
case 'dart':
return Colors.blue;
case 'java':
case 'kt':
return Colors.orange;
case 'py':
return Colors.blue[700]!;
case 'js':
case 'ts':
return Colors.yellow[700]!;
case 'json':
return Colors.green;
default:
return Colors.grey;
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
6.2 更新仓库详情页跳转
修改 lib/pages/repository_detail_page.dart,添加导入:
import 'repository_files_page.dart'; // 在文件顶部添加
修改 AppBar 中的按钮和底部的按钮:
// AppBar 中
IconButton(
icon: const Icon(Icons.folder_open),
tooltip: '浏览文件',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepositoryFilesPage(
owner: widget.owner,
repo: widget.repo,
token: widget.token,
defaultBranch: _repository?.defaultBranch,
),
),
);
},
),
// 底部按钮
FilledButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepositoryFilesPage(
owner: widget.owner,
repo: widget.repo,
token: widget.token,
defaultBranch: _repository?.defaultBranch,
),
),
);
},
icon: const Icon(Icons.folder_open),
label: const Text('浏览文件'),
),
第七步:测试所有功能
7.1 运行应用
flutter run
7.2 完整测试流程
-
搜索用户:
- 输入关键字,点击搜索
- 点击用户进入详情页
- 查看头像、简介、统计数据
- 点击主页链接
-
搜索仓库:
- 输入关键字,点击搜索
- 点击仓库进入详情页
- 查看描述、统计、语言等信息
-
浏览文件:
- 在仓库详情页点击"浏览文件"
- 进入文件浏览器
- 点击目录进入
- 使用返回按钮返回上级
- 点击主页图标返回根目录
本章总结
🎉 恭喜!你已经完成了第三章的学习。
你学到了什么
- ✅ 用户详情 - 完整的用户信息展示
- ✅ 仓库详情 - 仓库统计和元数据
- ✅ 文件浏览 - 目录导航和文件识别
- ✅ URL 启动 - 使用 url_launcher 打开链接
- ✅ 导航栈 - 使用 List 管理路径栈
- ✅ PopScope - 拦截返回键实现自定义导航
- ✅ 文件类型识别 - 根据扩展名显示图标和颜色
项目结构更新
lib/
├── core/
│ ├── app_config.dart
│ └── gitcode_api.dart ← 更新(添加详情 API)
├── 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 ← 新增
└── main.dart
下一章预告
在第四章中,我们将:
- 🔨 添加仓库动态功能
- 🔨 添加贡献者统计功能
- 🔨 实现"我的仓库"功能
- 🔨 完善 UI 和用户体验
- 🔨 项目总结和优化建议
准备好了吗?让我们进入第四章! 🚀

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



