654 lines
22 KiB
Dart
654 lines
22 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:badges/badges.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:gitea_client/cubit/file_list_load_bloc.dart';
|
|
import 'package:gitea_client/cubit/issues_bloc.dart';
|
|
import 'package:gitea_client/cubit/pulls_bloc.dart';
|
|
import 'package:gitea_client/model/issues.dart';
|
|
import 'package:gitea_client/model/pull.dart';
|
|
import 'package:gitea_client/model/repository.dart';
|
|
import 'package:gitea_client/model/user.dart';
|
|
import 'package:gitea_client/service/gitea_service.dart';
|
|
import 'package:gitea_client/widget/code_page.dart';
|
|
|
|
import '../model/File.dart';
|
|
|
|
class RepoPage extends StatefulWidget {
|
|
final Repository repo;
|
|
final SavedUser user;
|
|
|
|
const RepoPage({Key? key, required this.repo, required this.user})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_RepoPage createState() => _RepoPage();
|
|
}
|
|
|
|
class _RepoPage extends State<RepoPage> {
|
|
int _currentIndex = 0;
|
|
|
|
late final List _screens;
|
|
|
|
@override
|
|
void initState() {
|
|
_screens = [
|
|
RepoHome(
|
|
repo: widget.repo,
|
|
user: widget.user,
|
|
),
|
|
RepoFiles(
|
|
repo: widget.repo,
|
|
user: widget.user,
|
|
),
|
|
RepoIssues(
|
|
repo: widget.repo,
|
|
user: widget.user,
|
|
),
|
|
RepoPullRequests(
|
|
repo: widget.repo,
|
|
user: widget.user,
|
|
)
|
|
];
|
|
}
|
|
|
|
void _updateIndex(int value) {
|
|
setState(() {
|
|
_currentIndex = value;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.repo.name),
|
|
),
|
|
body: _screens[_currentIndex],
|
|
bottomNavigationBar: BottomNavigationBar(
|
|
type: BottomNavigationBarType.fixed,
|
|
currentIndex: _currentIndex,
|
|
onTap: _updateIndex,
|
|
selectedItemColor: Colors.green[700],
|
|
selectedFontSize: 13,
|
|
unselectedFontSize: 13,
|
|
iconSize: 30,
|
|
items: [
|
|
const BottomNavigationBarItem(
|
|
label: "Home",
|
|
icon: Icon(Icons.home),
|
|
),
|
|
const BottomNavigationBarItem(
|
|
label: "Files",
|
|
icon: Icon(Icons.folder),
|
|
),
|
|
if (widget.repo.hasIssues!)
|
|
BottomNavigationBarItem(
|
|
label: "Issues",
|
|
icon: (widget.repo.openIssuesCount! > 0)
|
|
? Badge(
|
|
badgeContent:
|
|
Text(widget.repo.openIssuesCount!.toString()),
|
|
child: const Icon(Icons.error_outline),
|
|
)
|
|
: const Icon(Icons.error_outline),
|
|
),
|
|
if (widget.repo.hasPullRequests!)
|
|
BottomNavigationBarItem(
|
|
label: "Pull requests",
|
|
icon: (widget.repo.openPrCounter! > 0)
|
|
? Badge(
|
|
badgeContent: Text(widget.repo.openPrCounter!.toString()),
|
|
child: const Icon(Icons.mediation_rounded),
|
|
)
|
|
: const Icon(Icons.mediation_rounded),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RepoHome extends StatefulWidget {
|
|
final Repository repo;
|
|
final SavedUser user;
|
|
|
|
const RepoHome({Key? key, required this.repo, required this.user})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_RepoHome createState() => _RepoHome();
|
|
}
|
|
|
|
class _RepoHome extends State<RepoHome> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final media = MediaQuery.of(context).size;
|
|
final padding = MediaQuery.of(context).viewPadding;
|
|
return Column(children: [
|
|
/*Text(
|
|
widget.repo.fullName!,
|
|
style: Theme.of(context).textTheme.headline3,
|
|
),*/
|
|
FutureBuilder<RepoFile>(
|
|
future: readmeRequest,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError) {
|
|
return Center(
|
|
child: Column(
|
|
children: [
|
|
Text("No Readme file found!",
|
|
style: Theme.of(context).textTheme.headline6),
|
|
],
|
|
),
|
|
);
|
|
} else if (snapshot.hasData) {
|
|
var file = snapshot.data!;
|
|
final content = utf8.decode(base64.decode(file.content!));
|
|
|
|
return Center(
|
|
child: SizedBox(
|
|
width: (media.width > 600)
|
|
? media.width * 0.6
|
|
: media.width * 0.9,
|
|
height: media.height -
|
|
padding.top -
|
|
padding.bottom -
|
|
kToolbarHeight -
|
|
kBottomNavigationBarHeight -
|
|
30,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Markdown(selectable: true, data: content)),
|
|
],
|
|
)),
|
|
);
|
|
} else {
|
|
return const Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
})
|
|
]);
|
|
}
|
|
|
|
Future<RepoFile>? readmeRequest;
|
|
late final GiteaService giteaService;
|
|
|
|
@override
|
|
void initState() {
|
|
giteaService = GiteaService(apiAccess: widget.user.apiAccess);
|
|
readmeRequest = giteaService.getFile(
|
|
widget.repo.owner.username!, widget.repo.name, "README.md");
|
|
}
|
|
}
|
|
|
|
class RepoFiles extends StatefulWidget {
|
|
final Repository repo;
|
|
final SavedUser user;
|
|
|
|
const RepoFiles({Key? key, required this.repo, required this.user})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_RepoFiles createState() => _RepoFiles();
|
|
}
|
|
|
|
class _RepoFiles extends State<RepoFiles> {
|
|
String path = "/";
|
|
Future<List<RepoFile>>? readmeRequest;
|
|
late final GiteaService giteaService;
|
|
final _scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
giteaService = GiteaService(apiAccess: widget.user.apiAccess);
|
|
readmeRequest = giteaService.getFolder(
|
|
widget.repo.owner.username!, widget.repo.name, path);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (context) {
|
|
return FileListLoadBloc(
|
|
giteaService: GiteaService(apiAccess: widget.user.apiAccess))
|
|
..add(FileListLoadPathEvent("/", widget.repo.fullName!));
|
|
},
|
|
child: BlocBuilder<FileListLoadBloc, FileListLoadState>(
|
|
builder: (context, state) {
|
|
switch (state.status) {
|
|
case FileLoadStatus.failure:
|
|
String error_message = state.error_message!;
|
|
return Center(child: Text('failed to fetch $error_message'));
|
|
case FileLoadStatus.success:
|
|
if (state.files.isEmpty) {
|
|
return const Center(
|
|
child: Text('No files in current directory'));
|
|
}
|
|
var files = (state.path == "/")
|
|
? []
|
|
: [
|
|
RepoFile(
|
|
name: "..",
|
|
path: state.path,
|
|
sha: "",
|
|
type: "dir",
|
|
size: 0,
|
|
url: "",
|
|
htmlUrl: "",
|
|
gitUrl: "",
|
|
lLinks: Links(self: "", git: "", html: ""))
|
|
];
|
|
files.addAll(state.files);
|
|
return ListView.builder(
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return FileListItem(
|
|
file: files[index],
|
|
onTap: () {
|
|
if (files[index].type == "dir") {
|
|
String prev = files[index].path.contains("/")
|
|
? files[index].path.substring(
|
|
0, files[index].path.lastIndexOf('/'))
|
|
: "/";
|
|
path = (files[index].name == "..")
|
|
? prev
|
|
: files[index].path;
|
|
context.read<FileListLoadBloc>().add(FileListLoadPathEvent(path,widget.repo.fullName!));
|
|
} else {
|
|
Navigator.of(context).pushNamed("/fileview",
|
|
arguments: CodePageData(
|
|
files[index].path,
|
|
widget.repo.name,
|
|
widget.repo.owner.username!,
|
|
widget.user));
|
|
}
|
|
},
|
|
);
|
|
},
|
|
itemCount: files.length,
|
|
controller: _scrollController,
|
|
);
|
|
default:
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class FileListItem extends StatelessWidget {
|
|
final RepoFile file;
|
|
final Function onTap;
|
|
|
|
const FileListItem({Key? key, required this.file, required this.onTap})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: (file.type == "file")
|
|
? const Icon(Icons.insert_drive_file_outlined)
|
|
: const Icon(Icons.folder_outlined),
|
|
title: Text(file.name),
|
|
onTap: () => {onTap()},
|
|
);
|
|
}
|
|
}
|
|
|
|
class RepoIssues extends StatefulWidget {
|
|
final Repository repo;
|
|
final SavedUser user;
|
|
|
|
const RepoIssues({Key? key, required this.repo, required this.user})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_RepoIssues createState() => _RepoIssues();
|
|
}
|
|
|
|
class _RepoIssues extends State<RepoIssues> {
|
|
final _scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final media = MediaQuery.of(context).size;
|
|
return DefaultTabController(
|
|
length: 2,
|
|
child: DefaultTabController(
|
|
length: 2,
|
|
child: Scaffold(
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(kToolbarHeight),
|
|
child: Container(
|
|
color: Colors.lightGreen,
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Expanded(child: Container()),
|
|
const TabBar(
|
|
tabs: [Text("Open"), Text("Closed")],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
children: <Widget>[
|
|
Column(
|
|
children: [
|
|
Center(
|
|
child: SizedBox(
|
|
width: (media.width > 600) ? media.width * 0.5 : media.width * 0.9,
|
|
height: media.height-kToolbarHeight-kBottomNavigationBarHeight-kTextTabBarHeight-50,
|
|
child: BlocProvider(
|
|
create: (context) {
|
|
return IssuesBloc(GiteaService(apiAccess: widget.user.apiAccess),widget.repo.fullName!,"open")
|
|
..add(IssuesFetched());
|
|
},
|
|
child: BlocBuilder<IssuesBloc, IssueState>(
|
|
builder: (context, state) {
|
|
switch (state.status) {
|
|
case IssueStatus.failure:
|
|
String error_message = state.error_message!;
|
|
return Center(child: Text('failed to fetch $error_message'));
|
|
case IssueStatus.success:
|
|
if (state.issues.isEmpty) {
|
|
return const Center(
|
|
child: Text('No issues found'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return IssuesListItem(
|
|
issue: state.issues[index],
|
|
onTap: () {},
|
|
);
|
|
},
|
|
itemCount: state.issues.length,
|
|
controller: _scrollController,
|
|
);
|
|
default:
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
Column(
|
|
children: [
|
|
Center(
|
|
child: SizedBox(
|
|
width: (media.width > 600) ? media.width * 0.5 : media.width * 0.9,
|
|
height: media.height-kToolbarHeight-kBottomNavigationBarHeight-kTextTabBarHeight-50,
|
|
child: BlocProvider(
|
|
create: (context) {
|
|
return IssuesBloc(GiteaService(apiAccess: widget.user.apiAccess),widget.repo.fullName!,"closed")
|
|
..add(IssuesFetched());
|
|
},
|
|
child: BlocBuilder<IssuesBloc, IssueState>(
|
|
builder: (context, state) {
|
|
switch (state.status) {
|
|
case IssueStatus.failure:
|
|
String error_message = state.error_message!;
|
|
return Center(child: Text('failed to fetch $error_message'));
|
|
case IssueStatus.success:
|
|
if (state.issues.isEmpty) {
|
|
return const Center(
|
|
child: Text('No issues found'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return IssuesListItem(
|
|
issue: state.issues[index],
|
|
onTap: () {},
|
|
);
|
|
},
|
|
itemCount: state.issues.length,
|
|
controller: _scrollController,
|
|
);
|
|
default:
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController
|
|
..removeListener(_onScroll)
|
|
..dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (_isBottom) context.read<IssuesBloc>().add(IssuesFetched());
|
|
}
|
|
|
|
bool get _isBottom {
|
|
if (!_scrollController.hasClients) return false;
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.offset;
|
|
return currentScroll >= (maxScroll * 0.9);
|
|
}
|
|
}
|
|
|
|
class IssuesListItem extends StatelessWidget {
|
|
final Issue issue;
|
|
final Function onTap;
|
|
|
|
const IssuesListItem({Key? key, required this.issue, required this.onTap})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: Text("#${issue.number!}"),
|
|
title: Text(issue.title!),
|
|
onTap: () => {onTap()},
|
|
);
|
|
}
|
|
}
|
|
|
|
class RepoPullRequests extends StatefulWidget {
|
|
final Repository repo;
|
|
final SavedUser user;
|
|
|
|
const RepoPullRequests({Key? key, required this.repo, required this.user})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_RepoPullRequests createState() => _RepoPullRequests();
|
|
}
|
|
|
|
class _RepoPullRequests extends State<RepoPullRequests> {
|
|
final _scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final media = MediaQuery.of(context).size;
|
|
return DefaultTabController(
|
|
length: 2,
|
|
child: DefaultTabController(
|
|
length: 2,
|
|
child: Scaffold(
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(kToolbarHeight),
|
|
child: Container(
|
|
color: Colors.lightGreen,
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Expanded(child: Container()),
|
|
const TabBar(
|
|
tabs: [Text("Open"), Text("Closed")],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
children: <Widget>[
|
|
Column(
|
|
children: [
|
|
Center(
|
|
child: SizedBox(
|
|
width: (media.width > 600) ? media.width * 0.5 : media.width * 0.9,
|
|
height: media.height-kToolbarHeight-kBottomNavigationBarHeight-kTextTabBarHeight-50,
|
|
child: BlocProvider(
|
|
create: (context) {
|
|
return PullRequestBloc(GiteaService(apiAccess: widget.user.apiAccess),widget.repo.fullName!,"open")
|
|
..add(PullRequestsFetched());
|
|
},
|
|
child: BlocBuilder<PullRequestBloc, PullRequestState>(
|
|
builder: (context, state) {
|
|
switch (state.status) {
|
|
case PullRequestStatus.failure:
|
|
String error_message = state.error_message!;
|
|
return Center(child: Text('failed to fetch $error_message'));
|
|
case PullRequestStatus.success:
|
|
if (state.pulls.isEmpty) {
|
|
return const Center(
|
|
child: Text('No issues found'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return PullRequestListItem(
|
|
issue: state.pulls[index],
|
|
onTap: () {},
|
|
);
|
|
},
|
|
itemCount: state.pulls.length,
|
|
controller: _scrollController,
|
|
);
|
|
default:
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
Column(
|
|
children: [
|
|
Center(
|
|
child: SizedBox(
|
|
width: (media.width > 600) ? media.width * 0.5 : media.width * 0.9,
|
|
height: media.height-kToolbarHeight-kBottomNavigationBarHeight-kTextTabBarHeight-50,
|
|
child: BlocProvider(
|
|
create: (context) {
|
|
return PullRequestBloc(GiteaService(apiAccess: widget.user.apiAccess),widget.repo.fullName!,"closed")
|
|
..add(PullRequestsFetched());
|
|
},
|
|
child: BlocBuilder<PullRequestBloc, PullRequestState>(
|
|
builder: (context, state) {
|
|
switch (state.status) {
|
|
case PullRequestStatus.failure:
|
|
String error_message = state.error_message!;
|
|
return Center(child: Text('failed to fetch $error_message'));
|
|
case PullRequestStatus.success:
|
|
if (state.pulls.isEmpty) {
|
|
return const Center(
|
|
child: Text('No issues found'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return PullRequestListItem(
|
|
issue: state.pulls[index],
|
|
onTap: () {},
|
|
);
|
|
},
|
|
itemCount: state.pulls.length,
|
|
controller: _scrollController,
|
|
);
|
|
default:
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController
|
|
..removeListener(_onScroll)
|
|
..dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (_isBottom) context.read<IssuesBloc>().add(IssuesFetched());
|
|
}
|
|
|
|
bool get _isBottom {
|
|
if (!_scrollController.hasClients) return false;
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.offset;
|
|
return currentScroll >= (maxScroll * 0.9);
|
|
}
|
|
}
|
|
|
|
class PullRequestListItem extends StatelessWidget {
|
|
final PullRequest issue;
|
|
final Function onTap;
|
|
|
|
const PullRequestListItem({Key? key, required this.issue, required this.onTap})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: Text("#${issue.number}"),
|
|
title: Text(issue.title),
|
|
onTap: () => {onTap()},
|
|
);
|
|
}
|
|
}
|
|
|