【开源鸿蒙跨平台开发--3.3】GitCode口袋工具详情页面与文件浏览功能

第3章:详情页面与文件浏览功能

从零开始构建 GitCode 口袋工具 - 实现详情展示和文件浏览

📚 本章目标

在本章中,你将学习:

  1. 实现用户详情页面
  2. 实现仓库详情页面
  3. 添加获取用户和仓库详情的 API
  4. 实现文件浏览功能(目录树)
  5. 添加文件相关的 API 和数据模型
  6. 实现目录导航和文件类型识别

第一步:添加用户详情 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 完整测试流程

  1. 搜索用户

    • 输入关键字,点击搜索
    • 点击用户进入详情页
    • 查看头像、简介、统计数据
    • 点击主页链接
  2. 搜索仓库

    • 输入关键字,点击搜索
    • 点击仓库进入详情页
    • 查看描述、统计、语言等信息
  3. 浏览文件

    • 在仓库详情页点击"浏览文件"
    • 进入文件浏览器
    • 点击目录进入
    • 使用返回按钮返回上级
    • 点击主页图标返回根目录

本章总结

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

你学到了什么

  1. 用户详情 - 完整的用户信息展示
  2. 仓库详情 - 仓库统计和元数据
  3. 文件浏览 - 目录导航和文件识别
  4. URL 启动 - 使用 url_launcher 打开链接
  5. 导航栈 - 使用 List 管理路径栈
  6. PopScope - 拦截返回键实现自定义导航
  7. 文件类型识别 - 根据扩展名显示图标和颜色

项目结构更新

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 和用户体验
  • 🔨 项目总结和优化建议

准备好了吗?让我们进入第四章! 🚀

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值