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/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<Home> {
}
_buildScreen(int index) {
// return IssueScreen(number: 29, owner: 'reactjs', name: 'rfcs');
switch (index) {
case 0:
return NewsScreen();

View File

@ -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<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 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<IssueScreen> {
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
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: <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),
],
),
)
]);
},
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 '../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<PullRequestScreen> {
Map<String, dynamic> 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<PullRequestScreen> {
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<PullRequestScreen> {
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'];
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<PullRequestScreen> {
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
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: <Widget>[
Container(
// padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_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);
}
final pageSize = 20;
final pageSize = 5;
final graphqlChunk1 = '''
title

View File

@ -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({

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) {
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:

View File

@ -7,9 +7,8 @@ import 'user_name.dart';
class TimelineItem extends StatelessWidget {
final Map<String, dynamic> item;
final Map<String, dynamic> 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),
);
}

View File

@ -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.