refactor: extract long list scaffold for issue and pull request

This commit is contained in:
Rongjian Zhang 2019-02-08 14:01:25 +08:00
parent 73907f51b4
commit 650af30838
10 changed files with 517 additions and 150 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

View File

@ -7,6 +7,8 @@ import 'screens/notifications.dart';
import 'screens/search.dart'; import 'screens/search.dart';
import 'screens/profile.dart'; import 'screens/profile.dart';
import 'screens/login.dart'; import 'screens/login.dart';
import 'screens/pull_request.dart';
import 'screens/issue.dart';
class Home extends StatefulWidget { class Home extends StatefulWidget {
@override @override
@ -58,6 +60,7 @@ class _HomeState extends State<Home> {
} }
_buildScreen(int index) { _buildScreen(int index) {
// return IssueScreen(number: 29, owner: 'reactjs', name: 'rfcs');
switch (index) { switch (index) {
case 0: case 0:
return NewsScreen(); return NewsScreen();

View File

@ -1,59 +1,44 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import '../utils/utils.dart'; import '../utils/utils.dart';
import '../widgets/list_scaffold.dart'; import '../widgets/long_list_scaffold.dart';
import '../widgets/timeline_item.dart'; import '../widgets/timeline_item.dart';
import '../widgets/comment_item.dart'; import '../widgets/comment_item.dart';
import '../providers/settings.dart'; import '../providers/settings.dart';
class IssueScreen extends StatefulWidget { class IssueScreen extends StatefulWidget {
final int id; final int number;
final String owner; final String owner;
final String name; 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 @override
_IssueScreenState createState() => _IssueScreenState(); _IssueScreenState createState() => _IssueScreenState();
} }
class _IssueScreenState extends State<IssueScreen> { class _IssueScreenState extends State<IssueScreen> {
Map<String, dynamic> payload;
Widget _buildHeader() {
return Column(children: <Widget>[
Container(
// padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
payload['title'],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
CommentItem(payload),
],
),
)
]);
}
get _fullName => widget.owner + '/' + widget.name; 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() async {
Future queryIssue(
BuildContext context, int id, String owner, String name) async {
var data = await SettingsProvider.of(context).query(''' var data = await SettingsProvider.of(context).query('''
{ {
repository(owner: "$owner", name: "$name") { repository(owner: "$owner", name: "$name") {
issue(number: $id) { issue(number: $id) {
$graphqlChunk1 $graphqlChunk1
timeline(first: $pageSize) { timeline(first: $pageSize) {
totalCount
pageInfo { pageInfo {
hasNextPage hasNextPage
endCursor endCursor
@ -69,23 +54,104 @@ class _IssueScreenState extends State<IssueScreen> {
return data['repository']['issue']; 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<List> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListScaffold( return LongListScaffold(
title: Text(_fullName + ' #' + widget.id.toString()), title: Text(_fullName + ' #' + widget.number.toString()),
header: payload == null ? null : _buildHeader(), headerBuilder: (payload) {
itemCount: _items.length, return Column(children: <Widget>[
itemBuilder: (context, index) => TimelineItem(_items[index], payload), Container(
onRefresh: () async { // padding: EdgeInsets.all(10),
var _payload = child: Column(
await queryIssue(context, widget.id, widget.owner, widget.name); crossAxisAlignment: CrossAxisAlignment.start,
if (mounted) { children: <Widget>[
setState(() { Text(
payload = _payload; 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: () => ,
); );
} }
} }

View File

@ -2,29 +2,76 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import '../providers/settings.dart'; import '../providers/settings.dart';
import '../utils/utils.dart'; import '../utils/utils.dart';
import '../widgets/list_scaffold.dart'; import '../widgets/long_list_scaffold.dart';
import '../widgets/timeline_item.dart'; import '../widgets/timeline_item.dart';
import '../widgets/comment_item.dart'; import '../widgets/comment_item.dart';
class PullRequestScreen extends StatefulWidget { class PullRequestScreen extends StatefulWidget {
final int id; final int number;
final String owner; final String owner;
final String name; 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 @override
_PullRequestScreenState createState() => _PullRequestScreenState(); _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<PullRequestScreen> { class _PullRequestScreenState extends State<PullRequestScreen> {
Map<String, dynamic> payload; get owner => widget.owner;
get id => widget.number;
Future queryPullRequest(BuildContext context) async { get name => widget.name;
var owner = widget.owner;
var id = widget.id;
var name = widget.name;
Future queryPullRequest() async {
var data = await SettingsProvider.of(context).query(''' var data = await SettingsProvider.of(context).query('''
{ {
repository(owner: "$owner", name: "$name") { repository(owner: "$owner", name: "$name") {
@ -38,48 +85,12 @@ class _PullRequestScreenState extends State<PullRequestScreen> {
totalCount totalCount
} }
timeline(first: $pageSize) { timeline(first: $pageSize) {
totalCount
pageInfo { pageInfo {
hasNextPage
endCursor endCursor
} }
nodes { nodes {
$graghqlChunk $commonChunk
... 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
}
} }
} }
} }
@ -89,7 +100,45 @@ class _PullRequestScreenState extends State<PullRequestScreen> {
return data['repository']['pullRequest']; 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<List> 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']; bool merged = payload['merged'];
Color bgColor = merged ? Palette.purple : Palette.green; Color bgColor = merged ? Palette.purple : Palette.green;
IconData iconData = merged ? Octicons.git_merge : Octicons.git_pull_request; IconData iconData = merged ? Octicons.git_merge : Octicons.git_pull_request;
@ -117,47 +166,67 @@ class _PullRequestScreenState extends State<PullRequestScreen> {
get _fullName => widget.owner + '/' + widget.name; get _fullName => widget.owner + '/' + widget.name;
Widget _buildHeader() {
return Column(children: <Widget>[
Container(
// padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListScaffold( return LongListScaffold(
title: Text(_fullName + ' #' + widget.id.toString()), title: Text(_fullName + ' #' + widget.number.toString()),
header: payload == null ? null : _buildHeader(), headerBuilder: (payload) {
itemCount: _items.length, return Column(children: <Widget>[
itemBuilder: (context, index) => TimelineItem(_items[index], payload), Container(
onRefresh: () async { // padding: EdgeInsets.all(10),
var _payload = await queryPullRequest(context); child: Column(
if (mounted) { crossAxisAlignment: CrossAxisAlignment.start,
setState(() { children: <Widget>[
payload = _payload; _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: () => ,
); );
} }
} }

View File

@ -154,7 +154,7 @@ class Palette {
static const gray = Color(0xff959da5); static const gray = Color(0xff959da5);
} }
final pageSize = 20; final pageSize = 5;
final graphqlChunk1 = ''' final graphqlChunk1 = '''
title title

View File

@ -19,17 +19,17 @@ class EventItem extends StatelessWidget {
TextSpan _buildIssue(BuildContext context) { TextSpan _buildIssue(BuildContext context) {
int id = event.payload['issue']['number']; int id = event.payload['issue']['number'];
String name = event.repo.name; return createLinkSpan(context, '#' + id.toString(),
var arr = name.split('/'); () => IssueScreen.fromFullName(number: id, fullName: event.repo.name));
return createLinkSpan(
context, '#' + id.toString(), () => IssueScreen(id, arr[0], arr[1]));
} }
TextSpan _buildPullRequest(BuildContext context, int id) { TextSpan _buildPullRequest(BuildContext context, int number) {
String name = event.repo.name; return createLinkSpan(
var arr = name.split('/'); context,
return createLinkSpan(context, '#' + id.toString(), '#' + number.toString(),
() => PullRequestScreen(id, arr[0], arr[1])); () => PullRequestScreen.fromFullName(
number: number, fullName: event.repo.name),
);
} }
Widget _buildItem({ Widget _buildItem({

View File

@ -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, K> {
T header;
int totalCount;
String cursor;
List<K> leadingItems;
List<K> 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<T, K> extends StatefulWidget {
final Widget title;
final List<Widget> actions;
final Widget trailing;
final Widget Function(T headerPayload) headerBuilder;
final Widget Function(K itemPayload) itemBuilder;
final Future<LongListPayload<T, K>> Function() onRefresh;
final Future<LongListPayload<T, K>> 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<T, K> extends State<LongListScaffold<T, K>> {
bool loading;
bool loadingMore = false;
LongListPayload<T, K> payload;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
print('long list scaffold refresh');
setState(() {
loading = true;
});
try {
payload = await widget.onRefresh();
} finally {
if (mounted) {
setState(() {
loading = false;
});
}
}
}
Future<void> _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: <Widget>[
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<Widget> 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(),
),
);
}
}
}

View File

@ -55,9 +55,17 @@ class _NotificationItemState extends State<NotificationItem> {
Widget _buildRoute(BuildContext context) { Widget _buildRoute(BuildContext context) {
switch (payload.type) { switch (payload.type) {
case 'Issue': case 'Issue':
return IssueScreen(payload.number, payload.owner, payload.name); return IssueScreen(
number: payload.number,
owner: payload.owner,
name: payload.name,
);
case 'PullRequest': case 'PullRequest':
return PullRequestScreen(payload.number, payload.owner, payload.name); return PullRequestScreen(
number: payload.number,
owner: payload.owner,
name: payload.name,
);
case 'Release': case 'Release':
// return // return
default: default:

View File

@ -7,9 +7,8 @@ import 'user_name.dart';
class TimelineItem extends StatelessWidget { class TimelineItem extends StatelessWidget {
final Map<String, dynamic> item; final Map<String, dynamic> item;
final Map<String, dynamic> payload;
TimelineItem(this.item, this.payload); TimelineItem(this.item);
TextSpan _buildReviewText(BuildContext context, item) { TextSpan _buildReviewText(BuildContext context, item) {
switch (item['state']) { switch (item['state']) {
@ -189,7 +188,9 @@ class TimelineItem extends StatelessWidget {
case 'ReviewRequestedEvent': case 'ReviewRequestedEvent':
return _buildItem( return _buildItem(
iconData: Octicons.eye, iconData: Octicons.eye,
actor: payload['author']['login'], // actor: payload['author']['login'],
// TODO:
actor: 'test',
textSpan: TextSpan(children: [ textSpan: TextSpan(children: [
TextSpan(text: ' requested a review from '), TextSpan(text: ' requested a review from '),
createUserSpan(item['requestedReviewer']['login']), createUserSpan(item['requestedReviewer']['login']),
@ -247,10 +248,6 @@ class TimelineItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: EdgeInsets.all(10), padding: EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: CupertinoColors.extraLightBackgroundGray))),
child: _buildByType(context), child: _buildByType(context),
); );
} }

View File

@ -21,7 +21,10 @@ dependencies:
github: ^4.1.0 github: ^4.1.0
intl: ^0.15.7 intl: ^0.15.7
url_launcher: ^4.2.0 url_launcher: ^4.2.0
uni_links: ^0.1.4
flutter_markdown: ^0.2.0 flutter_markdown: ^0.2.0
shared_preferences: ^0.5.0
nanoid: ^0.0.6
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@ -48,6 +51,9 @@ flutter:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
assets:
- images/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.io/assets-and-images/#resolution-aware. # https://flutter.io/assets-and-images/#resolution-aware.