diff --git a/lib/cubit/search_bloc.dart b/lib/cubit/search_bloc.dart new file mode 100644 index 0000000..32d8f98 --- /dev/null +++ b/lib/cubit/search_bloc.dart @@ -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 { + final GiteaService giteaService; + + SearchBloc({required this.giteaService}) : super(const SearchState()) { + on( + _SearchInputEvent, + transformer: (events, mapper) => events.switchMap(mapper), + ); + on( + _SearchFetchedEvent, + transformer: (events, mapper) => events.switchMap(mapper), + ); + } + Future _SearchFetchedEvent( + SearchFetchedEvent event, Emitter 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 _SearchInputEvent( + SearchInputEvent event, Emitter 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())); + } + } +} diff --git a/lib/cubit/search_event.dart b/lib/cubit/search_event.dart new file mode 100644 index 0000000..15b1cdd --- /dev/null +++ b/lib/cubit/search_event.dart @@ -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 get props => []; +} + + +class SearchFetchedEvent extends SearchEvent { + @override + List get props => []; +} diff --git a/lib/cubit/search_state.dart b/lib/cubit/search_state.dart new file mode 100644 index 0000000..60e5795 --- /dev/null +++ b/lib/cubit/search_state.dart @@ -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 [], + this.loadedPages = 0, + this.hasReachedMax = false, + this.error_message = null + }); + final String queryString; + final SearchStatus status; + final List repos; + final int loadedPages; + final bool hasReachedMax; + final String? error_message; + + SearchState copyWith({ + String? queryString, + SearchStatus? status, + List? 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 get props => [status, repos, hasReachedMax]; + +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 57916b7..69f0aab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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; }, diff --git a/lib/service/gitea_service.dart b/lib/service/gitea_service.dart index 4b108f5..5ab0ec7 100644 --- a/lib/service/gitea_service.dart +++ b/lib/service/gitea_service.dart @@ -72,4 +72,24 @@ class GiteaService { } throw Exception('error fetching posts'); } + + Future> 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'); + } } \ No newline at end of file diff --git a/lib/widget/repo_list_page.dart b/lib/widget/repo_list_page.dart index 66ff22b..011042c 100644 --- a/lib/widget/repo_list_page.dart +++ b/lib/widget/repo_list_page.dart @@ -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 { - +class _MainPage extends State { + int pageIndex = 0; final GlobalKey _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( @@ -33,40 +45,65 @@ class _RepoListPage extends State { child: ListView( padding: EdgeInsets.zero, children: [ - DrawerHeader( + DrawerHeader( decoration: BoxDecoration( 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, + )); + } +} diff --git a/lib/widget/search_list.dart b/lib/widget/search_list.dart new file mode 100644 index 0000000..3a72a12 --- /dev/null +++ b/lib/widget/search_list.dart @@ -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 { + 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 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().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().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)) + }, + ), + ), + ); + } +} diff --git a/lib/widget/search_page.dart b/lib/widget/search_page.dart new file mode 100644 index 0000000..1713a6f --- /dev/null +++ b/lib/widget/search_page.dart @@ -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 { + final search = TextEditingController(); + + + @override + void initState() { + search.addListener(_onSearch); + } + + void _onSearch() { + print("Input: ${search.text}"); + context.read().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, + ) + ], + ), + ), + )); + } +}