Skip to content

Commit

Permalink
feat: Search API provider (closes #51)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gustl22 committed Jun 18, 2024
1 parent fadb470 commit 0e90e1d
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 135 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,13 @@ class MockDataManager extends DataManager {
}

@override
Future<Map<String, List<DataObject>>> search({required String searchTerm, Type? type, Organization? organization}) {
Future<Map<String, List<DataObject>>> search({
required String searchTerm,
Type? type,
int? organizationId,
AuthService? authService,
bool includeApiProviderResults = false,
}) {
// TODO: implement search
throw UnimplementedError();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ abstract class DataManager {
Future<Map<String, List<DataObject>>> search({
required String searchTerm,
Type? type,
Organization? organization,
int? organizationId,
AuthService? authService,
bool includeApiProviderResults,
});

final Map<Type, StreamController<DataObject>> _singleStreamControllers = {};
Expand Down
16 changes: 13 additions & 3 deletions wrestling_scoreboard_client/lib/services/network/remote/rest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,24 @@ class RestDataManager extends DataManager {
Future<Map<String, List<DataObject>>> search({
required String searchTerm,
Type? type,
Organization? organization,
int? organizationId,
AuthService? authService,
bool includeApiProviderResults = false,
}) async {
// Auth is only needed for sensitive searches
String? body;
if (authService != null) {
if (authService is BasicAuthService) {
body = jsonEncode(singleToJson(authService, BasicAuthService, CRUD.read));
}
}
final uri = Uri.parse('$_apiUrl/search').replace(queryParameters: {
if (searchTerm.isNotEmpty) 'like': searchTerm,
if (type != null) 'type': getTableNameFromType(type),
if (organization != null) 'org': organization.id.toString(),
if (organizationId != null) 'org': organizationId.toString(),
if (includeApiProviderResults) 'use_provider': includeApiProviderResults.toString(),
});
final response = await http.get(uri);
final response = body == null ? await http.get(uri) : await http.post(uri, body: body);
if (response.statusCode != 200) {
throw RestException('Failed to search $type with term "$searchTerm"', response: response);
}
Expand Down
200 changes: 140 additions & 60 deletions wrestling_scoreboard_client/lib/view/screens/edit/lineup_edit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wrestling_scoreboard_client/localization/wrestling_style.dart';
import 'package:wrestling_scoreboard_client/provider/data_provider.dart';
import 'package:wrestling_scoreboard_client/provider/local_preferences_provider.dart';
import 'package:wrestling_scoreboard_client/provider/network_provider.dart';
import 'package:wrestling_scoreboard_client/view/widgets/card.dart';
import 'package:wrestling_scoreboard_client/view/widgets/dialogs.dart';
import 'package:wrestling_scoreboard_client/view/widgets/dropdown.dart';
import 'package:wrestling_scoreboard_client/view/widgets/edit.dart';
import 'package:wrestling_scoreboard_client/view/widgets/font.dart';
import 'package:wrestling_scoreboard_client/view/widgets/formatter.dart';
import 'package:wrestling_scoreboard_client/view/widgets/loading_builder.dart';
import 'package:wrestling_scoreboard_common/common.dart';

// TODO: dynamically add or remove participants without weight class
Expand Down Expand Up @@ -67,12 +70,31 @@ class LineupEditState extends ConsumerState<LineupEdit> {
leader: _leader,
coach: _coach,
));
await Future.forEach(
_deleteParticipations,
(Participation element) async =>
(await ref.read(dataManagerNotifierProvider)).deleteSingle<Participation>(element));
await Future.forEach(_createOrUpdateParticipations,
(Participation element) async => (await ref.read(dataManagerNotifierProvider)).createOrUpdateSingle(element));
await Future.forEach(_deleteParticipations, (Participation element) async {
await (await ref.read(dataManagerNotifierProvider)).deleteSingle<Participation>(element);
});
await Future.forEach(_createOrUpdateParticipations, (Participation participation) async {
// Create missing membership and person, if not present in database yet. This means, that the data was fetched from an API provider.
if (participation.membership.id == null) {
if (participation.membership.club.id == null) {
// TODO: Add club of current lineup team, if club does not exist. This should not occur as soon all clubs can be imported via API.
participation =
participation.copyWith(membership: participation.membership.copyWith(club: widget.lineup.team.club));
}

if (participation.membership.person.id == null) {
final personId = await (await ref.read(dataManagerNotifierProvider))
.createOrUpdateSingle<Person>(participation.membership.person);
participation = participation.copyWith(
membership:
participation.membership.copyWith(person: participation.membership.person.copyWithId(personId)));
}
final membershipId = await (await ref.read(dataManagerNotifierProvider))
.createOrUpdateSingle<Membership>(participation.membership);
participation = participation.copyWith(membership: participation.membership.copyWithId(membershipId));
}
await (await ref.read(dataManagerNotifierProvider)).createOrUpdateSingle<Participation>(participation);
});
if (onSubmitGenerate != null) onSubmitGenerate();
navigator.pop();
}
Expand Down Expand Up @@ -104,6 +126,12 @@ class LineupEditState extends ConsumerState<LineupEdit> {
];
}

Future<Iterable<Membership>> _getOrCreateMemberships() async {
_memberships ??= await ref.watch(
manyDataStreamProvider<Membership, Club>(ManyProviderData(filterObject: widget.lineup.team.club)).future);
return _memberships!;
}

@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
Expand All @@ -116,39 +144,30 @@ class LineupEditState extends ConsumerState<LineupEdit> {
items: [
ListTile(title: HeadingText(widget.lineup.team.name)),
ListTile(
title: SearchableDropdown<Membership>(
selectedItem: _leader,
title: _MembershipDropdown(
label: localizations.leader,
context: context,
onSaved: (Membership? value) => setState(() {
getOrSetMemberships: _getOrCreateMemberships,
organization: widget.lineup.organization,
selectedItem: _leader,
onSave: (Membership? value) => setState(() {
_leader = value;
}),
itemAsString: (u) => u.person.fullName,
asyncItems: (String filter) async {
_memberships ??= await _getMemberships(ref, club: widget.lineup.team.club);
return _filterMemberships(ref, filter, widget.lineup, _memberships!);
},
isFilterOnline: true,
),
),
ListTile(
title: SearchableDropdown<Membership>(
selectedItem: _coach,
title: _MembershipDropdown(
label: localizations.coach,
context: context,
onSaved: (Membership? value) => setState(() {
getOrSetMemberships: _getOrCreateMemberships,
organization: widget.lineup.organization,
selectedItem: _coach,
onSave: (Membership? value) => setState(() {
_coach = value;
}),
itemAsString: (u) => u.info,
asyncItems: (String filter) async {
_memberships ??= await _getMemberships(ref, club: widget.lineup.team.club);
return _filterMemberships(ref, filter, widget.lineup, _memberships!);
},
isFilterOnline: true,
),
),
..._participations.entries.map((mapEntry) {
return ParticipationEditTile(
getOrSetMemberships: _getOrCreateMemberships,
lineup: widget.lineup,
participation: mapEntry.value,
weightClass: mapEntry.key,
Expand All @@ -168,6 +187,7 @@ class ParticipationEditTile extends ConsumerStatefulWidget {
final Lineup lineup;
final void Function(Participation participation) deleteParticipation;
final void Function(Participation participation) createOrUpdateParticipation;
final Future<Iterable<Membership>> Function() getOrSetMemberships;

const ParticipationEditTile({
super.key,
Expand All @@ -176,15 +196,14 @@ class ParticipationEditTile extends ConsumerStatefulWidget {
required this.lineup,
required this.deleteParticipation,
required this.createOrUpdateParticipation,
required this.getOrSetMemberships,
});

@override
ConsumerState<ParticipationEditTile> createState() => _ParticipationEditTileState();
}

class _ParticipationEditTileState extends ConsumerState<ParticipationEditTile> {
Iterable<Membership>? _memberships;

Membership? _curMembership;
double? _curWeight;

Expand Down Expand Up @@ -235,21 +254,16 @@ class _ParticipationEditTileState extends ConsumerState<ParticipationEditTile> {
flex: 80,
child: Container(
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
child: SearchableDropdown<Membership>(
selectedItem: widget.participation?.membership,
child: _MembershipDropdown(
label:
'${localizations.weightClass} ${widget.weightClass.name} ${widget.weightClass.style.abbreviation(context)}',
context: context,
onChanged: (Membership? newMembership) {
getOrSetMemberships: widget.getOrSetMemberships,
onChange: (Membership? newMembership) {
_curMembership = newMembership;
},
onSaved: (Membership? newMembership) => onSave(),
itemAsString: (u) => u.info,
asyncItems: (String filter) async {
_memberships ??= await _getMemberships(ref, club: widget.lineup.team.club);
return _filterMemberships(ref, filter, widget.lineup, _memberships!);
},
isFilterOnline: true,
organization: widget.lineup.organization,
selectedItem: widget.participation?.membership,
onSave: (_) => onSave(),
),
),
),
Expand Down Expand Up @@ -278,28 +292,94 @@ class _ParticipationEditTileState extends ConsumerState<ParticipationEditTile> {
}
}

Future<List<Membership>> _filterMemberships(
WidgetRef ref,
String filter,
Lineup lineup,
Iterable<Membership> memberships,
) async {
filter = filter.trim().toLowerCase();
if (filter.isEmpty) {
return memberships.toList();
}
final number = int.tryParse(filter);
if (number == null) {
return memberships.where((item) => item.person.fullName.toLowerCase().contains(filter)).toList();
class _MembershipDropdown extends ConsumerWidget {
final Future<Iterable<Membership>> Function() getOrSetMemberships;
final void Function(Membership? membership)? onChange;
final void Function(Membership? membership)? onSave;
final Membership? selectedItem;
final String label;
final Organization? organization;

const _MembershipDropdown({
required this.getOrSetMemberships,
this.selectedItem,
required this.label,
this.organization,
this.onChange,
required this.onSave,
});

@override
Widget build(BuildContext context, WidgetRef ref) {
return LoadingBuilder<Map<int, AuthService>>(
future: ref.watch(orgAuthNotifierProvider),
builder: (context, authServiceMap) {
return SearchableDropdown<Membership>(
selectedItem: selectedItem,
label: label,
context: context,
onChanged: onChange,
onSaved: onSave,
itemAsString: (u) => u.info + (u.id == null ? ' (API)' : ''),
asyncItems: (String filter) async {
return _filterMemberships(ref, filter, organization, await getOrSetMemberships());
},
isFilterOnline: true,
containerBuilder: (context, popupWidget) {
return Column(
children: [
if (authServiceMap[organization?.id] == null)
const PaddedCard(
child: Text(
"⚠ You have not specified any credentials for this organization, therefore you can't search for sensitive data."),
),
Expanded(child: popupWidget),
],
);
},
);
},
);
}

// If filter string is a number, search for membership no or at API provider, if present.
filter = number.toString();
final filteredMemberships =
memberships.where((item) => (item.orgSyncId?.contains(filter) ?? false) || (item.no?.contains(filter) ?? false));
return filteredMemberships.toList();
}
Future<List<Membership>> _filterMemberships(
WidgetRef ref,
String filter,
Organization? organization,
Iterable<Membership> memberships,
) async {
filter = filter.trim().toLowerCase();
if (filter.isEmpty) {
return memberships.toList();
}
final number = int.tryParse(filter);
if (number == null) {
return memberships.where((item) => item.person.fullName.toLowerCase().contains(filter)).toList();
}

// If filter string is a number, search for membership no or at API provider, if present.
filter = number.toString();
final filteredMemberships = memberships
.where((item) => (item.orgSyncId?.contains(filter) ?? false) || (item.no?.contains(filter) ?? false))
.toList();

Future<List<Membership>> _getMemberships(WidgetRef ref, {required Club club}) async {
return ref.watch(manyDataStreamProvider<Membership, Club>(ManyProviderData(filterObject: club)).future);
const enableApiProviderSearch = true;
if (enableApiProviderSearch) {
final authService = (await ref.read(orgAuthNotifierProvider))[organization?.id];
if (authService != null) {
final providerResults = await (await ref.read(dataManagerNotifierProvider)).search(
searchTerm: filter,
type: Membership,
organizationId: organization?.id,
authService: authService,
includeApiProviderResults: true,
);
final providerMemberships =
providerResults[getTableNameFromType(Membership)]?.map((membership) => membership as Membership) ?? [];
filteredMemberships.addAll(providerMemberships);
}
}

return filteredMemberships;
}
}
8 changes: 6 additions & 2 deletions wrestling_scoreboard_client/lib/view/screens/home/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,12 @@ class HomeState extends ConsumerState<Home> {
});
} else {
try {
final results = await (await ref.read(dataManagerNotifierProvider))
.search(searchTerm: searchTerm, type: searchType, organization: searchOrganization);
final authService = (await ref.read(orgAuthNotifierProvider))[searchOrganization?.id];
final results = await (await ref.read(dataManagerNotifierProvider)).search(
searchTerm: searchTerm,
type: searchType,
organizationId: searchOrganization?.id,
authService: authService);
setState(() {
searchResults = results;
});
Expand Down
3 changes: 3 additions & 0 deletions wrestling_scoreboard_client/lib/view/widgets/dropdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class SearchableDropdown<T> extends StatelessWidget {
final BuildContext context;
final Widget? icon;
final bool isFilterOnline;
final PopupBuilder? containerBuilder;

const SearchableDropdown({
required this.selectedItem,
Expand All @@ -28,6 +29,7 @@ class SearchableDropdown<T> extends StatelessWidget {
this.icon,
this.isFilterOnline = false,
super.key,
this.containerBuilder,
});

@override
Expand All @@ -40,6 +42,7 @@ class SearchableDropdown<T> extends StatelessWidget {
searchFieldProps: const TextFieldProps(decoration: InputDecoration(prefixIcon: Icon(Icons.search))),
showSearchBox: true,
isFilterOnline: isFilterOnline,
containerBuilder: containerBuilder,
),
clearButtonProps: const ClearButtonProps(isVisible: true),
asyncItems: asyncItems,
Expand Down
2 changes: 1 addition & 1 deletion wrestling_scoreboard_common/lib/src/data/lineup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ class Lineup with _$Lineup implements DataObject {
String? get orgSyncId => throw UnimplementedError();

@override
Organization? get organization => throw UnimplementedError();
Organization? get organization => team.organization;
}
2 changes: 2 additions & 0 deletions wrestling_scoreboard_common/lib/src/services/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ abstract class WrestlingApi {
Future<Iterable<League>> importLeagues({required Division division});

Future<Iterable<TeamMatch>> importTeamMatches({required League league});

Future<List<DataObject>> search({required String searchStr, required Type searchType});
}
Loading

0 comments on commit 0e90e1d

Please sign in to comment.