
Issue 与 PR 的统一处理
AtomGit(类 GitHub)API 中,PR 本质上是一种特殊 Issue。本项目的 Issue 模型通过 isPullRequest 字段区分:
dart
class Issue {
final int id;
final int number;
final String title;
final String? body;
final String state; // 'open' 或 'closed'
final UserProfile? user;
final List<String> labels;
final int commentsCount;
final bool isPullRequest; // 区分 Issue 和 PR
final DateTime createdAt;
final DateTime updatedAt;
factory Issue.fromJson(Map<String, dynamic> json) {
return Issue(
id: parseInt(json['id']),
number: parseInt(json['number']),
title: parseString(json['title']),
body: json['body'] as String?,
state: parseString(json['state']),
isPullRequest: json['pull_request'] != null,
labels: (parseList<dynamic>(json, 'labels') ?? [])
.whereType<Map<String, dynamic>>()
.map((l) => parseString(l['name']))
.toList(),
commentsCount: parseInt(json['comments']),
// ...
);
}
}
isPullRequest 通过检查 JSON 中是否存在 pull_request 键来判定,这对应 GitHub API 的行为。
IssueProvider
加载列表
dart
class IssueProvider extends ChangeNotifier {
final AtomGitApiClient _apiClient;
List<Issue> _issues = [];
bool _isLoading = false;
String? _error;
String _stateFilter = 'open';
Future<void> loadIssues(
String owner, String repo, String type) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final encodedOwner = Uri.encodeComponent(owner);
final encodedRepo = Uri.encodeComponent(repo);
final endpoint = type == 'pr'
? '/repos/$encodedOwner/$encodedRepo/pulls'
: '/repos/$encodedOwner/$encodedRepo/issues';
final response = await _apiClient.get(endpoint, queryParams: {
'state': _stateFilter,
'per_page': '30',
});
final items = parseList<dynamic>(response.data) ?? [];
_issues = items
.whereType<Map<String, dynamic>>()
.map(Issue.fromJson)
.where((issue) =>
type == 'pr' ? issue.isPullRequest : !issue.isPullRequest)
.toList();
} on ApiException catch (e) {
_error = e.message;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
Issue 和 PR 使用不同 API 端点(/issues vs /pulls),但返回数据可能混合。通过 isPullRequest 过滤确保类型纯净。
状态过滤
dart
void setStateFilter(String state) {
_stateFilter = state;
// 筛选后不自动刷新,由 UI 调用 loadIssues
}
Filter 状态存储在 Provider 上,UI 切换后重新调用 loadIssues。
加载详情与评论
dart
Future<void> loadDetail(
String owner, String repo, int number) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final encodedOwner = Uri.encodeComponent(owner);
final encodedRepo = Uri.encodeComponent(repo);
// 并行加载 Issue 详情和评论
final results = await Future.wait([
_apiClient.get(
'/repos/$encodedOwner/$encodedRepo/issues/$number',
),
_apiClient.get(
'/repos/$encodedOwner/$encodedRepo/issues/$number/comments',
queryParams: {'per_page': '30'},
),
]);
final issueData = parseMap(results[0].data);
if (issueData != null) {
_detail = Issue.fromJson(issueData);
}
final commentsList = parseList<dynamic>(results[1].data) ?? [];
_comments = commentsList
.whereType<Map<String, dynamic>>()
.map(Comment.fromJson)
.toList();
} on ApiException catch (e) {
_error = e.message;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future.wait 并发请求 Issue 详情和评论列表,减少加载等待时间。
IssueListScreen
过滤器 Chips
使用 Material FilterChip 实现状态切换:
dart
AppBar(
title: Text(type == 'pr' ? 'Pull Requests' : 'Issues'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(children: [
FilterChip(
label: const Text('进行中'),
selected: provider.stateFilter == 'open',
onSelected: (_) {
provider.setStateFilter('open');
provider.loadIssues(owner, name, type);
},
),
const SizedBox(width: 8),
FilterChip(
label: const Text('已完成'),
selected: provider.stateFilter == 'closed',
onSelected: (_) {
provider.setStateFilter('closed');
provider.loadIssues(owner, name, type);
},
),
]),
),
),
)
Issue 列表项
dart
Widget _buildIssueTile(Issue issue, String owner, String name) {
final isOpen = issue.state == 'open';
return ListTile(
leading: Icon(
isOpen ? Icons.error_outline : Icons.check_circle_outline,
color: isOpen ? Colors.green : Colors.red,
),
title: Text('#${issue.number} ${issue.title}',
maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${issue.user?.login ?? 'unknown'} · '
'${DateFormatter.relative(issue.createdAt)} · '
'${issue.commentsCount} 条评论',
),
onTap: () {
final route = type == 'pr'
? '/repo/pulls/detail'
: '/repo/issues/detail';
Navigator.pushNamed(context, route, arguments: {
'owner': owner, 'name': name, 'number': issue.number,
});
},
);
}
打开/关闭状态用不同颜色图标区分,副标题展示作者、时间和评论数。
IssueDetailScreen
头部信息
dart
Widget _buildHeader(Issue issue) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
issue.state == 'open'
? Icons.error_outline
: Icons.check_circle_outline,
color: issue.state == 'open' ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
Expanded(child: Text(issue.title,
style: Theme.of(context).textTheme.titleMedium)),
]),
const SizedBox(height: 12),
Row(children: [
UserAvatar(
avatarUrl: issue.user?.avatarUrl,
name: issue.user?.login,
size: 24),
const SizedBox(width: 8),
Text(issue.user?.login ?? ''),
const Spacer(),
Text(DateFormatter.relative(issue.createdAt),
style: Theme.of(context).textTheme.bodySmall),
]),
if (issue.labels.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(spacing: 4, runSpacing: 4, children: issue.labels
.map((label) => Chip(label: Text(label)))
.toList()),
],
],
),
),
);
}
内容与评论
dart
Widget _buildBody(Issue issue, List<Comment> comments) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
MarkdownViewer(markdown: issue.body ?? '*无描述*'),
const Divider(height: 32),
Text('${comments.length} 条评论',
style: Theme.of(context).textTheme.titleSmall),
...comments.map((comment) => _CommentWidget(comment: comment)),
],
);
}
评论组件
dart
class _CommentWidget extends StatelessWidget {
final Comment comment;
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
UserAvatar(
avatarUrl: comment.user?.avatarUrl,
name: comment.user?.login,
size: 24),
const SizedBox(width: 8),
Text(comment.user?.login ?? ''),
const Spacer(),
Text(DateFormatter.relative(comment.createdAt),
style: Theme.of(context).textTheme.bodySmall),
]),
const SizedBox(height: 8),
MarkdownViewer(markdown: comment.body),
],
),
),
);
}
}
每条评论展示头像、用户名、时间和 Markdown 渲染的正文。
Comment 模型
dart
class Comment {
final int id;
final String body;
final UserProfile? user;
final DateTime createdAt;
final DateTime updatedAt;
factory Comment.fromJson(Map<String, dynamic> json) {
return Comment(
id: parseInt(json['id']),
body: parseString(json['body']),
user: json['user'] != null
? UserProfile.fromJson(json['user'] as Map<String, dynamic>)
: null,
createdAt: parseDateTime(json['created_at']) ?? DateTime.now(),
updatedAt: parseDateTime(json['updated_at']) ?? DateTime.now(),
);
}
}