diff --git a/lib/cubit/pulls_bloc.dart b/lib/cubit/pulls_bloc.dart new file mode 100644 index 0000000..ae198be --- /dev/null +++ b/lib/cubit/pulls_bloc.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:gitea_client/model/pull.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import '../service/gitea_service.dart'; + +part 'pulls_event.dart'; +part 'pulls_state.dart'; + +class PullRequestBloc extends Bloc { + final GiteaService giteaService; + final String istate; + final String repoFullName; + PullRequestBloc(this.giteaService,this.repoFullName,this.istate) : super(const PullRequestState()) { + on(_onIssuesFetched, + transformer: (events, mapper) => events.switchMap(mapper),); + } + + Future _onIssuesFetched(PullRequestsFetched event, Emitter emit) async { + if (state.hasReachedMax) return; + try { + if (state.status == PullRequestStatus.initial) { + final pulls = await giteaService.getRepoPulls(owner: repoFullName.split('/')[0],repo: repoFullName.split('/')[1],state: istate,page: 1, limit: 10); + return emit(state.copyWith( + status: PullRequestStatus.success, + pulls: pulls, + loadedPages: 1, + hasReachedMax: false, + error_message: null, + )); + } + final pulls = await giteaService.getRepoPulls(owner: repoFullName.split('/')[0],repo: repoFullName.split('/')[1],state: istate,page: state.loadedPages+1, limit: 10); + final prList = List.of(state.pulls); + pulls.forEach((element) { + if(prList.where((selement) => selement.id == element.id).isEmpty) { + prList.add(element); + } + }); + emit(pulls.isEmpty + ? state.copyWith(hasReachedMax: true) + : state.copyWith( + status: PullRequestStatus.success, + pulls: prList, + loadedPages: state.loadedPages+1, + hasReachedMax: false, + error_message: null, + )); + } on Exception catch (e) { + emit(state.copyWith(status: PullRequestStatus.failure,error_message: e.toString())); + } + } +} diff --git a/lib/cubit/pulls_event.dart b/lib/cubit/pulls_event.dart new file mode 100644 index 0000000..cb7e625 --- /dev/null +++ b/lib/cubit/pulls_event.dart @@ -0,0 +1,9 @@ +part of 'pulls_bloc.dart'; + +abstract class PullRequestEvent extends Equatable { + const PullRequestEvent(); + @override + List get props => []; +} + +class PullRequestsFetched extends PullRequestEvent {} \ No newline at end of file diff --git a/lib/cubit/pulls_state.dart b/lib/cubit/pulls_state.dart new file mode 100644 index 0000000..c64fc1f --- /dev/null +++ b/lib/cubit/pulls_state.dart @@ -0,0 +1,39 @@ +part of 'pulls_bloc.dart'; + +enum PullRequestStatus { initial, success, failure } + +class PullRequestState extends Equatable { + const PullRequestState({ + this.status = PullRequestStatus.initial, + this.pulls = const [], + this.loadedPages = 0, + this.hasReachedMax = false, + this.error_message = null + }); + + final PullRequestStatus status; + final List pulls; + final int loadedPages; + final bool hasReachedMax; + final String? error_message; + + PullRequestState copyWith({ + PullRequestStatus? status, + List? pulls, + int? loadedPages, + bool? hasReachedMax, + String? error_message, + }) { + return PullRequestState( + status: status ?? this.status, + pulls: pulls ?? this.pulls, + loadedPages: loadedPages ?? this.loadedPages, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + error_message: error_message ?? this.error_message, + ); + } + + + @override + List get props => [status, pulls, hasReachedMax]; +} diff --git a/lib/model/pull.dart b/lib/model/pull.dart new file mode 100644 index 0000000..06e2dd9 --- /dev/null +++ b/lib/model/pull.dart @@ -0,0 +1,230 @@ +import 'package:gitea_client/model/repository.dart'; +import 'package:gitea_client/model/user.dart'; + +import 'issues.dart'; + +class PullRequest { + int id; + String url; + int number; + User user; + String title; + String body; + List? labels; + Milestone? milestone; + User? assignee; + User? assignees; + String state; + bool isLocked; + int comments; + String htmlUrl; + String diffUrl; + String patchUrl; + bool? mergeable; + bool merged; + String? mergedAt; + String? mergeCommitSha; + User? mergedBy; + bool? allowMaintainerEdit; + Base? base; + Base? head; + String? mergeBase; + String? dueDate; + String createdAt; + String updatedAt; + String? closedAt; + + PullRequest( + {required this.id, + required this.url, + required this.number, + required this.user, + required this.title, + required this.body, + this.labels, + this.milestone, + this.assignee, + this.assignees, + required this.state, + required this.isLocked, + required this.comments, + required this.htmlUrl, + required this.diffUrl, + required this.patchUrl, + this.mergeable, + required this.merged, + this.mergedAt, + this.mergeCommitSha, + this.mergedBy, + this.allowMaintainerEdit, + this.base, + this.head, + this.mergeBase, + this.dueDate, + required this.createdAt, + required this.updatedAt, + this.closedAt}); + + PullRequest.fromJson(Map json) + : id = json['id'], + url = json['url'], + number = json['number'], + user = User.fromJson(json['user']), + title = json['title'], + body = json['body'], + comments = json['comments'], + htmlUrl = json['html_url'], + diffUrl = json['diff_url'], + createdAt = json['created_at'], + updatedAt = json['updated_at'], + state = json['state'], + isLocked = json['is_locked'], + patchUrl = json['patch_url'], + merged = json['merged'] { + if (json['labels'] != null) { + labels = []; + json['labels'].forEach((v) { + labels!.add( Labels.fromJson(v)); + }); + } + milestone = json['milestone'] != null + ? Milestone.fromJson(json['milestone']) + : null; + assignee = json['assignee']; + assignees = json['assignees']; + mergeable = json['mergeable']; + mergedAt = json['merged_at']; + mergeCommitSha = json['merge_commit_sha']; + mergedBy = + json['merged_by'] != null ? User.fromJson(json['merged_by']) : null; + allowMaintainerEdit = json['allow_maintainer_edit']; + base = json['base'] != null ? Base.fromJson(json['base']) : null; + head = json['head'] != null ? Base.fromJson(json['head']) : null; + mergeBase = json['merge_base']; + dueDate = json['due_date']; + closedAt = json['closed_at']; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['url'] = url; + data['number'] = number; + data['user'] = user.toJson(); + data['title'] = title; + data['body'] = body; + if (labels != null) { + data['labels'] = labels!.map((v) => v.toJson()).toList(); + } + if (milestone != null) { + data['milestone'] = milestone!.toJson(); + } + data['assignee'] = assignee; + data['assignees'] = assignees; + data['state'] = state; + data['is_locked'] = isLocked; + data['comments'] = comments; + data['html_url'] = htmlUrl; + data['diff_url'] = diffUrl; + data['patch_url'] = patchUrl; + data['mergeable'] = mergeable; + data['merged'] = merged; + data['merged_at'] = mergedAt; + data['merge_commit_sha'] = mergeCommitSha; + if (mergedBy != null) { + data['merged_by'] = mergedBy!.toJson(); + } + data['allow_maintainer_edit'] = allowMaintainerEdit; + if (base != null) { + data['base'] = base!.toJson(); + } + if (head != null) { + data['head'] = head!.toJson(); + } + data['merge_base'] = mergeBase; + data['due_date'] = dueDate; + data['created_at'] = createdAt; + data['updated_at'] = updatedAt; + data['closed_at'] = closedAt; + return data; + } +} + +class Base { + String? label; + String? ref; + String? sha; + int? repoId; + Repository? repo; + + Base({this.label, this.ref, this.sha, this.repoId, this.repo}); + + Base.fromJson(Map json) { + label = json['label']; + ref = json['ref']; + sha = json['sha']; + repoId = json['repo_id']; + repo = json['repo'] != null ? Repository.fromJson(json['repo']) : null; + } + + Map toJson() { + final Map data = {}; + data['label'] = label; + data['ref'] = ref; + data['sha'] = sha; + data['repo_id'] = repoId; + if (repo != null) { + data['repo'] = repo!.toJson(); + } + return data; + } +} + +class Permissions { + bool? admin; + bool? push; + bool? pull; + + Permissions({this.admin, this.push, this.pull}); + + Permissions.fromJson(Map json) { + admin = json['admin']; + push = json['push']; + pull = json['pull']; + } + + Map toJson() { + final Map data = {}; + data['admin'] = admin; + data['push'] = push; + data['pull'] = pull; + return data; + } +} + +class InternalTracker { + bool? enableTimeTracker; + bool? allowOnlyContributorsToTrackTime; + bool? enableIssueDependencies; + + InternalTracker( + {this.enableTimeTracker, + this.allowOnlyContributorsToTrackTime, + this.enableIssueDependencies}); + + InternalTracker.fromJson(Map json) { + enableTimeTracker = json['enable_time_tracker']; + allowOnlyContributorsToTrackTime = + json['allow_only_contributors_to_track_time']; + enableIssueDependencies = json['enable_issue_dependencies']; + } + + Map toJson() { + final Map data = {}; + data['enable_time_tracker'] = enableTimeTracker; + data['allow_only_contributors_to_track_time'] = + allowOnlyContributorsToTrackTime; + data['enable_issue_dependencies'] = enableIssueDependencies; + return data; + } +} diff --git a/lib/service/gitea_service.dart b/lib/service/gitea_service.dart index 053d658..46eeb5b 100644 --- a/lib/service/gitea_service.dart +++ b/lib/service/gitea_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:gitea_client/model/ApiAccess.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:http/http.dart' as http; @@ -79,6 +80,29 @@ class GiteaService { throw Exception('error fetching posts: ${response.statusCode}'); } + Future> getRepoPulls({required String owner,required String repo,required String state,int page = 1, int limit = 10}) async{ + if(state != "all" && state != "closed" && state != "open") { + throw Exception("Wrong state provided: $state"); + } + var response = await http.get( + Uri.https(apiAccess.instance, "api/v1/repos/$owner/$repo/pulls", { + "token": apiAccess.token, + "state": state, + "page" : page.toString(), + "limit" : limit.toString(), + }), + ); + if (response.statusCode == 200) { + final body = json.decode(response.body) as List; + var result = body.map((dynamic json) { + return PullRequest.fromJson(json); + }).toList(); + + return result; + } + throw Exception('error fetching posts: ${response.statusCode}'); + } + Future> getUserRepositories([int page = 1, int limit = 10]) async{ var response = await http.get( Uri.https(apiAccess.instance, "api/v1/user/repos", { diff --git a/lib/widget/repo_overview.dart b/lib/widget/repo_overview.dart index b2837b3..f81fe99 100644 --- a/lib/widget/repo_overview.dart +++ b/lib/widget/repo_overview.dart @@ -6,7 +6,9 @@ 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'; @@ -491,9 +493,167 @@ class RepoPullRequests extends StatefulWidget { } class _RepoPullRequests extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + @override Widget build(BuildContext context) { - // TODO: implement build - throw UnimplementedError(); + 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: [ + Expanded(child: Container()), + const TabBar( + tabs: [Text("Open"), Text("Closed")], + ), + ], + ), + ), + ), + ), + body: TabBarView( + children: [ + 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( + 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( + 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().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()}, + ); + } +} +