From 69704a20f0a49294a90b68f501c736da521c6c86 Mon Sep 17 00:00:00 2001 From: Balazs Toldi Date: Mon, 9 May 2022 11:57:44 +0200 Subject: [PATCH] Basic repository list --- lib/cubit/repo_cubit.dart | 47 +++++ lib/cubit/repo_event.dart | 8 + lib/cubit/repo_state.dart | 41 ++++ lib/main.dart | 7 + lib/model/repository.dart | 251 +++++++++++++++++++++++++ lib/model/user.dart | 14 +- lib/service/AuthenticationChecker.dart | 6 +- lib/service/gitea_service.dart | 36 +++- lib/widget/login_status.dart | 68 +++++-- lib/widget/repo_list.dart | 103 ++++++++++ lib/widget/repo_list_page.dart | 34 ++++ pubspec.lock | 42 +++++ pubspec.yaml | 3 + 13 files changed, 629 insertions(+), 31 deletions(-) create mode 100644 lib/cubit/repo_cubit.dart create mode 100644 lib/cubit/repo_event.dart create mode 100644 lib/cubit/repo_state.dart create mode 100644 lib/model/repository.dart create mode 100644 lib/widget/repo_list.dart create mode 100644 lib/widget/repo_list_page.dart diff --git a/lib/cubit/repo_cubit.dart b/lib/cubit/repo_cubit.dart new file mode 100644 index 0000000..a5e759b --- /dev/null +++ b/lib/cubit/repo_cubit.dart @@ -0,0 +1,47 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:gitea_client/cubit/repo_event.dart'; +import 'package:gitea_client/model/repository.dart'; +import 'package:gitea_client/service/gitea_service.dart'; + +part 'repo_state.dart'; + +class RepoCubit extends Cubit { + RepoCubit() : super(RepoState()); +} + +class RepoBloc extends Bloc { + RepoBloc({required this.giteaService}) : super(const RepoState()) { + on(_onRepoFetched); + } + + final GiteaService giteaService; + + Future _onRepoFetched(RepoFetched event, Emitter emit) async { + if (state.hasReachedMax) return; + try { + if (state.status == RepoStatus.initial) { + final repos = await giteaService.getUserRepositories(); + return emit(state.copyWith( + status: RepoStatus.success, + repos: repos, + loadedPages: 1, + hasReachedMax: false, + error_message: null, + )); + } + final repos = await giteaService.getUserRepositories(state.loadedPages+1); + emit(repos.isEmpty + ? state.copyWith(hasReachedMax: true) + : state.copyWith( + status: RepoStatus.success, + repos: List.of(state.repos)..addAll(repos), + loadedPages: state.loadedPages+1, + hasReachedMax: false, + error_message: null, + )); + } on Exception catch (e) { + emit(state.copyWith(status: RepoStatus.failure,error_message: e.toString())); + } + } +} diff --git a/lib/cubit/repo_event.dart b/lib/cubit/repo_event.dart new file mode 100644 index 0000000..9d81e6f --- /dev/null +++ b/lib/cubit/repo_event.dart @@ -0,0 +1,8 @@ +import 'package:equatable/equatable.dart'; + +abstract class RepoEvent extends Equatable { + @override + List get props => []; +} + +class RepoFetched extends RepoEvent {} \ No newline at end of file diff --git a/lib/cubit/repo_state.dart b/lib/cubit/repo_state.dart new file mode 100644 index 0000000..ba806fd --- /dev/null +++ b/lib/cubit/repo_state.dart @@ -0,0 +1,41 @@ +part of 'repo_cubit.dart'; + +enum RepoStatus { initial, success, failure } + + +class RepoState extends Equatable { + const RepoState({ + this.status = RepoStatus.initial, + this.repos = const [], + this.loadedPages = 0, + this.hasReachedMax = false, + this.error_message = null + }); + + final RepoStatus status; + final List repos; + final int loadedPages; + final bool hasReachedMax; + final String? error_message; + + RepoState copyWith({ + RepoStatus? status, + List? repos, + int? loadedPages, + bool? hasReachedMax, + String? error_message, + }) { + return RepoState( + status: status ?? this.status, + repos: repos ?? this.repos, + loadedPages: loadedPages ?? this.loadedPages, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + error_message: error_message ?? this.error_message, + ); + } + + + @override + List get props => [status, repos, hasReachedMax]; + +} diff --git a/lib/main.dart b/lib/main.dart index aca3035..bdfde2e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:gitea_client/model/user.dart'; import 'package:gitea_client/widget/login_form.dart'; import 'package:gitea_client/widget/login_status.dart'; +import 'package:gitea_client/widget/repo_list_page.dart'; import 'model/ApiAccess.dart'; @@ -32,6 +34,11 @@ class MyApp extends StatelessWidget { apiAccess: route.arguments as ApiAccess, ), ); + case "/repolist": + return MaterialPageRoute( + settings: const RouteSettings(name: "/repolist"), + builder: (context) => RepoListPage(savedUser: route.arguments as SavedUser), + ); } return null; }, diff --git a/lib/model/repository.dart b/lib/model/repository.dart new file mode 100644 index 0000000..9d73cd7 --- /dev/null +++ b/lib/model/repository.dart @@ -0,0 +1,251 @@ +import 'package:equatable/equatable.dart'; +import 'package:gitea_client/model/user.dart'; + +class Repository extends Equatable { + int id; + User owner; + String name; + String? fullName; + String? description; + bool? empty; + bool? private; + bool? fork; + bool? template; + bool? mirror; + int? size; + String? language; + String? languagesUrl; + String? htmlUrl; + String? sshUrl; + String? cloneUrl; + String? originalUrl; + String? website; + int? starsCount; + int? forksCount; + int? watchersCount; + int? openIssuesCount; + int? openPrCounter; + int? releaseCounter; + String? defaultBranch; + bool? archived; + String? createdAt; + String? updatedAt; + Permissions? permissions; + bool? hasIssues; + InternalTracker? internalTracker; + bool? hasWiki; + bool? hasPullRequests; + bool? hasProjects; + bool? ignoreWhitespaceConflicts; + bool? allowMergeCommits; + bool? allowRebase; + bool? allowRebaseExplicit; + bool? allowSquashMerge; + String? defaultMergeStyle; + String? avatarUrl; + bool? internal; + String? mirrorInterval; + String? mirrorUpdated; + + Repository( + {required this.id, + required this.owner, + required this.name, + this.fullName, + this.description, + this.empty, + this.private, + this.fork, + this.template, + this.mirror, + this.size, + this.language, + this.languagesUrl, + this.htmlUrl, + this.sshUrl, + this.cloneUrl, + this.originalUrl, + this.website, + this.starsCount, + this.forksCount, + this.watchersCount, + this.openIssuesCount, + this.openPrCounter, + this.releaseCounter, + this.defaultBranch, + this.archived, + this.createdAt, + this.updatedAt, + this.permissions, + this.hasIssues, + this.internalTracker, + this.hasWiki, + this.hasPullRequests, + this.hasProjects, + this.ignoreWhitespaceConflicts, + this.allowMergeCommits, + this.allowRebase, + this.allowRebaseExplicit, + this.allowSquashMerge, + this.defaultMergeStyle, + this.avatarUrl, + this.internal, + this.mirrorInterval, + this.mirrorUpdated}); + + Repository.fromJson(Map json) + : name = json['name'], + id = json['id'], + owner = User.fromJson(json['owner']) { + fullName = json['full_name']; + description = json['description']; + empty = json['empty']; + private = json['private']; + fork = json['fork']; + template = json['template']; + mirror = json['mirror']; + size = json['size']; + language = json['language']; + languagesUrl = json['languages_url']; + htmlUrl = json['html_url']; + sshUrl = json['ssh_url']; + cloneUrl = json['clone_url']; + originalUrl = json['original_url']; + website = json['website']; + starsCount = json['stars_count']; + forksCount = json['forks_count']; + watchersCount = json['watchers_count']; + openIssuesCount = json['open_issues_count']; + openPrCounter = json['open_pr_counter']; + releaseCounter = json['release_counter']; + defaultBranch = json['default_branch']; + archived = json['archived']; + createdAt = json['created_at']; + updatedAt = json['updated_at']; + permissions = json['permissions'] != null + ? Permissions.fromJson(json['permissions']) + : null; + hasIssues = json['has_issues']; + internalTracker = json['internal_tracker'] != null + ? InternalTracker.fromJson(json['internal_tracker']) + : null; + hasWiki = json['has_wiki']; + hasPullRequests = json['has_pull_requests']; + hasProjects = json['has_projects']; + ignoreWhitespaceConflicts = json['ignore_whitespace_conflicts']; + allowMergeCommits = json['allow_merge_commits']; + allowRebase = json['allow_rebase']; + allowRebaseExplicit = json['allow_rebase_explicit']; + allowSquashMerge = json['allow_squash_merge']; + defaultMergeStyle = json['default_merge_style']; + avatarUrl = json['avatar_url']; + internal = json['internal']; + mirrorInterval = json['mirror_interval']; + mirrorUpdated = json['mirror_updated']; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['owner'] = owner.toJson(); + data['name'] = name; + data['full_name'] = fullName; + data['description'] = description; + data['empty'] = empty; + data['private'] = private; + data['fork'] = fork; + data['template'] = template; + data['mirror'] = mirror; + data['size'] = size; + data['language'] = language; + data['languages_url'] = languagesUrl; + data['html_url'] = htmlUrl; + data['ssh_url'] = sshUrl; + data['clone_url'] = cloneUrl; + data['original_url'] = originalUrl; + data['website'] = website; + data['stars_count'] = starsCount; + data['forks_count'] = forksCount; + data['watchers_count'] = watchersCount; + data['open_issues_count'] = openIssuesCount; + data['open_pr_counter'] = openPrCounter; + data['release_counter'] = releaseCounter; + data['default_branch'] = defaultBranch; + data['archived'] = archived; + data['created_at'] = createdAt; + data['updated_at'] = updatedAt; + if (permissions != null) { + data['permissions'] = permissions!.toJson(); + } + data['has_issues'] = hasIssues; + if (internalTracker != null) { + data['internal_tracker'] = internalTracker!.toJson(); + } + data['has_wiki'] = hasWiki; + data['has_pull_requests'] = hasPullRequests; + data['has_projects'] = hasProjects; + data['ignore_whitespace_conflicts'] = ignoreWhitespaceConflicts; + data['allow_merge_commits'] = allowMergeCommits; + data['allow_rebase'] = allowRebase; + data['allow_rebase_explicit'] = allowRebaseExplicit; + data['allow_squash_merge'] = allowSquashMerge; + data['default_merge_style'] = defaultMergeStyle; + data['avatar_url'] = avatarUrl; + data['internal'] = internal; + data['mirror_interval'] = mirrorInterval; + data['mirror_updated'] = mirrorUpdated; + return data; + } + + @override + List get props => [id, owner, name]; +} + +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/model/user.dart b/lib/model/user.dart index a591830..6e2711f 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,6 +1,8 @@ import 'dart:convert'; -class AuthenticatedUser { +import 'package:gitea_client/model/ApiAccess.dart'; + +class User { int? id; String? login; String? fullName; @@ -22,7 +24,7 @@ class AuthenticatedUser { int? starredReposCount; String? username; - AuthenticatedUser( + User( {this.id, this.login, this.fullName, @@ -44,7 +46,7 @@ class AuthenticatedUser { this.starredReposCount, this.username}); - AuthenticatedUser.fromJson(Map json) { + User.fromJson(Map json) { id = json['id']; login = json['login']; fullName = json['full_name']; @@ -92,3 +94,9 @@ class AuthenticatedUser { return data; } } + +class SavedUser { + final User authedUser; + final ApiAccess apiAccess; + const SavedUser({required this.authedUser,required this.apiAccess}); +} diff --git a/lib/service/AuthenticationChecker.dart b/lib/service/AuthenticationChecker.dart index bb8b6b8..24b9f61 100644 --- a/lib/service/AuthenticationChecker.dart +++ b/lib/service/AuthenticationChecker.dart @@ -5,12 +5,12 @@ import '../model/user.dart'; class AuthenticationChecker { final ApiAccess apiAccess; - late GiteaServie service; + late GiteaService service; AuthenticationChecker(this.apiAccess) { - service = GiteaServie(apiAccess.instance, apiAccess.token); + service = GiteaService(apiAccess: apiAccess); } - Future getAuthenticatedUserOrError() async{ + Future getAuthenticatedUserOrError() async{ final user = await service.getAuthenticatedUser(); if(user.username != null) { return user; diff --git a/lib/service/gitea_service.dart b/lib/service/gitea_service.dart index 11905a1..030a47f 100644 --- a/lib/service/gitea_service.dart +++ b/lib/service/gitea_service.dart @@ -1,25 +1,43 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:gitea_client/model/ApiAccess.dart'; +import 'package:gitea_client/model/repository.dart'; import 'package:gitea_client/model/user.dart'; import 'package:http/http.dart' as http; -AuthenticatedUser _parseAuthenticatedUserResponse(String message){ - return AuthenticatedUser.fromJson(jsonDecode(message)); +User _parseAuthenticatedUserResponse(String message){ + return User.fromJson(jsonDecode(message)); } -class GiteaServie { - final String apiUrl; - final String _token; +class GiteaService { + final ApiAccess apiAccess; - const GiteaServie(this.apiUrl, this._token); + const GiteaService({ required this.apiAccess}); - Future getAuthenticatedUser() async { + Future getAuthenticatedUser() async { var response = await http.get( - Uri.https(apiUrl, "api/v1/user", { - "token": _token, + Uri.https(apiAccess.instance, "api/v1/user", { + "token": apiAccess.token, }), ); return compute(_parseAuthenticatedUserResponse, response.body); } +// + Future> getUserRepositories([int page = 1, int limit = 10]) async{ + var response = await http.get( + Uri.https(apiAccess.instance, "api/v1/user/repos", { + "token": apiAccess.token, + "page" : page.toString(), + "limit" : limit.toString(), + }), + ); + if (response.statusCode == 200) { + final body = json.decode(response.body) as List; + return body.map((dynamic json) { + return Repository.fromJson(json); + }).toList(); + } + throw Exception('error fetching posts'); + } } \ No newline at end of file diff --git a/lib/widget/login_status.dart b/lib/widget/login_status.dart index 9a1504f..ed281d7 100644 --- a/lib/widget/login_status.dart +++ b/lib/widget/login_status.dart @@ -6,20 +6,21 @@ import '../service/AuthenticationChecker.dart'; class StatefulLoginStatus extends StatefulWidget { final ApiAccess apiAccess; - const StatefulLoginStatus({Key? key, required this.apiAccess}) : super(key: key); + + const StatefulLoginStatus({Key? key, required this.apiAccess}) + : super(key: key); @override _StatefulLoginStatus createState() => _StatefulLoginStatus(); } class _StatefulLoginStatus extends State { - - - Future? userRequest; + Future? userRequest; @override void initState() { - userRequest = AuthenticationChecker(widget.apiAccess).getAuthenticatedUserOrError(); + userRequest = + AuthenticationChecker(widget.apiAccess).getAuthenticatedUserOrError(); super.initState(); } @@ -33,30 +34,65 @@ class _StatefulLoginStatus extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FutureBuilder( + children: [ + FutureBuilder( future: userRequest, builder: (context, snapshot) { - if (snapshot.hasError){ + if (snapshot.hasError) { return Center( - child: Text( - "Hiba történt: ${snapshot.error}" + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(15), + child: const Icon( + Icons.error, + color: Colors.redAccent, + size: 60, + ), + ), + Text("Hiba történt: ${snapshot.error}", + style: Theme.of(context).textTheme.headline6), + ], ), ); - } else if (snapshot.hasData){ + } else if (snapshot.hasData) { var user = snapshot.data!; final username = user.username; - return Text("Logged in as $username"); - + final avatar = user.avatarUrl; + return Center( + child: Column( + children: [ + const Icon( + Icons.check, + color: Colors.green, + size: 60, + ), + Text( + "Logged in as $username", + style: Theme.of(context).textTheme.headline6, + ), + Container( + padding: const EdgeInsets.all(15), + child: Image.network( + avatar!, + width: 100, + )), + ElevatedButton( + onPressed: () => { + Navigator.pushNamed(context, "/repolist",arguments: + SavedUser(authedUser: user, apiAccess: widget.apiAccess)) + }, + child: const Text("Start using Gitea")) + ], + )); } else { return Center( child: CircularProgressIndicator(), ); } - } - ) + }) ], ), )); } -} \ No newline at end of file +} diff --git a/lib/widget/repo_list.dart b/lib/widget/repo_list.dart new file mode 100644 index 0000000..9d08f62 --- /dev/null +++ b/lib/widget/repo_list.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gitea_client/cubit/repo_event.dart'; +import 'package:gitea_client/model/repository.dart'; + +import '../cubit/repo_cubit.dart'; + +class ReposList extends StatefulWidget { + @override + _ReposListState createState() => _ReposListState(); +} + +class _ReposListState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + switch (state.status) { + case RepoStatus.failure: + String error_message = state.error_message!; + return Center(child: Text('failed to fetch $error_message')); + case RepoStatus.success: + if (state.repos.isEmpty) { + return const Center(child: Text('no posts')); + } + return ListView.builder( + itemBuilder: (BuildContext context, int index) { + return index >= state.repos.length + ? BottomLoader() + : RepoListItem(post: state.repos[index]); + }, + itemCount: state.hasReachedMax + ? state.repos.length + : state.repos.length + 1, + controller: _scrollController, + ); + default: + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) context.read().add(RepoFetched()); + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } +} + +class BottomLoader extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ); + } +} + +class RepoListItem extends StatelessWidget { + const RepoListItem({Key? key, required this.post}) : super(key: key); + + final Repository post; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Material( + child: ListTile( + leading: Text('${post.id}', style: textTheme.caption), + title: Text(post.name), + isThreeLine: true, + subtitle: Text(post.owner.username!), + dense: true, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widget/repo_list_page.dart b/lib/widget/repo_list_page.dart new file mode 100644 index 0000000..c671c81 --- /dev/null +++ b/lib/widget/repo_list_page.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gitea_client/cubit/repo_event.dart'; +import 'package:gitea_client/cubit/repo_cubit.dart'; +import 'package:gitea_client/model/user.dart'; +import 'package:gitea_client/service/gitea_service.dart'; +import 'package:gitea_client/widget/repo_list.dart'; + +class RepoListPage extends StatefulWidget { + final SavedUser savedUser; + + const RepoListPage({Key? key,required this.savedUser}) : super (key: key); + + @override + _RepoListPage createState() => _RepoListPage(); + +} + +class _RepoListPage extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Repositories"), + leading: IconButton(icon: const Icon(Icons.menu),onPressed: ()=> {},), + ), + body: BlocProvider( + create: (_) => RepoBloc(giteaService: GiteaService(apiAccess: widget.savedUser.apiAccess))..add(RepoFetched()), + child: ReposList(), + ), + ); + } + +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index aec2822..c5dbdd2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,6 +29,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + bloc: + dependency: transitive + description: + name: bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.3" boolean_selector: dependency: transitive description: @@ -113,6 +120,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.3" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" fake_async: dependency: transitive description: @@ -132,6 +146,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.1" flutter_lints: dependency: "direct dev" description: @@ -238,6 +259,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -259,6 +287,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.2" pub_semver: dependency: transitive description: @@ -313,6 +348,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + stream_transform: + dependency: "direct main" + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f8cbd22..b5eb12a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + equatable: ^2.0.3 + flutter_bloc: ^8.0.1 + stream_transform: ^2.0.0 dev_dependencies: flutter_test: