import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:git_touch/graphql/gh.dart'; import 'package:git_touch/models/auth.dart'; import 'package:git_touch/models/theme.dart'; import 'package:git_touch/utils/utils.dart'; import 'package:git_touch/widgets/action_button.dart'; import 'package:git_touch/widgets/avatar.dart'; import 'package:git_touch/widgets/link.dart'; import 'package:primer/primer.dart'; import 'package:provider/provider.dart'; import '../scaffolds/long_list.dart'; import '../widgets/timeline_item.dart'; import '../widgets/comment_item.dart'; final issueRouter = RouterScreen( '/:owner/:name/issues/:number', (context, params) => IssueScreen(params['owner'].first, params['name'].first, int.parse(params['number'].first))); final pullRouter = RouterScreen( '/:owner/:name/pulls/:number', (context, params) => IssueScreen(params['owner'].first, params['name'].first, int.parse(params['number'].first), isPullRequest: true)); final reactionChunk = emojiMap.entries.map((entry) { var key = entry.key; return ''' $key: reactions(content: $key) { totalCount viewerHasReacted }'''; }).join('\n'); /// Screen for issue and pull request class IssueScreen extends StatefulWidget { final String owner; final String name; final int number; final bool isPullRequest; IssueScreen(this.owner, this.name, this.number, {this.isPullRequest = false}); @override _IssueScreenState createState() => _IssueScreenState(); } class _IssueScreenState extends State { String get owner => widget.owner; String get name => widget.name; int get number => widget.number; bool get isPullRequest => widget.isPullRequest; String get resource => isPullRequest ? 'pullRequest' : 'issue'; String get issueChunk { var base = ''' repository { owner { avatarUrl } } title closed url viewerCanReact viewerCanUpdate ...CommentParts ...ReactableParts '''; if (isPullRequest) { base += ''' merged additions deletions changedFiles commits { totalCount } '''; } return base; } String get timelineChunk { var base = ''' __typename ... on IssueComment { ...CommentParts ...ReactableParts } ... on ReferencedEvent { createdAt isCrossRepository actor { login } commit { oid url } commitRepository { owner { login } name } } ... on RenamedTitleEvent { createdAt previousTitle currentTitle actor { login } } ... on ClosedEvent { createdAt actor { login } } ... on ReopenedEvent { createdAt actor { login } } ... on CrossReferencedEvent { createdAt actor { login } source { __typename ... on Issue { number repository { owner { login } name } } ... on PullRequest { number repository { owner { login } name } } } } ... on LabeledEvent { createdAt actor { login } label { name color } } ... on UnlabeledEvent { createdAt actor { login } label { name color } } ... on MilestonedEvent { createdAt actor { login } milestoneTitle } ... on LockedEvent { createdAt actor { login } lockReason } ... on UnlockedEvent { createdAt actor { login } } ... on AssignedEvent { createdAt actor { login } user { login } } '''; if (isPullRequest) { base += ''' ... on PullRequestCommit { prCommit: commit { committedDate oid author { user { login } } } } ... on HeadRefForcePushedEvent { createdAt actor { login } pullRequest { headRef { name } } beforeCommit { oid } afterCommit { oid } } ... on ReviewRequestedEvent { createdAt actor { login } requestedReviewer { ... on User { login } } } ... on PullRequestReview { createdAt state author { login } comments(first: 10) { nodes { ...CommentParts ...ReactableParts } } } ... on MergedEvent { createdAt mergeRefName actor { login } commit { oid url } } ... on HeadRefDeletedEvent { createdAt actor { login } headRefName } '''; } return base; } Future _queryIssue({String cursor, bool trailing = false}) async { String timelineParams; if (trailing) { timelineParams = 'last: $pageSize'; } else { timelineParams = 'first: $pageSize'; if (cursor != null) { timelineParams += ', after: $cursor'; } } var data = await Provider.of(context).query(''' fragment CommentParts on Comment { id createdAt body author { login avatarUrl } } fragment ReactableParts on Reactable { $reactionChunk } { repository(owner: "$owner", name: "$name") { $resource(number: $number) { $issueChunk timelineItems($timelineParams) { totalCount pageInfo { hasNextPage endCursor } nodes { $timelineChunk } } } } } '''); return data['repository'][resource]; } StateLabelStatus _getLabelStatus(payload) { StateLabelStatus status; if (isPullRequest) { if (payload['merged']) { status = StateLabelStatus.pullMerged; } else if (payload['closed']) { status = StateLabelStatus.pullClosed; } else { status = StateLabelStatus.pullOpened; } } else { if (payload['closed']) { status = StateLabelStatus.issueClosed; } else { status = StateLabelStatus.issueOpened; } } return status; } _handleReaction(payload) { return (String emojiKey, bool isRemove) async { if (emojiKey == null) return; var id = payload['id'] as String; var operation = isRemove ? 'remove' : 'add'; await Provider.of(context).query(''' mutation { ${operation}Reaction(input: {subjectId: "$id", content: $emojiKey}) { clientMutationId } } '''); setState(() { payload[emojiKey]['totalCount'] += isRemove ? -1 : 1; payload[emojiKey]['viewerHasReacted'] = !isRemove; }); }; } @override Widget build(BuildContext context) { final auth = Provider.of(context); return LongListStatefulScaffold( title: Text(isPullRequest ? 'Pull Request' : 'Issue'), trailingBuilder: (payload, setState) { return ActionButton( title: (isPullRequest ? 'Pull Request' : 'Issue') + ' Actions', items: [ if (payload != null) ...[ if (!isPullRequest && payload['viewerCanUpdate']) ActionItem( text: payload['closed'] ? 'Reopen issue' : 'Close issue', onTap: (_) async { final res = await auth.gqlClient.execute( GhOpenIssueQuery( variables: GhOpenIssueArguments( id: payload['id'], open: payload['closed'], ), ), ); setState(() { payload['closed'] = res.data.reopenIssue?.issue?.closed ?? res.data.closeIssue.issue.closed; }); }, ), ActionItem.share(payload['url']), ActionItem.launch(payload['url']), ], ], ); }, headerBuilder: (p) { final theme = Provider.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: CommonStyle.padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Avatar( url: p['repository']['owner']['avatarUrl'], size: AvatarSize.extraSmall, ), SizedBox(width: 4), Text( '$owner / $name', style: TextStyle( fontSize: 17, color: theme.palette.secondaryText, ), ), SizedBox(width: 4), Text( '#$number', style: TextStyle( fontSize: 17, color: theme.palette.tertiaryText, ), ), ], ), SizedBox(height: 8), Text( p['title'], style: TextStyle( fontSize: 20, fontWeight: FontWeight.w600, ), ), SizedBox(height: 8), StateLabel(_getLabelStatus(p), small: true), SizedBox(height: 8), CommonStyle.border, if (isPullRequest) ...[ Link( child: Container( padding: EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${p['changedFiles']} files changed', style: TextStyle( color: theme.palette.secondaryText, fontSize: 17, ), ), Row( children: [ Text( '+${p['additions']}', style: TextStyle( color: Colors.green, fontSize: 15, ), ), SizedBox(width: 2), Text( '-${p['deletions']}', style: TextStyle( color: Colors.red, fontSize: 15, ), ), Icon( Icons.chevron_right, color: theme.palette.border, ), ], ) ], ), ), url: 'https://github.com/$owner/$name/pull/$number/files', ), CommonStyle.border, ], SizedBox(height: 8), CommentItem( p, onReaction: _handleReaction(p), ), ], ), ), CommonStyle.border, ], ); }, itemBuilder: (itemPayload) => TimelineItem(itemPayload, onReaction: _handleReaction(itemPayload)), onRefresh: () async { var res = await _queryIssue(); int totalCount = res['timelineItems']['totalCount']; String cursor = res['timelineItems']['pageInfo']['endCursor']; List leadingItems = res['timelineItems']['nodes']; var payload = LongListPayload( header: res, totalCount: totalCount, cursor: cursor, leadingItems: leadingItems, trailingItems: [], ); if (totalCount > 2 * pageSize) { var res = await _queryIssue(trailing: true); payload.trailingItems = res['timelineItems']['nodes']; } return payload; }, onLoadMore: (String _cursor) async { var res = await _queryIssue(cursor: _cursor); int totalCount = res['timelineItems']['totalCount']; String cursor = res['timelineItems']['pageInfo']['endCursor']; List leadingItems = res['timelineItems']['nodes']; var payload = LongListPayload( totalCount: totalCount, cursor: cursor, leadingItems: leadingItems, ); return payload; }, ); } }