【开源鸿蒙跨平台开发--3.4】GitCode口袋工具高级功能与项目完善

第4章:高级功能与项目完善

从零开始构建 GitCode 口袋工具 - 实现仓库动态、贡献者统计和我的仓库

📚 本章目标

在本章中,你将学习:

  1. 实现仓库动态事件功能
  2. 实现贡献者统计功能
  3. 添加"我的仓库"功能
  4. 创建可复用的 UI 组件
  5. 完善用户体验细节
  6. 本章总结

第一步:实现仓库动态功能

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

测试清单

  1. ✅ 用户搜索和详情
  2. ✅ 仓库搜索和详情
  3. ✅ 文件浏览
  4. ✅ 仓库动态
  5. ✅ 贡献者统计
  6. ✅ 我的仓库
  7. ✅ 下拉刷新和上拉加载

5.2 性能优化建议

  1. 使用 const 构造函数
const Icon(Icons.home)
const SizedBox(height: 16)
  1. 图片缓存
// NetworkImage 自动缓存
CircleAvatar(backgroundImage: NetworkImage(url))
  1. 及时释放资源

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 页面

技术亮点

  1. 完整的 API 封装 - 15+ 数据模型,10+ API 方法
  2. Material Design 3 - 现代化 UI 设计
  3. 分页加载 - SmartRefresher 实现
  4. 文件浏览 - 目录导航和文件识别
  5. 错误处理 - 完善的异常处理机制
  6. 状态管理 - 清晰的加载/错误/空数据状态

本章学到的知识点

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. 创建可复用组件

本章我们创建了 UserCardRepositoryCard 两个可复用组件,减少了代码重复。

2. 统一的错误处理

在事件和贡献者统计功能中,我们实现了:

  • 404 错误时显示空状态而非错误提示
  • 详细的调试日志
  • 友好的用户提示
3. 性能优化实践
  • 使用 const 构造函数
  • 及时释放 Controller 资源
  • 图片自动缓存

🎯 已掌握的技能

通过前4章的学习,你已经掌握:

✅ Flutter 基础

  • StatefulWidget 和 StatelessWidget
  • State 管理和生命周期
  • 导航和路由

✅ 网络编程

  • Dio 库使用
  • API 封装和数据模型
  • 错误处理机制

✅ UI 开发

  • Material Design 3 应用
  • 复杂页面构建
  • 可复用组件设计

✅ 高级特性

  • 下拉刷新和上拉加载
  • 文件浏览和目录导航
  • 时间格式化和数据展示

📝 待实现功能预告

在后续章节中,我们将继续完善项目:

  • 🔜 热门项目展示 - 展示 GitCode 热门开源项目
  • 🔜 我的组织管理 - 查看和管理加入的组织
  • 🔜 高级筛选功能 - 按语言、时间等条件筛选
  • 🔜 代码查看器 - 在线查看文件内容
  • 🔜 主题切换 - 支持深色模式
  • 🔜 本地缓存 - Token 和数据缓存
  • 🔜 更多功能 - 持续扩展中…

🚀 下一章预告

在下一章中,我们将实现:

  • 热门项目展示功能
  • 按语言分类筛选
  • 项目排序和搜索优化
  • 首页内容完善

准备好继续学习了吗?让我们进入下一章! 🎓

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值