Basic repository list

This commit is contained in:
Balazs Toldi 2022-05-09 11:57:44 +02:00
parent 31874f88a7
commit 69704a20f0
Signed by: Bazsalanszky
GPG key ID: 6C7D440036F99D58
13 changed files with 629 additions and 31 deletions

47
lib/cubit/repo_cubit.dart Normal file
View file

@ -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<RepoState> {
RepoCubit() : super(RepoState());
}
class RepoBloc extends Bloc<RepoEvent, RepoState> {
RepoBloc({required this.giteaService}) : super(const RepoState()) {
on<RepoFetched>(_onRepoFetched);
}
final GiteaService giteaService;
Future<void> _onRepoFetched(RepoFetched event, Emitter<RepoState> 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()));
}
}
}

View file

@ -0,0 +1,8 @@
import 'package:equatable/equatable.dart';
abstract class RepoEvent extends Equatable {
@override
List<Object> get props => [];
}
class RepoFetched extends RepoEvent {}

41
lib/cubit/repo_state.dart Normal file
View file

@ -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 <Repository>[],
this.loadedPages = 0,
this.hasReachedMax = false,
this.error_message = null
});
final RepoStatus status;
final List<Repository> repos;
final int loadedPages;
final bool hasReachedMax;
final String? error_message;
RepoState copyWith({
RepoStatus? status,
List<Repository>? 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<Object> get props => [status, repos, hasReachedMax];
}

View file

@ -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;
},

251
lib/model/repository.dart Normal file
View file

@ -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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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<Object> get props => [id, owner, name];
}
class Permissions {
bool? admin;
bool? push;
bool? pull;
Permissions({this.admin, this.push, this.pull});
Permissions.fromJson(Map<String, dynamic> json) {
admin = json['admin'];
push = json['push'];
pull = json['pull'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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<String, dynamic> json) {
enableTimeTracker = json['enable_time_tracker'];
allowOnlyContributorsToTrackTime =
json['allow_only_contributors_to_track_time'];
enableIssueDependencies = json['enable_issue_dependencies'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['enable_time_tracker'] = enableTimeTracker;
data['allow_only_contributors_to_track_time'] =
allowOnlyContributorsToTrackTime;
data['enable_issue_dependencies'] = enableIssueDependencies;
return data;
}
}

View file

@ -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<String, dynamic> json) {
User.fromJson(Map<String, dynamic> 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});
}

View file

@ -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<AuthenticatedUser> getAuthenticatedUserOrError() async{
Future<User> getAuthenticatedUserOrError() async{
final user = await service.getAuthenticatedUser();
if(user.username != null) {
return user;

View file

@ -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<AuthenticatedUser> getAuthenticatedUser() async {
Future<User> 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<List<Repository>> 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');
}
}

View file

@ -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<StatefulLoginStatus> {
Future<AuthenticatedUser>? userRequest;
Future<User>? userRequest;
@override
void initState() {
userRequest = AuthenticationChecker(widget.apiAccess).getAuthenticatedUserOrError();
userRequest =
AuthenticationChecker(widget.apiAccess).getAuthenticatedUserOrError();
super.initState();
}
@ -33,28 +34,63 @@ class _StatefulLoginStatus extends State<StatefulLoginStatus> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FutureBuilder<AuthenticatedUser>(
children: [
FutureBuilder<User>(
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(),
);
}
}
)
})
],
),
));

103
lib/widget/repo_list.dart Normal file
View file

@ -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<ReposList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<RepoBloc, RepoState>(
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<RepoBloc>().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,
),
);
}
}

View file

@ -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<RepoListPage> {
@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(),
),
);
}
}

View file

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

View file

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