Issue 与 PR 的统一处理
AtomGit(类 GitHub)API 中,PR 本质上是一种特殊 Issue。本项目的 Issue 模型通过isPullRequest字段区分:
classIssue{finalint id;finalint number;finalStringtitle;finalString?body;finalStringstate;// 'open' 或 'closed'finalUserProfile?user;finalList<String>labels;finalint commentsCount;finalbool isPullRequest;// 区分 Issue 和 PRfinalDateTimecreatedAt;finalDateTimeupdatedAt;factoryIssue.fromJson(Map<String,dynamic>json){returnIssue(id:parseInt(json['id']),number:parseInt(json['number']),title:parseString(json['title']),body:json['body']asString?,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
加载列表
classIssueProviderextendsChangeNotifier{finalAtomGitApiClient_apiClient;List<Issue>_issues=[];bool _isLoading=false;String?_error;String_stateFilter='open';Future<void>loadIssues(Stringowner,Stringrepo,Stringtype)async{_isLoading=true;_error=null;notifyListeners();try{finalencodedOwner=Uri.encodeComponent(owner);finalencodedRepo=Uri.encodeComponent(repo);finalendpoint=type=='pr'?'/repos/$encodedOwner/$encodedRepo/pulls':'/repos/$encodedOwner/$encodedRepo/issues';finalresponse=await_apiClient.get(endpoint,queryParams:{'state':_stateFilter,'per_page':'30',});finalitems=parseList<dynamic>(response.data)??[];_issues=items.whereType<Map<String,dynamic>>().map(Issue.fromJson).where((issue)=>type=='pr'?issue.isPullRequest:!issue.isPullRequest).toList();}onApiExceptioncatch(e){_error=e.message;}finally{_isLoading=false;notifyListeners();}}}Issue 和 PR 使用不同 API 端点(/issuesvs/pulls),但返回数据可能混合。通过isPullRequest过滤确保类型纯净。
状态过滤
voidsetStateFilter(Stringstate){_stateFilter=state;// 筛选后不自动刷新,由 UI 调用 loadIssues}Filter 状态存储在 Provider 上,UI 切换后重新调用loadIssues。
加载详情与评论
Future<void>loadDetail(Stringowner,Stringrepo,int number)async{_isLoading=true;_error=null;notifyListeners();try{finalencodedOwner=Uri.encodeComponent(owner);finalencodedRepo=Uri.encodeComponent(repo);// 并行加载 Issue 详情和评论finalresults=awaitFuture.wait([_apiClient.get('/repos/$encodedOwner/$encodedRepo/issues/$number',),_apiClient.get('/repos/$encodedOwner/$encodedRepo/issues/$number/comments',queryParams:{'per_page':'30'},),]);finalissueData=parseMap(results[0].data);if(issueData!=null){_detail=Issue.fromJson(issueData);}finalcommentsList=parseList<dynamic>(results[1].data)??[];_comments=commentsList.whereType<Map<String,dynamic>>().map(Comment.fromJson).toList();}onApiExceptioncatch(e){_error=e.message;}finally{_isLoading=false;notifyListeners();}}Future.wait并发请求 Issue 详情和评论列表,减少加载等待时间。
IssueListScreen
过滤器 Chips
使用 Material FilterChip 实现状态切换:
AppBar(title:Text(type=='pr'?'Pull Requests':'Issues'),bottom:PreferredSize(preferredSize:constSize.fromHeight(48),child:Padding(padding:constEdgeInsets.symmetric(horizontal:16,vertical:8),child:Row(children:[FilterChip(label:constText('进行中'),selected:provider.stateFilter=='open',onSelected:(_){provider.setStateFilter('open');provider.loadIssues(owner,name,type);},),constSizedBox(width:8),FilterChip(label:constText('已完成'),selected:provider.stateFilter=='closed',onSelected:(_){provider.setStateFilter('closed');provider.loadIssues(owner,name,type);},),]),),),)Issue 列表项
Widget_buildIssueTile(Issueissue,Stringowner,Stringname){finalisOpen=issue.state=='open';returnListTile(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:(){finalroute=type=='pr'?'/repo/pulls/detail':'/repo/issues/detail';Navigator.pushNamed(context,route,arguments:{'owner':owner,'name':name,'number':issue.number,});},);}打开/关闭状态用不同颜色图标区分,副标题展示作者、时间和评论数。
IssueDetailScreen
头部信息
Widget_buildHeader(Issueissue){returnCard(margin:constEdgeInsets.all(16),child:Padding(padding:constEdgeInsets.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,),constSizedBox(width:8),Expanded(child:Text(issue.title,style:Theme.of(context).textTheme.titleMedium)),]),constSizedBox(height:12),Row(children:[UserAvatar(avatarUrl:issue.user?.avatarUrl,name:issue.user?.login,size:24),constSizedBox(width:8),Text(issue.user?.login??''),constSpacer(),Text(DateFormatter.relative(issue.createdAt),style:Theme.of(context).textTheme.bodySmall),]),if(issue.labels.isNotEmpty)...[constSizedBox(height:8),Wrap(spacing:4,runSpacing:4,children:issue.labels.map((label)=>Chip(label:Text(label))).toList()),],],),),);}内容与评论
Widget_buildBody(Issueissue,List<Comment>comments){returnListView(padding:constEdgeInsets.all(16),children:[MarkdownViewer(markdown:issue.body??'*无描述*'),constDivider(height:32),Text('${comments.length}条评论',style:Theme.of(context).textTheme.titleSmall),...comments.map((comment)=>_CommentWidget(comment:comment)),],);}评论组件
class_CommentWidgetextendsStatelessWidget{finalCommentcomment;Widgetbuild(BuildContextcontext){returnCard(margin:constEdgeInsets.symmetric(vertical:8),child:Padding(padding:constEdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Row(children:[UserAvatar(avatarUrl:comment.user?.avatarUrl,name:comment.user?.login,size:24),constSizedBox(width:8),Text(comment.user?.login??''),constSpacer(),Text(DateFormatter.relative(comment.createdAt),style:Theme.of(context).textTheme.bodySmall),]),constSizedBox(height:8),MarkdownViewer(markdown:comment.body),],),),);}}每条评论展示头像、用户名、时间和 Markdown 渲染的正文。
Comment 模型
classComment{finalint id;finalStringbody;finalUserProfile?user;finalDateTimecreatedAt;finalDateTimeupdatedAt;factoryComment.fromJson(Map<String,dynamic>json){returnComment(id:parseInt(json['id']),body:parseString(json['body']),user:json['user']!=null?UserProfile.fromJson(json['user']asMap<String,dynamic>):null,createdAt:parseDateTime(json['created_at'])??DateTime.now(),updatedAt:parseDateTime(json['updated_at'])??DateTime.now(),);}}