From 650af30838158f92986490b6ba939ffba207cb78 Mon Sep 17 00:00:00 2001 From: Rongjian Zhang Date: Fri, 8 Feb 2019 14:01:25 +0800 Subject: [PATCH] refactor: extract long list scaffold for issue and pull request --- images/progressive-disclosure-line.png | Bin 0 -> 333 bytes lib/main.dart | 3 + lib/screens/issue.dart | 156 +++++++++++----- lib/screens/pull_request.dart | 241 ++++++++++++++++--------- lib/utils/utils.dart | 2 +- lib/widgets/event_item.dart | 18 +- lib/widgets/long_list_scaffold.dart | 218 ++++++++++++++++++++++ lib/widgets/notification_item.dart | 12 +- lib/widgets/timeline_item.dart | 11 +- pubspec.yaml | 6 + 10 files changed, 517 insertions(+), 150 deletions(-) create mode 100644 images/progressive-disclosure-line.png create mode 100644 lib/widgets/long_list_scaffold.dart diff --git a/images/progressive-disclosure-line.png b/images/progressive-disclosure-line.png new file mode 100644 index 0000000000000000000000000000000000000000..caec8ba2b0da5396973c499064bf0519a1260c27 GIT binary patch literal 333 zcmV-T0kZyyP)({KaAw-j{OEDh;eGZleb{YMC~6~(4H7T>mB8NoZm z%N;0zB}8X-+3|3>-Ewg?ma!uj^OO1Q5f-@VG4>*}z!DE4XDz9g)T7=@xVp$oWt?5a z8V!6seU-4^(~KoJvyi>N-O6HHpIH)8k}{o>1)grPnYtGXeRbW18(abm-9qvt;H~!9 zRZ!cwkUYE6q!;EnEIsSrW(z`&0$2*8*1BhuZI@;;&taP=q-7+YQ!2tn7uk&5^GoIW f|0(@1{+8l9Qjvdjf;(%N00000NkvXXu0mjffMJrf literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index f7e77a0..9c924a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,8 @@ import 'screens/notifications.dart'; import 'screens/search.dart'; import 'screens/profile.dart'; import 'screens/login.dart'; +import 'screens/pull_request.dart'; +import 'screens/issue.dart'; class Home extends StatefulWidget { @override @@ -58,6 +60,7 @@ class _HomeState extends State { } _buildScreen(int index) { + // return IssueScreen(number: 29, owner: 'reactjs', name: 'rfcs'); switch (index) { case 0: return NewsScreen(); diff --git a/lib/screens/issue.dart b/lib/screens/issue.dart index b03d727..5dd97cc 100644 --- a/lib/screens/issue.dart +++ b/lib/screens/issue.dart @@ -1,59 +1,44 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import '../utils/utils.dart'; -import '../widgets/list_scaffold.dart'; +import '../widgets/long_list_scaffold.dart'; import '../widgets/timeline_item.dart'; import '../widgets/comment_item.dart'; import '../providers/settings.dart'; class IssueScreen extends StatefulWidget { - final int id; + final int number; final String owner; final String name; - IssueScreen(this.id, this.owner, this.name); + IssueScreen({ + @required this.number, + @required this.owner, + @required this.name, + }); + + IssueScreen.fromFullName({@required this.number, @required String fullName}) + : this.owner = fullName.split('/')[0], + this.name = fullName.split('/')[1]; @override _IssueScreenState createState() => _IssueScreenState(); } class _IssueScreenState extends State { - Map payload; - - Widget _buildHeader() { - return Column(children: [ - Container( - // padding: EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - payload['title'], - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - CommentItem(payload), - ], - ), - ) - ]); - } - get _fullName => widget.owner + '/' + widget.name; + get owner => widget.owner; + get id => widget.number; + get name => widget.name; - List get _items => payload == null ? [] : payload['timeline']['nodes']; - - Future queryIssue( - BuildContext context, int id, String owner, String name) async { + Future queryIssue() async { var data = await SettingsProvider.of(context).query(''' { repository(owner: "$owner", name: "$name") { issue(number: $id) { $graphqlChunk1 timeline(first: $pageSize) { + totalCount pageInfo { hasNextPage endCursor @@ -69,23 +54,104 @@ class _IssueScreenState extends State { return data['repository']['issue']; } + Future queryMore(String cursor) async { + var data = await SettingsProvider.of(context).query(''' +{ + repository(owner: "$owner", name: "$name") { + issue(number: $id) { + timeline(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + endCursor + } + nodes { + $graghqlChunk + } + } + } + } +} +'''); + return data['repository']['issue']; + } + + Future queryTrailing() async { + var data = await SettingsProvider.of(context).query(''' +{ + repository(owner: "$owner", name: "$name") { + issue(number: $id) { + timeline(last: $pageSize) { + nodes { + $graghqlChunk + } + } + } + } +} +'''); + return data['repository']['issue']['timeline']['nodes']; + } + @override Widget build(BuildContext context) { - return ListScaffold( - title: Text(_fullName + ' #' + widget.id.toString()), - header: payload == null ? null : _buildHeader(), - itemCount: _items.length, - itemBuilder: (context, index) => TimelineItem(_items[index], payload), - onRefresh: () async { - var _payload = - await queryIssue(context, widget.id, widget.owner, widget.name); - if (mounted) { - setState(() { - payload = _payload; - }); - } + return LongListScaffold( + title: Text(_fullName + ' #' + widget.number.toString()), + headerBuilder: (payload) { + return Column(children: [ + Container( + // padding: EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + payload['title'], + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + CommentItem(payload), + ], + ), + ) + ]); + }, + itemBuilder: (itemPayload) => TimelineItem(itemPayload), + onRefresh: () async { + var res = await queryIssue(); + int totalCount = res['timeline']['totalCount']; + String cursor = res['timeline']['pageInfo']['endCursor']; + List leadingItems = res['timeline']['nodes']; + + var payload = LongListPayload( + header: res, + totalCount: totalCount, + cursor: cursor, + leadingItems: leadingItems, + trailingItems: [], + ); + + if (totalCount > 2 * pageSize) { + payload.trailingItems = await queryTrailing(); + } + + return payload; + }, + onLoadMore: (String _cursor) async { + var res = await queryMore(_cursor); + int totalCount = res['timeline']['totalCount']; + String cursor = res['timeline']['pageInfo']['endCursor']; + List leadingItems = res['timeline']['nodes']; + + var payload = LongListPayload( + totalCount: totalCount, + cursor: cursor, + leadingItems: leadingItems, + ); + + return payload; }, - // onLoadMore: () => , ); } } diff --git a/lib/screens/pull_request.dart b/lib/screens/pull_request.dart index 805b687..82cef41 100644 --- a/lib/screens/pull_request.dart +++ b/lib/screens/pull_request.dart @@ -2,29 +2,76 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import '../providers/settings.dart'; import '../utils/utils.dart'; -import '../widgets/list_scaffold.dart'; +import '../widgets/long_list_scaffold.dart'; import '../widgets/timeline_item.dart'; import '../widgets/comment_item.dart'; class PullRequestScreen extends StatefulWidget { - final int id; + final int number; final String owner; final String name; - PullRequestScreen(this.id, this.owner, this.name); + PullRequestScreen({ + @required this.number, + @required this.owner, + @required this.name, + }); + + PullRequestScreen.fromFullName( + {@required this.number, @required String fullName}) + : this.owner = fullName.split('/')[0], + this.name = fullName.split('/')[1]; @override _PullRequestScreenState createState() => _PullRequestScreenState(); } +var commonChunk = ''' +$graghqlChunk +... on ReviewRequestedEvent { + createdAt + actor { + login + } + requestedReviewer { + ... on User { + login + } + } +} +... on PullRequestReview { + createdAt + state + author { + login + } +} +... on MergedEvent { + createdAt + mergeRefName + actor { + login + } + commit { + oid + url + } +} +... on HeadRefDeletedEvent { + createdAt + actor { + login + } + headRefName +} +'''; + class _PullRequestScreenState extends State { - Map payload; - - Future queryPullRequest(BuildContext context) async { - var owner = widget.owner; - var id = widget.id; - var name = widget.name; + get owner => widget.owner; + get id => widget.number; + get name => widget.name; + Future queryPullRequest() async { var data = await SettingsProvider.of(context).query(''' { repository(owner: "$owner", name: "$name") { @@ -38,48 +85,12 @@ class _PullRequestScreenState extends State { totalCount } timeline(first: $pageSize) { + totalCount pageInfo { - hasNextPage endCursor } nodes { - $graghqlChunk - ... on ReviewRequestedEvent { - createdAt - actor { - login - } - requestedReviewer { - ... on User { - login - } - } - } - ... on PullRequestReview { - createdAt - state - author { - login - } - } - ... on MergedEvent { - createdAt - mergeRefName - actor { - login - } - commit { - oid - url - } - } - ... on HeadRefDeletedEvent { - createdAt - actor { - login - } - headRefName - } + $commonChunk } } } @@ -89,7 +100,45 @@ class _PullRequestScreenState extends State { return data['repository']['pullRequest']; } - Widget _buildBadge() { + Future queryMore(String cursor) async { + var data = await SettingsProvider.of(context).query(''' +{ + repository(owner: "$owner", name: "$name") { + pullRequest(number: $id) { + timeline(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + endCursor + } + nodes { + $commonChunk + } + } + } + } +} +'''); + return data['repository']['pullRequest']; + } + + Future queryTrailing() async { + var data = await SettingsProvider.of(context).query(''' +{ + repository(owner: "$owner", name: "$name") { + pullRequest(number: $id) { + timeline(last: $pageSize) { + nodes { + $commonChunk + } + } + } + } +} +'''); + return data['repository']['pullRequest']['timeline']['nodes']; + } + + Widget _buildBadge(payload) { bool merged = payload['merged']; Color bgColor = merged ? Palette.purple : Palette.green; IconData iconData = merged ? Octicons.git_merge : Octicons.git_pull_request; @@ -117,47 +166,67 @@ class _PullRequestScreenState extends State { get _fullName => widget.owner + '/' + widget.name; - Widget _buildHeader() { - return Column(children: [ - Container( - // padding: EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBadge(), - Text( - payload['title'], - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - CommentItem(payload), - ], - ), - ) - ]); - } - - List get _items => payload == null ? [] : payload['timeline']['nodes']; - @override Widget build(BuildContext context) { - return ListScaffold( - title: Text(_fullName + ' #' + widget.id.toString()), - header: payload == null ? null : _buildHeader(), - itemCount: _items.length, - itemBuilder: (context, index) => TimelineItem(_items[index], payload), - onRefresh: () async { - var _payload = await queryPullRequest(context); - if (mounted) { - setState(() { - payload = _payload; - }); - } + return LongListScaffold( + title: Text(_fullName + ' #' + widget.number.toString()), + headerBuilder: (payload) { + return Column(children: [ + Container( + // padding: EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBadge(payload), + Text( + payload['title'], + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + CommentItem(payload), + ], + ), + ) + ]); + }, + itemBuilder: (itemPayload) => TimelineItem(itemPayload), + onRefresh: () async { + var res = await queryPullRequest(); + int totalCount = res['timeline']['totalCount']; + String cursor = res['timeline']['pageInfo']['endCursor']; + List leadingItems = res['timeline']['nodes']; + + var payload = LongListPayload( + header: res, + totalCount: totalCount, + cursor: cursor, + leadingItems: leadingItems, + trailingItems: [], + ); + + if (totalCount > 2 * pageSize) { + payload.trailingItems = await queryTrailing(); + } + + return payload; + }, + onLoadMore: (String _cursor) async { + var res = await queryMore(_cursor); + int totalCount = res['timeline']['totalCount']; + String cursor = res['timeline']['pageInfo']['endCursor']; + List leadingItems = res['timeline']['nodes']; + + var payload = LongListPayload( + totalCount: totalCount, + cursor: cursor, + leadingItems: leadingItems, + ); + + return payload; }, - // onLoadMore: () => , ); } } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 494d85d..1c27cfe 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -154,7 +154,7 @@ class Palette { static const gray = Color(0xff959da5); } -final pageSize = 20; +final pageSize = 5; final graphqlChunk1 = ''' title diff --git a/lib/widgets/event_item.dart b/lib/widgets/event_item.dart index ce40c24..9220d5d 100644 --- a/lib/widgets/event_item.dart +++ b/lib/widgets/event_item.dart @@ -19,17 +19,17 @@ class EventItem extends StatelessWidget { TextSpan _buildIssue(BuildContext context) { int id = event.payload['issue']['number']; - String name = event.repo.name; - var arr = name.split('/'); - return createLinkSpan( - context, '#' + id.toString(), () => IssueScreen(id, arr[0], arr[1])); + return createLinkSpan(context, '#' + id.toString(), + () => IssueScreen.fromFullName(number: id, fullName: event.repo.name)); } - TextSpan _buildPullRequest(BuildContext context, int id) { - String name = event.repo.name; - var arr = name.split('/'); - return createLinkSpan(context, '#' + id.toString(), - () => PullRequestScreen(id, arr[0], arr[1])); + TextSpan _buildPullRequest(BuildContext context, int number) { + return createLinkSpan( + context, + '#' + number.toString(), + () => PullRequestScreen.fromFullName( + number: number, fullName: event.repo.name), + ); } Widget _buildItem({ diff --git a/lib/widgets/long_list_scaffold.dart b/lib/widgets/long_list_scaffold.dart new file mode 100644 index 0000000..fa97c2f --- /dev/null +++ b/lib/widgets/long_list_scaffold.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import '../providers/settings.dart'; +import 'loading.dart'; +import 'link.dart'; + +class LongListPayload { + T header; + int totalCount; + String cursor; + List leadingItems; + List trailingItems; + + LongListPayload({ + this.header, + this.totalCount, + this.cursor, + this.leadingItems, + this.trailingItems, + }); +} + +// This is a scaffold for issue and pull request +// Since the list could be very long, and some users may only want to to check trailing items +// We should load leading and trailing items at first fetching, and do load more in the middle +// e.g. https://github.com/reactjs/rfcs/pull/68 +class LongListScaffold extends StatefulWidget { + final Widget title; + final List actions; + final Widget trailing; + final Widget Function(T headerPayload) headerBuilder; + final Widget Function(K itemPayload) itemBuilder; + final Future> Function() onRefresh; + final Future> Function(String cursor) onLoadMore; + + LongListScaffold({ + @required this.title, + this.actions, + this.trailing, + @required this.headerBuilder, + @required this.itemBuilder, + @required this.onRefresh, + @required this.onLoadMore, + }); + + @override + _LongListScaffoldState createState() => _LongListScaffoldState(); +} + +class _LongListScaffoldState extends State> { + bool loading; + bool loadingMore = false; + LongListPayload payload; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + print('long list scaffold refresh'); + setState(() { + loading = true; + }); + try { + payload = await widget.onRefresh(); + } finally { + if (mounted) { + setState(() { + loading = false; + }); + } + } + } + + Future _loadMore() async { + print('long list scaffold load more'); + setState(() { + loadingMore = true; + }); + try { + var _payload = await widget.onLoadMore(payload.cursor); + payload.totalCount = _payload.totalCount; + payload.cursor = _payload.cursor; + payload.leadingItems.addAll(_payload.leadingItems); + } finally { + if (mounted) { + setState(() { + loadingMore = false; + }); + } + } + } + + Widget _buildItem(BuildContext context, int index) { + if (index % 2 == 1) { + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.black12)), + ), + ); + } + + int realIndex = index ~/ 2; + + if (realIndex < payload.leadingItems.length) { + return widget.itemBuilder(payload.leadingItems[realIndex]); + } else if (realIndex == payload.leadingItems.length) { + var count = payload.totalCount - + payload.leadingItems.length + + payload.trailingItems.length; + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + image: DecorationImage( + image: ExactAssetImage('images/progressive-disclosure-line.png', + scale: 2), + repeat: ImageRepeat.repeatX, + ), + ), + child: Center( + child: Link( + beforeRedirect: _loadMore, + child: Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all(color: Colors.black12), + ), + child: Column( + children: [ + Text('$count hidden items', + style: TextStyle(color: Colors.black87, fontSize: 15)), + Padding(padding: EdgeInsets.only(top: 4)), + loadingMore + ? CupertinoActivityIndicator() + : Text( + 'Load more...', + style: + TextStyle(color: Colors.blueAccent, fontSize: 16), + ), + ], + ), + ), + ), + ), + ); + } else { + return widget.itemBuilder( + payload.trailingItems[realIndex - payload.leadingItems.length - 1]); + } + } + + int get _itemCount { + int count = payload.leadingItems.length + payload.trailingItems.length; + if (payload.totalCount > count) { + count++; + } + return 2 * count; // including bottom border + } + + Widget _buildSliver() { + if (loading) { + return SliverToBoxAdapter(child: Loading(more: false)); + } else { + return SliverList( + delegate: + SliverChildBuilderDelegate(_buildItem, childCount: _itemCount), + ); + } + } + + Widget _buildBody() { + if (loading) { + return Loading(more: false); + } else { + return ListView.builder(itemCount: _itemCount, itemBuilder: _buildItem); + } + } + + @override + Widget build(BuildContext context) { + switch (SettingsProvider.of(context).theme) { + case ThemeMap.cupertino: + List slivers = [ + CupertinoSliverRefreshControl(onRefresh: widget.onRefresh) + ]; + if (payload != null) { + slivers.add( + SliverToBoxAdapter(child: widget.headerBuilder(payload.header)), + ); + } + slivers.add(_buildSliver()); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: widget.title, + trailing: widget.trailing, + ), + child: SafeArea( + child: CustomScrollView(slivers: slivers), + ), + ); + default: + return Scaffold( + appBar: AppBar( + title: widget.title, + actions: widget.actions, + ), + body: RefreshIndicator( + onRefresh: widget.onRefresh, + child: _buildBody(), + ), + ); + } + } +} diff --git a/lib/widgets/notification_item.dart b/lib/widgets/notification_item.dart index 55efe43..ada72b8 100644 --- a/lib/widgets/notification_item.dart +++ b/lib/widgets/notification_item.dart @@ -55,9 +55,17 @@ class _NotificationItemState extends State { Widget _buildRoute(BuildContext context) { switch (payload.type) { case 'Issue': - return IssueScreen(payload.number, payload.owner, payload.name); + return IssueScreen( + number: payload.number, + owner: payload.owner, + name: payload.name, + ); case 'PullRequest': - return PullRequestScreen(payload.number, payload.owner, payload.name); + return PullRequestScreen( + number: payload.number, + owner: payload.owner, + name: payload.name, + ); case 'Release': // return default: diff --git a/lib/widgets/timeline_item.dart b/lib/widgets/timeline_item.dart index 5a70504..7ea00a5 100644 --- a/lib/widgets/timeline_item.dart +++ b/lib/widgets/timeline_item.dart @@ -7,9 +7,8 @@ import 'user_name.dart'; class TimelineItem extends StatelessWidget { final Map item; - final Map payload; - TimelineItem(this.item, this.payload); + TimelineItem(this.item); TextSpan _buildReviewText(BuildContext context, item) { switch (item['state']) { @@ -189,7 +188,9 @@ class TimelineItem extends StatelessWidget { case 'ReviewRequestedEvent': return _buildItem( iconData: Octicons.eye, - actor: payload['author']['login'], + // actor: payload['author']['login'], + // TODO: + actor: 'test', textSpan: TextSpan(children: [ TextSpan(text: ' requested a review from '), createUserSpan(item['requestedReviewer']['login']), @@ -247,10 +248,6 @@ class TimelineItem extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(10), - decoration: BoxDecoration( - border: Border( - bottom: - BorderSide(color: CupertinoColors.extraLightBackgroundGray))), child: _buildByType(context), ); } diff --git a/pubspec.yaml b/pubspec.yaml index b7062f1..8f860da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,10 @@ dependencies: github: ^4.1.0 intl: ^0.15.7 url_launcher: ^4.2.0 + uni_links: ^0.1.4 flutter_markdown: ^0.2.0 + shared_preferences: ^0.5.0 + nanoid: ^0.0.6 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -48,6 +51,9 @@ flutter: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - images/ + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware.