feat: home screen style, add pull request screen
This commit is contained in:
parent
4e28608714
commit
16240278f6
|
@ -26,11 +26,30 @@ class _IosHomePageState extends State<IosHomePage> {
|
|||
tabBar: CupertinoTabBar(
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
title: Text('Home'),
|
||||
icon: Icon(Icons.rss_feed),
|
||||
title: Text('News'),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.notifications),
|
||||
icon: StreamBuilder<int>(builder: (context, snapshot) {
|
||||
int count = snapshot.data;
|
||||
print(count);
|
||||
|
||||
// https://stackoverflow.com/a/45434404
|
||||
if (count != null && count > 0) {
|
||||
return Stack(children: <Widget>[
|
||||
Icon(Icons.notifications),
|
||||
Positioned(
|
||||
// draw a red marble
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Icon(Icons.brightness_1,
|
||||
size: 8.0, color: Colors.redAccent),
|
||||
)
|
||||
]);
|
||||
} else {
|
||||
return Icon(Icons.notifications);
|
||||
}
|
||||
}),
|
||||
title: Text('Notification'),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
|
|
|
@ -5,17 +5,37 @@ import 'package:git_flux/providers/notification.dart';
|
|||
import 'package:git_flux/screens/screens.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
|
||||
class NotificationGroup {
|
||||
String fullName;
|
||||
List<Notification> items = [];
|
||||
|
||||
NotificationGroup(this.fullName);
|
||||
}
|
||||
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
@override
|
||||
NotificationScreenState createState() => NotificationScreenState();
|
||||
}
|
||||
|
||||
class NotificationScreenState extends State<NotificationScreen> {
|
||||
Widget _getRouteWidget(String type) {
|
||||
int active = 0;
|
||||
bool loading = false;
|
||||
List<NotificationGroup> groups = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(Duration(seconds: 0)).then((_) {
|
||||
_onSwitchTab(context, 0);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildRoute(Notification item) {
|
||||
String type = item.subject.type;
|
||||
switch (type) {
|
||||
case 'Issue':
|
||||
case 'PullRequest':
|
||||
return IssueScreen();
|
||||
// return IssueScreen(item.repository.);
|
||||
default:
|
||||
throw new Exception('Unhandled notification type: $type');
|
||||
}
|
||||
|
@ -40,8 +60,7 @@ class NotificationScreenState extends State<NotificationScreen> {
|
|||
splashColor: Colors.transparent,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => _getRouteWidget(item.subject.type)),
|
||||
CupertinoPageRoute(builder: (context) => _buildRoute(item)),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
|
@ -91,7 +110,9 @@ class NotificationScreenState extends State<NotificationScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupItem(BuildContext context, NotificationGroup group) {
|
||||
Widget _buildGroupItem(BuildContext context, int index) {
|
||||
var group = groups[index];
|
||||
|
||||
return Container(
|
||||
// padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
|
@ -113,6 +134,34 @@ class NotificationScreenState extends State<NotificationScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onSwitchTab(BuildContext context, int index) async {
|
||||
setState(() {
|
||||
active = index;
|
||||
loading = true;
|
||||
});
|
||||
|
||||
var ns = await ghClient.activity
|
||||
.listNotifications(all: index == 2, participating: index == 1)
|
||||
.toList();
|
||||
|
||||
NotificationProvider.of(context).countUpdate.add(ns.length);
|
||||
|
||||
Map<String, NotificationGroup> groupMap = {};
|
||||
ns.forEach((item) {
|
||||
String repo = item.repository.fullName;
|
||||
if (groupMap[repo] == null) {
|
||||
groupMap[repo] = NotificationGroup(repo);
|
||||
}
|
||||
|
||||
groupMap[repo].items.add(item);
|
||||
});
|
||||
|
||||
setState(() {
|
||||
groups = groupMap.values.toList();
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(context) {
|
||||
NotificationBloc bloc = NotificationProvider.of(context);
|
||||
|
@ -120,49 +169,35 @@ class NotificationScreenState extends State<NotificationScreen> {
|
|||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: StreamBuilder<int>(
|
||||
stream: bloc.active,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data == null) {
|
||||
return Text("loading...");
|
||||
}
|
||||
return SizedBox.expand(
|
||||
child: DefaultTextStyle(
|
||||
style: _textStyle,
|
||||
child: CupertinoSegmentedControl(
|
||||
groupValue: snapshot.data,
|
||||
onValueChanged: (int index) {
|
||||
bloc.activeUpdate.add(index);
|
||||
},
|
||||
children: {
|
||||
0: Text('Unread'),
|
||||
1: Text('Paticipating'),
|
||||
2: Text('All')
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
middle: SizedBox.expand(
|
||||
child: DefaultTextStyle(
|
||||
style: _textStyle,
|
||||
child: CupertinoSegmentedControl(
|
||||
groupValue: active,
|
||||
onValueChanged: (index) => _onSwitchTab(context, index),
|
||||
children: {
|
||||
0: Text('Unread'),
|
||||
1: Text('Paticipating'),
|
||||
2: Text('All')
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// CupertinoSliverRefreshControl(
|
||||
// onRefresh: () async {
|
||||
// return Future.delayed(Duration(seconds: 3));
|
||||
// },
|
||||
// ),
|
||||
Container(
|
||||
child: StreamBuilder<List<NotificationGroup>>(
|
||||
stream: bloc.items,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data == null) {
|
||||
return Text('loading...');
|
||||
}
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: snapshot.data
|
||||
.map((group) => _buildGroupItem(context, group))
|
||||
.toList());
|
||||
},
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: groups.length,
|
||||
itemBuilder: _buildGroupItem,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import '../widgets/user.dart';
|
||||
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(context) {
|
||||
return UserWidget('pd4d10');
|
||||
return Text("profile");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'event.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class Actor {
|
||||
Actor(this.login, this.avatarUrl);
|
||||
|
||||
String login;
|
||||
String avatarUrl;
|
||||
|
||||
factory Actor.fromJson(Map<String, dynamic> json) => _$ActorFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ActorToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Repo {
|
||||
Repo(this.name);
|
||||
|
||||
String name;
|
||||
|
||||
factory Repo.fromJson(Map<String, dynamic> json) => _$RepoFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$RepoToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Event {
|
||||
Event(this.id, this.type, this.actor, this.repo);
|
||||
|
||||
String id;
|
||||
String type;
|
||||
Actor actor;
|
||||
Repo repo;
|
||||
Map<String, dynamic> payload;
|
||||
|
||||
factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$EventToJson(this);
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'event.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Actor _$ActorFromJson(Map<String, dynamic> json) {
|
||||
return Actor(json['login'] as String, json['avatar_url'] as String);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ActorToJson(Actor instance) => <String, dynamic>{
|
||||
'login': instance.login,
|
||||
'avatar_url': instance.avatarUrl
|
||||
};
|
||||
|
||||
Repo _$RepoFromJson(Map<String, dynamic> json) {
|
||||
return Repo(json['name'] as String);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$RepoToJson(Repo instance) =>
|
||||
<String, dynamic>{'name': instance.name};
|
||||
|
||||
Event _$EventFromJson(Map<String, dynamic> json) {
|
||||
return Event(
|
||||
json['id'] as String,
|
||||
json['type'] as String,
|
||||
json['actor'] == null
|
||||
? null
|
||||
: Actor.fromJson(json['actor'] as Map<String, dynamic>),
|
||||
json['repo'] == null
|
||||
? null
|
||||
: Repo.fromJson(json['repo'] as Map<String, dynamic>))
|
||||
..payload = json['payload'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$EventToJson(Event instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'actor': instance.actor,
|
||||
'repo': instance.repo,
|
||||
'payload': instance.payload
|
||||
};
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'notification.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class Subject {
|
||||
Subject(this.title, this.type);
|
||||
|
||||
String title;
|
||||
String type;
|
||||
|
||||
factory Subject.fromJson(Map<String, dynamic> json) =>
|
||||
_$SubjectFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SubjectToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Owner {
|
||||
Owner(this.login, this.avatarUrl);
|
||||
|
||||
String login;
|
||||
String avatarUrl;
|
||||
|
||||
factory Owner.fromJson(Map<String, dynamic> json) => _$OwnerFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$OwnerToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Repository {
|
||||
Repository(this.fullName, this.type, this.onwer);
|
||||
|
||||
String fullName;
|
||||
String type;
|
||||
Owner onwer;
|
||||
|
||||
factory Repository.fromJson(Map<String, dynamic> json) =>
|
||||
_$RepositoryFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$RepositoryToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class NotificationItem {
|
||||
NotificationItem(
|
||||
this.id, this.type, this.updatedAt, this.repository, this.subject);
|
||||
|
||||
String id;
|
||||
String type;
|
||||
String updatedAt;
|
||||
Repository repository;
|
||||
Subject subject;
|
||||
Map<String, dynamic> payload;
|
||||
|
||||
factory NotificationItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationItemFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$NotificationItemToJson(this);
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Subject _$SubjectFromJson(Map<String, dynamic> json) {
|
||||
return Subject(json['title'] as String, json['type'] as String);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$SubjectToJson(Subject instance) =>
|
||||
<String, dynamic>{'title': instance.title, 'type': instance.type};
|
||||
|
||||
Owner _$OwnerFromJson(Map<String, dynamic> json) {
|
||||
return Owner(json['login'] as String, json['avatar_url'] as String);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$OwnerToJson(Owner instance) => <String, dynamic>{
|
||||
'login': instance.login,
|
||||
'avatar_url': instance.avatarUrl
|
||||
};
|
||||
|
||||
Repository _$RepositoryFromJson(Map<String, dynamic> json) {
|
||||
return Repository(
|
||||
json['full_name'] as String,
|
||||
json['type'] as String,
|
||||
json['onwer'] == null
|
||||
? null
|
||||
: Owner.fromJson(json['onwer'] as Map<String, dynamic>));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$RepositoryToJson(Repository instance) =>
|
||||
<String, dynamic>{
|
||||
'full_name': instance.fullName,
|
||||
'type': instance.type,
|
||||
'onwer': instance.onwer
|
||||
};
|
||||
|
||||
NotificationItem _$NotificationItemFromJson(Map<String, dynamic> json) {
|
||||
return NotificationItem(
|
||||
json['id'] as String,
|
||||
json['type'] as String,
|
||||
json['updated_at'] as String,
|
||||
json['repository'] == null
|
||||
? null
|
||||
: Repository.fromJson(json['repository'] as Map<String, dynamic>),
|
||||
json['subject'] == null
|
||||
? null
|
||||
: Subject.fromJson(json['subject'] as Map<String, dynamic>))
|
||||
..payload = json['payload'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$NotificationItemToJson(NotificationItem instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'updated_at': instance.updatedAt,
|
||||
'repository': instance.repository,
|
||||
'subject': instance.subject,
|
||||
'payload': instance.payload
|
||||
};
|
|
@ -1,8 +1,7 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'dart:async';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
import '../models/event.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
|
||||
class EventBloc {
|
||||
final _items = BehaviorSubject<List<Event>>(seedValue: []);
|
||||
|
|
|
@ -1,24 +1,18 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
|
||||
class NotificationBloc {
|
||||
final _groups = BehaviorSubject<List<NotificationGroup>>(seedValue: []);
|
||||
final _active = BehaviorSubject(seedValue: 0);
|
||||
final _loading = BehaviorSubject(seedValue: false);
|
||||
final _count = BehaviorSubject(seedValue: 0);
|
||||
final _updater = StreamController();
|
||||
|
||||
Stream<List<NotificationGroup>> get items => _groups.stream;
|
||||
Stream<int> get active => _active.stream;
|
||||
Stream<bool> get loading => _loading.stream;
|
||||
|
||||
Sink<int> get activeUpdate => _active.sink;
|
||||
Stream<int> get count => _count.stream;
|
||||
Sink get countUpdate => _updater.sink;
|
||||
|
||||
NotificationBloc() {
|
||||
_active.stream.listen((int index) async {
|
||||
_loading.add(true);
|
||||
_groups.add(await fetchNotifications(index));
|
||||
_loading.add(false);
|
||||
_updater.stream.listen((_) {
|
||||
_count.add(99);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export 'event.dart';
|
||||
export 'notification.dart';
|
||||
export 'search.dart';
|
||||
export 'user.dart';
|
|
@ -1,13 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class IssueScreen extends StatelessWidget {
|
||||
class IssueScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final String repo;
|
||||
IssueScreen(this.id, this.repo);
|
||||
|
||||
@override
|
||||
Widget build(context) {
|
||||
_IssueScreenState createState() => _IssueScreenState();
|
||||
}
|
||||
|
||||
class _IssueScreenState extends State<IssueScreen> {
|
||||
int active = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text("issue"),
|
||||
middle: Text(widget.repo + ' #' + widget.id.toString()),
|
||||
trailing: Icon(Icons.more_vert, size: 24),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
child: Text(widget.id.toString() + widget.repo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text("issue"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,400 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
import 'package:git_flux/widgets/widgets.dart';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
Future queryPullRequest(int id, String owner, String name) async {
|
||||
var data = await query('''
|
||||
{
|
||||
repository(owner: "$owner", name: "$name") {
|
||||
pullRequest(number: $id) {
|
||||
title
|
||||
createdAt
|
||||
body
|
||||
merged
|
||||
permalink
|
||||
additions
|
||||
deletions
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
}
|
||||
commits {
|
||||
totalCount
|
||||
}
|
||||
timeline(first: $PAGE_SIZE) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
__typename
|
||||
... on ReviewRequestedEvent {
|
||||
createdAt
|
||||
actor {
|
||||
login
|
||||
}
|
||||
requestedReviewer {
|
||||
... on User {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
... on PullRequestReview {
|
||||
createdAt
|
||||
state
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
... on IssueComment {
|
||||
createdAt
|
||||
body
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
... on LabeledEvent {
|
||||
createdAt
|
||||
label {
|
||||
name
|
||||
url
|
||||
}
|
||||
actor {
|
||||
login
|
||||
}
|
||||
}
|
||||
... on ReferencedEvent {
|
||||
createdAt
|
||||
actor {
|
||||
login
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
url
|
||||
}
|
||||
}
|
||||
... on MergedEvent {
|
||||
createdAt
|
||||
mergeRefName
|
||||
actor {
|
||||
login
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
url
|
||||
}
|
||||
}
|
||||
... on HeadRefDeletedEvent {
|
||||
createdAt
|
||||
actor {
|
||||
login
|
||||
}
|
||||
headRefName
|
||||
}
|
||||
... on Commit {
|
||||
committedDate
|
||||
oid
|
||||
author {
|
||||
user {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
return data['repository']['pullRequest'];
|
||||
}
|
||||
|
||||
class PullRequestScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final String owner;
|
||||
final String name;
|
||||
PullRequestScreen(this.id, this.owner, this.name);
|
||||
|
||||
@override
|
||||
_PullRequestScreenState createState() => _PullRequestScreenState();
|
||||
}
|
||||
|
||||
class _PullRequestScreenState extends State<PullRequestScreen> {
|
||||
int active = 0;
|
||||
Map<String, dynamic> payload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
queryPullRequest(widget.id, widget.owner, widget.name).then((_payload) {
|
||||
setState(() {
|
||||
payload = _payload;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get _fullName => widget.owner + '/' + widget.name;
|
||||
|
||||
Widget _buildBadge() {
|
||||
bool merged = payload['merged'];
|
||||
int bgColor = merged ? 0xff6f42c1 : 0xff2cbe4e;
|
||||
IconData iconData = merged ? Octicons.git_merge : Octicons.git_pull_request;
|
||||
String text = merged ? 'Merged' : 'Open';
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(bgColor),
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(iconData, color: Colors.white, size: 15),
|
||||
Text(text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _buildReviewText(BuildContext context, item) {
|
||||
switch (item['state']) {
|
||||
case 'APPROVED':
|
||||
return TextSpan(text: ' approved these changes');
|
||||
default:
|
||||
return TextSpan(text: 'not implement');
|
||||
}
|
||||
}
|
||||
|
||||
TextSpan _buildCommitText(BuildContext context, item) {}
|
||||
|
||||
Widget _buildComment(BuildContext context, item) {
|
||||
// return Text('comment');
|
||||
return Column(children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Avatar(item['author']['login'], item['author']['avatarUrl']),
|
||||
Padding(padding: EdgeInsets.only(left: 10)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UserName(item['author']['login']),
|
||||
Text('opened ' + TimeAgo.formatFromString(item['createdAt'])),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, top: 10),
|
||||
child: MarkdownBody(data: item['body']),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildItemItem({
|
||||
String actor,
|
||||
IconData iconData = Octicons.octoface,
|
||||
int iconColor = 0xff959da5,
|
||||
TextSpan textSpan,
|
||||
item,
|
||||
}) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(iconData, color: Color(iconColor), size: 16),
|
||||
Padding(padding: EdgeInsets.only(left: 4)),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(style: TextStyle(color: Colors.black), children: [
|
||||
createUserSpan(actor),
|
||||
textSpan,
|
||||
// TextSpan(text: ' ' + TimeAgo.formatFromString(item['createdAt']))
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, item) {
|
||||
switch (item['__typename']) {
|
||||
case 'IssueComment':
|
||||
return _buildComment(context, item);
|
||||
case 'ReviewRequestedEvent':
|
||||
return _buildItemItem(
|
||||
iconData: Octicons.eye,
|
||||
actor: payload['author']['login'],
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' requested a review from '),
|
||||
createUserSpan(item['requestedReviewer']['login']),
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
case 'PullRequestReview':
|
||||
return _buildItemItem(
|
||||
actor: item['author']['login'],
|
||||
iconColor: 0xff28a745,
|
||||
iconData: Octicons.check,
|
||||
textSpan: _buildReviewText(context, item),
|
||||
item: item,
|
||||
);
|
||||
case 'LabeledEvent':
|
||||
return _buildItemItem(
|
||||
actor: item['actor']['login'],
|
||||
iconData: Octicons.tag,
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' added the '),
|
||||
TextSpan(text: item['label']['name']),
|
||||
TextSpan(text: 'label'),
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
case 'ReferencedEvent':
|
||||
return _buildItemItem(
|
||||
actor: item['actor']['login'],
|
||||
iconData: Octicons.bookmark,
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' referenced this pull request from commit '),
|
||||
TextSpan(text: item['commit']['oid'].substring(0, 8)),
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
case 'MergedEvent':
|
||||
return _buildItemItem(
|
||||
actor: item['actor']['login'],
|
||||
iconData: Octicons.git_merge,
|
||||
iconColor: 0xff6f42c1,
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' merged commit '),
|
||||
TextSpan(text: item['commit']['oid'].substring(0, 8)),
|
||||
TextSpan(text: ' into '),
|
||||
TextSpan(text: item['mergeRefName']),
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
case 'HeadRefDeletedEvent':
|
||||
return _buildItemItem(
|
||||
actor: item['actor']['login'],
|
||||
iconData: Octicons.git_branch,
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' deleted the '),
|
||||
TextSpan(text: item['headRefName']),
|
||||
TextSpan(text: ' branch'),
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
case 'Commit':
|
||||
return _buildItemItem(
|
||||
actor: item['author']['user']['login'],
|
||||
iconData: Octicons.git_commit,
|
||||
textSpan: TextSpan(children: [
|
||||
TextSpan(text: ' added commit '),
|
||||
TextSpan(text: item['oid'].substring(0, 8))
|
||||
]),
|
||||
item: item,
|
||||
);
|
||||
default:
|
||||
return Text('no data', style: TextStyle(color: Colors.redAccent));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (payload == null) {
|
||||
return CupertinoActivityIndicator();
|
||||
}
|
||||
|
||||
List items = payload['timeline']['nodes'];
|
||||
|
||||
return Column(children: <Widget>[
|
||||
Container(
|
||||
// padding: EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_buildBadge(),
|
||||
],
|
||||
),
|
||||
Text(payload['title'],
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 2,
|
||||
)),
|
||||
_buildComment(context, payload),
|
||||
// ListView.builder(
|
||||
// shrinkWrap: true,
|
||||
// itemCount: comments.length,
|
||||
// itemBuilder: _buildCommentItem,
|
||||
// ),
|
||||
Column(
|
||||
children: items.map((item) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color:
|
||||
CupertinoColors.extraLightBackgroundGray))),
|
||||
child: _buildItem(context, item),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(_fullName + ' #' + widget.id.toString()),
|
||||
trailing: Icon(Icons.more_vert, size: 24),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: CupertinoSegmentedControl(
|
||||
groupValue: active,
|
||||
onValueChanged: (value) async {
|
||||
switch (value) {
|
||||
case 1:
|
||||
launch(
|
||||
'https://github.com/$_fullName/pull/${widget.id}/commits');
|
||||
break;
|
||||
case 2:
|
||||
launch(
|
||||
'https://github.com/$_fullName/pull/${widget.id}/files');
|
||||
break;
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
children: {
|
||||
0: Text('Conversation'),
|
||||
1: Text('Commits'),
|
||||
2: Text('Changes')
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildBody(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,13 +1,19 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RepoScreen extends StatefulWidget {
|
||||
final String owner;
|
||||
final String name;
|
||||
RepoScreen(this.owner, this.name);
|
||||
|
||||
class RepoScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(context) {
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text("repo"),
|
||||
),
|
||||
child: Text("repo"),
|
||||
_RepoScreenState createState() => _RepoScreenState();
|
||||
}
|
||||
|
||||
class _RepoScreenState extends State<RepoScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
child: Text(widget.owner),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export 'repo.dart';
|
||||
export 'user.dart';
|
||||
export 'issue.dart';
|
||||
export 'pull_request.dart';
|
||||
|
|
|
@ -1,9 +1,106 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import '../widgets/user.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
|
||||
class UserScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(context) {
|
||||
return UserWidget('pd4d10');
|
||||
Future queryUser(String login) async {
|
||||
var data = await query('''
|
||||
{
|
||||
user(login: "$login") {
|
||||
name
|
||||
avatarUrl
|
||||
bio
|
||||
email
|
||||
repositories {
|
||||
totalCount
|
||||
}
|
||||
starredRepositories {
|
||||
totalCount
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
}
|
||||
following {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
return data['user'];
|
||||
}
|
||||
|
||||
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
|
||||
new GlobalKey<RefreshIndicatorState>();
|
||||
|
||||
class UserScreen extends StatefulWidget {
|
||||
final String login;
|
||||
|
||||
UserScreen(this.login);
|
||||
|
||||
_UserScreenState createState() => _UserScreenState();
|
||||
}
|
||||
|
||||
class _UserScreenState extends State<UserScreen> {
|
||||
var user = null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
queryUser(widget.login).then((_user) {
|
||||
setState(() {
|
||||
user = _user;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: RefreshIndicator(
|
||||
key: _refreshIndicatorKey,
|
||||
onRefresh: () async {
|
||||
var _user = await queryUser(widget.login);
|
||||
setState(() {
|
||||
user = _user;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(vertical: 20.0),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
user['avatarUrl'],
|
||||
fit: BoxFit.fill,
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(user['followers']['totalCount'].toString()),
|
||||
Text('Followers'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// print(1);
|
||||
},
|
||||
),
|
||||
Column(
|
||||
children: <Widget>[
|
||||
Text(user['following']['totalCount'].toString()),
|
||||
Text('Following')
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,13 +31,13 @@ Future<dynamic> postWithCredentials(String url, String body) async {
|
|||
}
|
||||
|
||||
Future<dynamic> query(String query) async {
|
||||
final data =
|
||||
final res =
|
||||
await postWithCredentials('/graphql', json.encode({'query': query}));
|
||||
if (data['errors'] != null) {
|
||||
throw new Exception(data['errors'].toString());
|
||||
if (res['errors'] != null) {
|
||||
throw new Exception(res['errors'].toString());
|
||||
}
|
||||
print(data);
|
||||
return data['data'];
|
||||
print(res);
|
||||
return res['data'];
|
||||
}
|
||||
|
||||
Future<List<Event>> fetchEvents(int page) async {
|
||||
|
@ -46,27 +46,3 @@ Future<List<Event>> fetchEvents(int page) async {
|
|||
);
|
||||
return data.map<Event>((item) => Event.fromJSON(item)).toList();
|
||||
}
|
||||
|
||||
class NotificationGroup {
|
||||
String fullName;
|
||||
List<Notification> items = [];
|
||||
|
||||
NotificationGroup(this.fullName);
|
||||
}
|
||||
|
||||
Future<List<NotificationGroup>> fetchNotifications([int index = 0]) async {
|
||||
var data = await ghClient.activity
|
||||
.listNotifications(all: index == 2, participating: index == 1)
|
||||
.toList();
|
||||
|
||||
Map<String, NotificationGroup> groupMap = {};
|
||||
data.forEach((item) {
|
||||
String repo = item.repository.fullName;
|
||||
if (groupMap[repo] == null) {
|
||||
groupMap[repo] = NotificationGroup(repo);
|
||||
}
|
||||
|
||||
groupMap[repo].items.add(item);
|
||||
});
|
||||
return groupMap.values.toList();
|
||||
}
|
||||
|
|
|
@ -10,6 +10,10 @@ class TimeAgo {
|
|||
return '${_ceil(time)} ${unit}s ago';
|
||||
}
|
||||
|
||||
static String formatFromString(String str) {
|
||||
return format(DateTime.parse(str));
|
||||
}
|
||||
|
||||
static String format(DateTime time) {
|
||||
double diff =
|
||||
(DateTime.now().millisecondsSinceEpoch - time.millisecondsSinceEpoch) /
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:git_flux/screens/screens.dart';
|
||||
|
||||
class Avatar extends StatelessWidget {
|
||||
final String login;
|
||||
final String url;
|
||||
|
||||
Avatar(this.login, this.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(builder: (_) => UserScreen(login)),
|
||||
);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundImage: NetworkImage(url),
|
||||
radius: 18,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:git_flux/screens/screens.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
import 'package:git_flux/widgets/widgets.dart';
|
||||
|
||||
/// Events types:
|
||||
///
|
||||
|
@ -34,14 +35,14 @@ class EventItem extends StatelessWidget {
|
|||
case 'PullRequestEvent':
|
||||
return TextSpan(children: [
|
||||
TextSpan(text: ' ${event.payload['action']} pull request '),
|
||||
_buildPullRequest(context),
|
||||
_buildPullRequest(context, event.payload['pull_request']['number']),
|
||||
TextSpan(text: ' at '),
|
||||
_buildRepo(context),
|
||||
]);
|
||||
case 'PullRequestReviewCommentEvent':
|
||||
return TextSpan(children: [
|
||||
TextSpan(text: ' reviewed pull request '),
|
||||
_buildPullRequest(context),
|
||||
_buildPullRequest(context, event.payload['pull_request']['number']),
|
||||
TextSpan(text: ' at '),
|
||||
_buildRepo(context),
|
||||
]);
|
||||
|
@ -51,9 +52,15 @@ class EventItem extends StatelessWidget {
|
|||
_buildRepo(context)
|
||||
]);
|
||||
case 'IssueCommentEvent':
|
||||
bool isIssue = event.payload['issue']['pull_request'] == null;
|
||||
String resource = isIssue ? 'issue' : 'pull request';
|
||||
TextSpan link = isIssue
|
||||
? _buildIssue(context)
|
||||
: _buildPullRequest(context, event.payload['issue']['number']);
|
||||
|
||||
return TextSpan(children: [
|
||||
TextSpan(text: ' commented on issue '),
|
||||
_buildIssue(context),
|
||||
TextSpan(text: ' commented on $resource '),
|
||||
link,
|
||||
TextSpan(text: ' at '),
|
||||
_buildRepo(context),
|
||||
// TextSpan(text: event.payload['comment']['body'])
|
||||
|
@ -103,19 +110,23 @@ class EventItem extends StatelessWidget {
|
|||
}
|
||||
|
||||
TextSpan _buildRepo(BuildContext context) {
|
||||
return _buildLink(context, event.repo.name, () => RepoScreen());
|
||||
String name = event.repo.name;
|
||||
var arr = name.split('/');
|
||||
return _buildLink(context, name, () => RepoScreen(arr[0], arr[1]));
|
||||
}
|
||||
|
||||
TextSpan _buildIssue(BuildContext context) {
|
||||
return _buildLink(context,
|
||||
'#' + event.payload['issue']['number'].toString(), () => UserScreen());
|
||||
int id = event.payload['issue']['number'];
|
||||
String repo = event.repo.name;
|
||||
return _buildLink(
|
||||
context, '#' + id.toString(), () => IssueScreen(id, repo));
|
||||
}
|
||||
|
||||
TextSpan _buildPullRequest(BuildContext context) {
|
||||
return _buildLink(
|
||||
context,
|
||||
'#' + event.payload['pull_request']['number'].toString(),
|
||||
() => UserScreen());
|
||||
TextSpan _buildPullRequest(BuildContext context, int id) {
|
||||
String name = event.repo.name;
|
||||
var arr = name.split('/');
|
||||
return _buildLink(context, '#' + id.toString(),
|
||||
() => PullRequestScreen(id, arr[0], arr[1]));
|
||||
}
|
||||
|
||||
IconData _buildIconData(BuildContext context) {
|
||||
|
@ -147,19 +158,15 @@ class EventItem extends StatelessWidget {
|
|||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundImage: NetworkImage(event.actor.avatarUrl),
|
||||
radius: 16,
|
||||
),
|
||||
Avatar(event.actor.login, event.actor.avatarUrl),
|
||||
Padding(padding: EdgeInsets.only(left: 10)),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(color: Color(0xff24292e), height: 1.2),
|
||||
children: <TextSpan>[
|
||||
_buildLink(
|
||||
context, event.actor.login, () => UserScreen()),
|
||||
_buildLink(context, event.actor.login,
|
||||
() => UserScreen(event.actor.login)),
|
||||
_buildEvent(context),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:git_flux/utils/utils.dart';
|
||||
|
||||
Future queryUser(String login) async {
|
||||
var data = await query('''
|
||||
{
|
||||
user(login: "$login") {
|
||||
name
|
||||
avatarUrl
|
||||
bio
|
||||
email
|
||||
repositories {
|
||||
totalCount
|
||||
}
|
||||
starredRepositories {
|
||||
totalCount
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
}
|
||||
following {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
''');
|
||||
return data['user'];
|
||||
}
|
||||
|
||||
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
|
||||
new GlobalKey<RefreshIndicatorState>();
|
||||
|
||||
class UserWidget extends StatefulWidget {
|
||||
final String login;
|
||||
|
||||
UserWidget(this.login);
|
||||
|
||||
_UserWidgetState createState() => _UserWidgetState();
|
||||
}
|
||||
|
||||
class _UserWidgetState extends State<UserWidget> {
|
||||
var user = null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
queryUser(widget.login).then((_user) {
|
||||
setState(() {
|
||||
user = _user;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: RefreshIndicator(
|
||||
key: _refreshIndicatorKey,
|
||||
onRefresh: () async {
|
||||
var _user = await queryUser(widget.login);
|
||||
setState(() {
|
||||
user = _user;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(vertical: 20.0),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
user['avatarUrl'],
|
||||
fit: BoxFit.fill,
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(user['followers']['totalCount'].toString()),
|
||||
Text('Followers'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// print(1);
|
||||
},
|
||||
),
|
||||
Column(
|
||||
children: <Widget>[
|
||||
Text(user['following']['totalCount'].toString()),
|
||||
Text('Following')
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:git_flux/screens/screens.dart';
|
||||
|
||||
final style = TextStyle(fontWeight: FontWeight.w600);
|
||||
|
||||
TextSpan createUserSpan(String login) {
|
||||
return TextSpan(text: login, style: style);
|
||||
}
|
||||
|
||||
class UserName extends StatelessWidget {
|
||||
final String login;
|
||||
|
||||
UserName(this.login);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(builder: (_) => UserScreen(login)),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: Text(login, style: style),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export 'avatar.dart';
|
||||
export 'event.dart';
|
||||
export 'user_name.dart';
|
|
@ -24,6 +24,8 @@ dependencies:
|
|||
uri: ^0.11.3
|
||||
github: ^4.1.0
|
||||
intl: ^0.15.7
|
||||
url_launcher: ^4.2.0
|
||||
flutter_markdown: ^0.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in New Issue