Basic search page
All checks were successful
ci/woodpecker/push/flutterBuild Pipeline was successful

This commit is contained in:
Bazsalanszky 2022-05-15 20:58:46 +02:00
parent ae9b173742
commit 676e1540c7
Signed by: Bazsalanszky
GPG key ID: B40814F4EFE23F96
8 changed files with 433 additions and 21 deletions

View file

@ -0,0 +1,87 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:stream_transform/stream_transform.dart';
import '../model/repository.dart';
import '../service/gitea_service.dart';
part 'search_event.dart';
part 'search_state.dart';
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final GiteaService giteaService;
SearchBloc({required this.giteaService}) : super(const SearchState()) {
on<SearchInputEvent>(
_SearchInputEvent,
transformer: (events, mapper) => events.switchMap(mapper),
);
on<SearchFetchedEvent>(
_SearchFetchedEvent,
transformer: (events, mapper) => events.switchMap(mapper),
);
}
Future<void> _SearchFetchedEvent(
SearchFetchedEvent event, Emitter<SearchState> emit) async {
if (state.hasReachedMax) return;
if (state.queryString.isEmpty) return;
try {
if (state.status == SearchStatus.initial) {
final repos = await giteaService.searchRepo(
query: state.queryString, page: 1, limit: 100);
return emit(state.copyWith(
status: SearchStatus.success,
repos: repos,
loadedPages: 1,
hasReachedMax: false,
error_message: null,
));
}
final repos = await giteaService.searchRepo(
query: state.queryString, page: state.loadedPages + 1, limit: 100);
final repoList = List.of(state.repos);
repos.forEach((element) {
if (repoList.where((selement) => selement.id == element.id).isEmpty) {
repoList.add(element);
}
});
emit(repos.isEmpty
? state.copyWith(hasReachedMax: true)
: state.copyWith(
status: SearchStatus.success,
repos: repoList,
loadedPages: state.loadedPages + 1,
hasReachedMax: false,
error_message: null,
));
} on Exception catch (e) {
emit(state.copyWith(
status: SearchStatus.failure, error_message: e.toString()));
}
}
Future<void> _SearchInputEvent(
SearchInputEvent event, Emitter<SearchState> emit) async {
if (state.queryString.isEmpty) return;
print(event.queryText);
try {
final repos = await giteaService.searchRepo(
query: event.queryText, page: 1, limit: 100);
return emit(state.copyWith(
queryString: event.queryText,
status: SearchStatus.success,
repos: repos,
loadedPages: 1,
hasReachedMax: false,
error_message: null,
));
} on Exception catch (e) {
emit(state.copyWith(
status: SearchStatus.failure, error_message: e.toString()));
}
}
}

View file

@ -0,0 +1,20 @@
part of 'search_bloc.dart';
abstract class SearchEvent extends Equatable {
const SearchEvent();
}
class SearchInputEvent extends SearchEvent {
final String queryText;
const SearchInputEvent(this.queryText);
@override
List<Object?> get props => [];
}
class SearchFetchedEvent extends SearchEvent {
@override
List<Object?> get props => [];
}

View file

@ -0,0 +1,43 @@
part of 'search_bloc.dart';
enum SearchStatus { initial, success, failure }
class SearchState extends Equatable {
const SearchState({
this.queryString = "",
this.status = SearchStatus.initial,
this.repos = const <Repository>[],
this.loadedPages = 0,
this.hasReachedMax = false,
this.error_message = null
});
final String queryString;
final SearchStatus status;
final List<Repository> repos;
final int loadedPages;
final bool hasReachedMax;
final String? error_message;
SearchState copyWith({
String? queryString,
SearchStatus? status,
List<Repository>? repos,
int? loadedPages,
bool? hasReachedMax,
String? error_message,
}) {
return SearchState(
queryString: queryString ?? this.queryString,
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

@ -6,6 +6,7 @@ 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 'package:gitea_client/widget/repo_overview.dart';
import 'package:gitea_client/widget/search_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'model/ApiAccess.dart';
@ -41,7 +42,13 @@ class MyApp extends StatelessWidget {
return MaterialPageRoute(
settings: const RouteSettings(name: "/repolist"),
builder: (context) =>
RepoListPage(savedUser: route.arguments as SavedUser),
MainPage(savedUser: route.arguments as SavedUser),
);
case "/search":
return MaterialPageRoute(
settings: const RouteSettings(name: "/search"),
builder: (context) =>
SearchPage(user: route.arguments as SavedUser),
);
case "/repopage":
final repouser = route.arguments as RepoUser;
@ -55,6 +62,7 @@ class MyApp extends StatelessWidget {
settings: const RouteSettings(name: "/fileview"),
builder: (context) =>
CodePage(filePath: data.filePath, repo: data.repo, owner: data.owner, user: data.user));
}
return null;
},

View file

@ -72,4 +72,24 @@ class GiteaService {
}
throw Exception('error fetching posts');
}
Future<List<Repository>> searchRepo({required String query,int page = 1, int limit = 10}) async{
var response = await http.get(
Uri.https(apiAccess.instance, "api/v1/user/repos/search", {
"token": apiAccess.token,
"q": query,
"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 Repository.fromJson(json);
}).toList();
return result;
}
throw Exception('error fetching posts');
}
}

View file

@ -2,22 +2,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/ApiAccess.dart';
import 'package:gitea_client/model/user.dart';
import 'package:gitea_client/service/gitea_service.dart';
import 'package:gitea_client/widget/repo_list.dart';
import 'package:gitea_client/widget/search_page.dart';
class RepoListPage extends StatefulWidget {
class MainPage extends StatefulWidget {
final SavedUser savedUser;
const RepoListPage({Key? key, required this.savedUser}) : super(key: key);
const MainPage({Key? key, required this.savedUser}) : super(key: key);
@override
_RepoListPage createState() => _RepoListPage();
_MainPage createState() => _MainPage();
}
class _RepoListPage extends State<RepoListPage> {
class _MainPage extends State<MainPage> {
int pageIndex = 0;
final GlobalKey<ScaffoldState> _key = GlobalKey();
late final List pages;
@override
void initState() {
pages = [
RepoListPage(user: widget.savedUser),
SearchPage(user: widget.savedUser)
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -38,35 +50,60 @@ class _RepoListPage extends State<RepoListPage> {
color: Colors.green,
),
child: Row(
children: [
Container(padding: EdgeInsets.all(5),child: Image.network(widget.savedUser.authedUser.avatarUrl!,width: 60,)),
Container(
padding: EdgeInsets.all(5),
child: Image.network(
widget.savedUser.authedUser.avatarUrl!,
width: 60,
)),
Text(
widget.savedUser.authedUser.username!,
style:
TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
],
),
),
Container(
color: Colors.lightGreen[100],
child: const ListTile(
color: (pageIndex == 0) ? Colors.lightGreen[100] : Colors.white,
child: ListTile(
title: Text('My Repositories'),
onTap: () => {
setState(() => {pageIndex = 0})
},
),
),
const ListTile(
title: Text('Explore'),
Container(
color: (pageIndex == 1) ? Colors.lightGreen[100] : Colors.white,
child: ListTile(
title: const Text('Explore'),
onTap: () => {
setState(() => {pageIndex = 1})
},
),
),
],
),
),
body: BlocProvider(
create: (_) => RepoBloc(
giteaService: GiteaService(apiAccess: widget.savedUser.apiAccess))
..add(RepoFetched()),
child: ReposList(user: widget.savedUser, ),
),
body: pages[pageIndex],
);
}
}
class RepoListPage extends StatelessWidget {
final SavedUser user;
const RepoListPage({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) =>
RepoBloc(giteaService: GiteaService(apiAccess: user.apiAccess))
..add(RepoFetched()),
child: ReposList(
user: user,
));
}
}

134
lib/widget/search_list.dart Normal file
View file

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gitea_client/cubit/search_bloc.dart';
import '../model/repository.dart';
import '../model/repouser.dart';
import '../model/user.dart';
class SearchList extends StatefulWidget {
final SavedUser user;
const SearchList({Key? key, required this.user}) : super(key: key);
@override
_SearchListState createState() => _SearchListState();
}
class _SearchListState extends State<SearchList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
switch (state.status) {
case SearchStatus.failure:
String error_message = state.error_message!;
return Center(child: Text('failed to fetch $error_message'));
case SearchStatus.success:
if (state.repos.isEmpty) {
return const Center(child: Text('no repos'));
}
if (state.repos.length < 5) {
context.read<SearchBloc>().add(SearchFetchedEvent());
}
return ListView.builder(
itemBuilder: (BuildContext context, int index) {
return index >= state.repos.length
? BottomLoader()
: RepoListItem(repo: state.repos[index],user: widget.user,);
},
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<SearchBloc>().add(SearchFetchedEvent());
}
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.repo, required this.user}) : super(key: key);
final Repository repo;
final SavedUser user;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Material(
child: Container(
color: (repo.private!) ? Colors.yellow[100] : Colors.white,
child: ListTile(
leading: (repo.private!)
? const Icon(Icons.lock)
: (repo.mirror!)
? const Icon(Icons.amp_stories_outlined)
: (repo.archived!)
? const Icon(Icons.archive)
: const Icon(Icons.book),
title: Text('${repo.owner.username}/${repo.name}'),
isThreeLine: true,
subtitle: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
(repo.description != null)
? Text(repo.description!)
: Container(),
(repo.mirror!)
? Text("Mirror of ${repo.originalUrl!}")
: Container()
],
),
dense: true,
onTap: () => {
Navigator.pushNamed(context, "/repopage",arguments: RepoUser(repo,user))
},
),
),
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gitea_client/cubit/search_bloc.dart';
import 'package:gitea_client/widget/search_list.dart';
import '../model/user.dart';
import '../service/gitea_service.dart';
class SearchPage extends StatefulWidget {
final SavedUser user;
const SearchPage({Key? key, required this.user}) : super(key: key);
@override
_SearchPage createState() => _SearchPage();
}
class _SearchPage extends State<SearchPage> {
final search = TextEditingController();
@override
void initState() {
search.addListener(_onSearch);
}
void _onSearch() {
print("Input: ${search.text}");
context.read<SearchBloc>().add(SearchInputEvent(search.text));
}
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context).size;
return Center(
child: SizedBox(
width: (media.width > 600) ? media.width * 0.5 : media.width * 0.9,
child: BlocProvider(
create: (_) => SearchBloc(
giteaService: GiteaService(apiAccess: widget.user.apiAccess))
..add(SearchInputEvent(search.text)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Search",
style: Theme.of(context).textTheme.headline4,
),
TextField(
decoration: const InputDecoration(
labelText: "Search text",
),
controller: search,
),
SearchList(
user: widget.user,
)
],
),
),
));
}
}