Skip to content

Commit

Permalink
feat: add appstream results to autocompletion
Browse files Browse the repository at this point in the history
test: update search_field_test

refactor: race futures in autoCompleteProvider

docs: clarify autoCompleteProvider behavior

docs: comment on unawaited future appstream.init()

feat: add logger
  • Loading branch information
d-loose committed Sep 14, 2023
1 parent 7c0df19 commit 0993561
Show file tree
Hide file tree
Showing 17 changed files with 402 additions and 142 deletions.
1 change: 1 addition & 0 deletions lib/appstream.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'src/appstream/appstream_search.dart';
export 'src/appstream/appstream_service.dart';
export 'src/appstream/appstream_utils.dart';
12 changes: 12 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:github/github.dart';
import 'package:gtk/gtk.dart';
import 'package:snapcraft_launcher/snapcraft_launcher.dart';
import 'package:ubuntu_logger/ubuntu_logger.dart';
import 'package:ubuntu_service/ubuntu_service.dart';
import 'package:yaru_widgets/yaru_widgets.dart';

import 'appstream.dart';
import 'l10n.dart';
import 'snapd.dart';
import 'store.dart';
Expand All @@ -24,7 +28,15 @@ Future<void> main(List<String> args) async {
registerService(() => GitHub());
registerService(() => GtkApplicationNotifier(args));

final appstream = AppstreamService();
// Explicitly ignore the future to continue while appstream is reading the
// metadata from the disk.
unawaited(appstream.init());
registerServiceInstance(appstream);

await initDefaultLocale();

Logger.setup();

runApp(const ProviderScope(child: StoreApp()));
}
1 change: 1 addition & 0 deletions lib/snapd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export 'src/snapd/snap_category_enum.dart';
export 'src/snapd/snap_l10n.dart';
export 'src/snapd/snap_launcher.dart';
export 'src/snapd/snap_model.dart';
export 'src/snapd/snap_search.dart';
export 'src/snapd/snap_sort.dart';
export 'src/snapd/snapd_service.dart';
export 'src/snapd/snapx.dart';
Expand Down
18 changes: 18 additions & 0 deletions lib/src/appstream/appstream_search.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:appstream/appstream.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ubuntu_service/ubuntu_service.dart';

import '/appstream.dart';

final appstreamSearchProvider =
StreamProvider.family<List<AppstreamComponent>, String>(
(ref, query) async* {
final appstream = getService<AppstreamService>();
if (!appstream.initialized) {
// Return empty results in order to not slow down the autocompletion, in case
// the appstream service is still populating the cache.
yield [];
await appstream.init();
}
yield await appstream.search(query);
});
8 changes: 7 additions & 1 deletion lib/src/appstream/appstream_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ class _ScoredComponent {

class AppstreamService {
final AppstreamPool _pool;
late final Future<void> _loader = _pool.load().then((_) => _populateCache());
late final Future<void> _loader = _pool.load().then((_) {
_populateCache();
_initialized = true;
});

bool get initialized => _initialized;
bool _initialized = false;

// TODO: cache AppstreamPool
AppstreamService({@visibleForTesting AppstreamPool? pool})
Expand Down
5 changes: 2 additions & 3 deletions lib/src/explore/explore_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'package:yaru_icons/yaru_icons.dart';

import '/l10n.dart';
import '/layout.dart';
import '/search.dart';
import '/snapd.dart';
import '/store.dart';
import '/widgets.dart';
Expand Down Expand Up @@ -109,7 +108,7 @@ class _CategorySnapList extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final categorySnaps = ref
// get snaps from `category`
.watch(searchProvider(SnapSearchParameters(category: category)))
.watch(snapSearchProvider(SnapSearchParameters(category: category)))
.whenOrNull(data: (data) => data)
// .. without the banner snaps, if we don't want them
?.where(
Expand Down Expand Up @@ -150,7 +149,7 @@ class _CategoryBanner extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final snaps = ref
.watch(searchProvider(SnapSearchParameters(category: category)))
.watch(snapSearchProvider(SnapSearchParameters(category: category)))
.whenOrNull(data: (data) => data);
final featuredSnaps = category.featuredSnapNames != null
? category.featuredSnapNames!
Expand Down
4 changes: 3 additions & 1 deletion lib/src/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@
"developmentPageLabel": "Development",
"gamesPageLabel": "Games",
"unknownPublisher": "Unknown Publisher",
"searchFieldDebSection": "Debian packages",
"searchFieldSearchHint": "Search for apps",
"searchFieldSearchForLabel": "Search for \"{query}\"",
"searchFieldSearchForLabel": "See all results for \"{query}\"",
"@searchFieldSearchForLabel": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchFieldSnapSection": "Snap packages",
"searchPageFilterByLabel": "Filter by",
"searchPageNoResults": "No results found for \"{query}\"",
"@searchPageNoResults": {
Expand Down
154 changes: 125 additions & 29 deletions lib/src/search/search_field.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:appstream/appstream.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
Expand All @@ -6,21 +7,58 @@ import 'package:snapd/snapd.dart';
import 'package:yaru_icons/yaru_icons.dart';
import 'package:yaru_widgets/yaru_widgets.dart';

import '/appstream.dart';
import '/snapd.dart';
import '/widgets.dart';
import 'search_provider.dart';

typedef SnapAutoCompleteOption = ({Snap? snap, String query});
sealed class AutoCompleteOption {
String get title => switch (this) {
AutoCompleteSnapOption(snap: final snap) => snap.titleOrName,
AutoCompleteDebOption(deb: final deb) => deb.getLocalizedName(),
AutoCompleteSectionOption(section: final section) => section,
AutoCompleteSearchOption(query: final query) => query,
AutoCompleteDividerOption() => '',
};
bool get selectable => switch (this) {
AutoCompleteSectionOption() || AutoCompleteDividerOption() => false,
_ => true,
};
}

class AutoCompleteSnapOption extends AutoCompleteOption {
final Snap snap;
AutoCompleteSnapOption(this.snap);
}

class AutoCompleteDebOption extends AutoCompleteOption {
final AppstreamComponent deb;
AutoCompleteDebOption(this.deb);
}

class AutoCompleteSectionOption extends AutoCompleteOption {
final String section;
AutoCompleteSectionOption(this.section);
}

class AutoCompleteSearchOption extends AutoCompleteOption {
final String query;
AutoCompleteSearchOption(this.query);
}

class AutoCompleteDividerOption extends AutoCompleteOption {}

class SearchField extends ConsumerStatefulWidget {
const SearchField({
super.key,
required this.onSearch,
required this.onSelected,
required this.onSnapSelected,
required this.onDebSelected,
});

final ValueChanged<String> onSearch;
final ValueChanged<String> onSelected;
final ValueChanged<String> onSnapSelected;
final ValueChanged<AppstreamComponent> onDebSelected;

@override
ConsumerState<SearchField> createState() => _SearchFieldState();
Expand All @@ -44,18 +82,34 @@ class _SearchFieldState extends ConsumerState<SearchField> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return RawAutocomplete<SnapAutoCompleteOption>(
return RawAutocomplete<AutoCompleteOption>(
optionsBuilder: (query) async {
ref.read(queryProvider.notifier).state = query.text;
final options = await ref.watch(autoCompleteProvider.future);
if (options.isEmpty) return [];
return options
.take(5)
.map<SnapAutoCompleteOption>(
(snap) => (snap: snap, query: query.text))
.followedBy([(snap: null, query: query.text)]);
if (options.snaps.isEmpty && options.debs.isEmpty) return [];
final snapOptions = options.snaps
.take(3)
.map<AutoCompleteOption>(AutoCompleteSnapOption.new)
.toList();
final debOptions = options.debs
.take(3)
.map<AutoCompleteOption>(AutoCompleteDebOption.new)
.toList();
return <AutoCompleteOption>[
if (snapOptions.isNotEmpty) ...[
AutoCompleteSectionOption(l10n.searchFieldSnapSection),
...snapOptions,
AutoCompleteDividerOption()
],
if (debOptions.isNotEmpty) ...[
AutoCompleteSectionOption(l10n.searchFieldDebSection),
...debOptions,
AutoCompleteDividerOption(),
],
AutoCompleteSearchOption(query.text),
];
},
displayStringForOption: (option) => option.snap?.name ?? option.query,
displayStringForOption: (option) => option.title,
optionsViewBuilder: (context, onSelected, options) => Align(
alignment: Alignment.topLeft,
child: Material(
Expand All @@ -70,38 +124,34 @@ class _SearchFieldState extends ConsumerState<SearchField> {
final option = options.elementAt(index);
_optionsAvailable = options.isNotEmpty;
return InkWell(
onTap: () => onSelected(option),
onTap: option.selectable ? () => onSelected(option) : null,
child: Builder(builder: (context) {
final bool highlight =
final bool selected =
AutocompleteHighlightedOption.of(context) == index;
if (highlight) {
if (selected) {
SchedulerBinding.instance
.addPostFrameCallback((Duration timeStamp) {
Scrollable.ensureVisible(context, alignment: 0.5);
});
}
return option.snap != null
? ListTile(
selected: highlight,
contentPadding: const EdgeInsets.all(8),
leading: SnapIcon(iconUrl: option.snap!.iconUrl),
title: Text(option.snap!.titleOrName),
)
: ListTile(
title: Text(
l10n.searchFieldSearchForLabel(option.query)),
selected: highlight,
);
return _AutoCompleteTile(
option: option,
selected: selected,
);
}),
);
},
),
),
),
),
onSelected: (option) => option.snap != null
? widget.onSelected(option.snap!.name)
: widget.onSearch(option.query),
onSelected: (option) => switch (option) {
AutoCompleteSnapOption(snap: final snap) =>
widget.onSnapSelected(snap.name),
AutoCompleteDebOption(deb: final deb) => widget.onDebSelected(deb),
AutoCompleteSearchOption(query: final query) => widget.onSearch(query),
_ => () {}
},
fieldViewBuilder: (context, controller, node, onFieldSubmitted) {
return Consumer(builder: (context, ref, child) {
ref.listen(queryProvider, (prev, next) {
Expand Down Expand Up @@ -143,3 +193,49 @@ class _SearchFieldState extends ConsumerState<SearchField> {
);
}
}

class _AutoCompleteTile extends StatelessWidget {
const _AutoCompleteTile({required this.option, required this.selected});

static const _iconSize = 32.0;

final AutoCompleteOption option;
final bool selected;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return switch (option) {
AutoCompleteSnapOption(snap: final snap) => ListTile(
selected: selected,
title: Text(snap.titleOrName),
leading: SnapIcon(
size: _iconSize,
iconUrl: snap.iconUrl,
),
),
AutoCompleteDebOption(deb: final deb) => ListTile(
selected: selected,
title: Text(deb.getLocalizedName()),
leading: SnapIcon(
size: _iconSize,
iconUrl:
deb.icons.whereType<AppstreamRemoteIcon>().firstOrNull?.url,
),
),
AutoCompleteSearchOption(query: final query) => ListTile(
selected: selected,
title: Text(
l10n.searchFieldSearchForLabel(query),
),
),
AutoCompleteSectionOption(section: final section) => ListTile(
title: Text(
section,
style: Theme.of(context).textTheme.bodyMedium,
),
),
AutoCompleteDividerOption() => const Divider(),
};
}
}
21 changes: 11 additions & 10 deletions lib/src/search/search_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import '/layout.dart';
import '/snapd.dart';
import '/store.dart';
import '/widgets.dart';
import 'search_provider.dart';

// TODO: break down into smaller widgets
class SearchPage extends StatelessWidget {
Expand Down Expand Up @@ -50,7 +49,7 @@ class SearchPage extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Consumer(builder: (context, ref, child) {
final sortOrder = ref.watch(sortOrderProvider);
final sortOrder = ref.watch(snapSortOrderProvider);
return MenuButtonBuilder<SnapSortOrder?>(
values: const [
null,
Expand All @@ -63,8 +62,9 @@ class SearchPage extends StatelessWidget {
sortOrder?.localize(l10n) ??
l10n.snapSortOrderRelevance,
),
onSelected: (value) =>
ref.read(sortOrderProvider.notifier).state = value,
onSelected: (value) => ref
.read(snapSortOrderProvider.notifier)
.state = value,
child: Text(
sortOrder?.localize(l10n) ??
l10n.snapSortOrderRelevance,
Expand All @@ -88,11 +88,12 @@ class SearchPage extends StatelessWidget {
category?.localize(l10n) ??
l10n.snapCategoryAll),
onSelected: (value) => ref
.read(
categoryProvider(initialCategory).notifier)
.read(snapCategoryProvider(initialCategory)
.notifier)
.state = value,
child: Text(ref
.watch(categoryProvider(initialCategory))
.watch(
snapCategoryProvider(initialCategory))
?.localize(l10n) ??
l10n.snapCategoryAll),
);
Expand All @@ -106,10 +107,10 @@ class SearchPage extends StatelessWidget {
),
Expanded(
child: Consumer(builder: (context, ref, child) {
final category = ref.watch(
categoryProvider(initialCategoryName?.toSnapCategoryEnum()));
final category = ref.watch(snapCategoryProvider(
initialCategoryName?.toSnapCategoryEnum()));
final results = ref.watch(
sortedSearchProvider(
sortedSnapSearchProvider(
SnapSearchParameters(
query: query,
category: category,
Expand Down
Loading

0 comments on commit 0993561

Please sign in to comment.