Basic search page
All checks were successful
ci/woodpecker/push/flutterBuild Pipeline was successful
All checks were successful
ci/woodpecker/push/flutterBuild Pipeline was successful
This commit is contained in:
parent
ae9b173742
commit
676e1540c7
8 changed files with 433 additions and 21 deletions
87
lib/cubit/search_bloc.dart
Normal file
87
lib/cubit/search_bloc.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
20
lib/cubit/search_event.dart
Normal file
20
lib/cubit/search_event.dart
Normal 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 => [];
|
||||
}
|
43
lib/cubit/search_state.dart
Normal file
43
lib/cubit/search_state.dart
Normal 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];
|
||||
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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
134
lib/widget/search_list.dart
Normal 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))
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
63
lib/widget/search_page.dart
Normal file
63
lib/widget/search_page.dart
Normal 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue