Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
needs_angular_dart_example_checks: ${{ steps.needs_angular_dart_example_checks.outputs.changes }}
needs_bloc_tools_e2e_checks: ${{ steps.needs_bloc_tools_e2e_checks.outputs.changes }}
needs_dart_package_checks: ${{ steps.needs_dart_package_checks.outputs.changes }}
needs_common_github_search_checks: ${{ steps.needs_common_github_search_checks.outputs.changes }}
needs_bloc_tools_compile_checks: ${{ steps.needs_bloc_tools_compile_checks.outputs.changes }}
needs_flutter_package_checks: ${{ steps.needs_flutter_package_checks.outputs.changes }}
needs_flutter_example_checks: ${{ steps.needs_flutter_example_checks.outputs.changes }}
Expand Down Expand Up @@ -101,6 +102,17 @@ jobs:
- ./.github/actions/dart_package/action.yaml
- packages/bloc/**

- uses: dorny/paths-filter@v4
name: Common GitHub Search Detection
id: needs_common_github_search_checks
with:
filters: |
common_github_search:
- ./.github/codecov.yml
- ./.github/workflows/main.yaml
- ./.github/actions/dart_package/action.yaml
- examples/github_search/common_github_search/**

- uses: dorny/paths-filter@v4
name: Bloc Tools Compile Detection
id: needs_bloc_tools_compile_checks
Expand Down Expand Up @@ -282,6 +294,31 @@ jobs:
working_directory: packages/${{ matrix.package }}
min_coverage: 100

common_github_search_checks:
needs: changes
if: ${{ needs.changes.outputs.needs_common_github_search_checks != '[]' }}

strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.changes.outputs.needs_common_github_search_checks) }}

runs-on: ubuntu-latest

name: 🎯 ${{ matrix.package }}

steps:
- name: 📚 Git Checkout
uses: actions/checkout@v7

- name: 🎯 Build ${{ matrix.package }}
uses: ./.github/actions/dart_package
with:
codecov_token: ${{ secrets.CODECOV_TOKEN }}
collect_score: false
working_directory: examples/github_search/${{ matrix.package }}
min_coverage: 100

bloc_tools_compile_checks:
needs: changes
if: ${{ needs.changes.outputs.needs_bloc_tools_compile_checks != '[]' }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class SearchBodyComponent {
@Input()
late GithubSearchState state;

@Input()
late GithubSearchBloc githubSearchBloc;

bool get isEmpty => state is SearchStateEmpty;
bool get isLoading => state is SearchStateLoading;
bool get isSuccess => state is SearchStateSuccess;
Expand All @@ -22,5 +25,12 @@ class SearchBodyComponent {
List<SearchResultItem> get items =>
isSuccess ? (state as SearchStateSuccess).items : [];

bool get hasReachedMax =>
isSuccess && (state as SearchStateSuccess).hasReachedMax;

String get error => isError ? (state as SearchStateError).error : '';

void loadMore() => githubSearchBloc.add(const NextPageRequested());

void refresh() => githubSearchBloc.add(const Refreshed());
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@
<p>No Results</p>
</div>
<search-results [items]="items"></search-results>
<button *ngIf="!hasReachedMax" (click)="loadMore()">Load More</button>
<button (click)="refresh()">Refresh</button>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<div>
<h1>GitHub Search</h1>
<search-bar [githubSearchBloc]="githubSearchBloc"></search-bar>
<search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body>
<search-body
[state]="$pipe.bloc(githubSearchBloc)"
[githubSearchBloc]="githubSearchBloc"
></search-body>
</div>
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:common_github_search/common_github_search.dart';

class GithubCache {
// ponytail: key is a composite 'term::page' string built by the repository;
// the cache stays a plain String-keyed map (no per-page class needed).
final _cache = <String, SearchResult>{};

SearchResult? get(String term) => _cache[term];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ class GithubClient {
final String baseUrl;
final http.Client _httpClient;

Future<SearchResult> search(String term) async {
final response = await _httpClient.get(Uri.parse('$baseUrl$term'));
Future<SearchResult> search(
String term, {
int page = 1,
int perPage = 30,
}) async {
// ponytail: encode the term so an embedded `&` can't inject query params.
final query = Uri.encodeQueryComponent(term);
final response = await _httpClient.get(
Uri.parse('$baseUrl$query&page=$page&per_page=$perPage'),
);
final results = json.decode(response.body) as Map<String, dynamic>;

if (response.statusCode == 200) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ class GithubRepository {
final GithubCache _cache;
final GithubClient _client;

Future<SearchResult> search(String term) async {
final cachedResult = _cache.get(term);
if (cachedResult != null) {
return cachedResult;
Future<SearchResult> search(
String term, {
int page = 1,
int perPage = 30,
bool forceRefresh = false,
}) async {
// ponytail: composite term+page cache key so pages cache independently.
final cacheKey = '$term::$page';
if (!forceRefresh) {
final cachedResult = _cache.get(cacheKey);
if (cachedResult != null) {
return cachedResult;
}
}
final result = await _client.search(term);
_cache.set(term, result);
final result = await _client.search(term, page: page, perPage: perPage);
// Overwrite so a forced refresh never leaves stale cached data behind.
_cache.set(cacheKey, result);
return result;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'dart:developer' as developer;

import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:stream_transform/stream_transform.dart';

Expand All @@ -12,10 +15,27 @@ class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required this._githubRepository})
: super(SearchStateEmpty()) {
on<TextChanged>(_onTextChanged, transformer: debounce(_duration));
// droppable: ignore new page requests while one is already in flight.
on<NextPageRequested>(_onNextPage, transformer: droppable());
// restartable: newest refresh wins; resets to page 1.
on<Refreshed>(_onRefreshed, transformer: restartable());
}

final GithubRepository _githubRepository;

// ponytail: observability for the concurrency behavior — shows in the browser
// console under `[GithubSearchBloc]`. Uses dart:developer.log (avoid_print).
void _log(String message) => developer.log(message, name: 'GithubSearchBloc');

// ponytail: monotonic race-guard stamp. Per-handler transformers are
// INDEPENDENT — restartable() on Refreshed cancels only the Refreshed
// handler's own future, NOT a droppable _onNextPage already awaiting. So a
// slow next page can resolve after a refresh/new search and clobber results.
// Every fresh search bumps this; _onNextPage drops its result if the stamp
// moved while it awaited. Also catches a same-term refresh, which a
// searchTerm-only guard would miss.
var _generation = 0;

Future<void> _onTextChanged(
TextChanged event,
Emitter<GithubSearchState> emit,
Expand All @@ -24,17 +44,111 @@ class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {

if (searchTerm.isEmpty) return emit(SearchStateEmpty());

final generation = ++_generation;
_log('search "$searchTerm" g$generation (debounce) p1');
emit(SearchStateLoading());

try {
final results = await _githubRepository.search(searchTerm);
emit(SearchStateSuccess(results.items));
final result = await _githubRepository.search(searchTerm);
_log('search "$searchTerm" g$generation: ${result.items.length} items');
emit(
SearchStateSuccess(
items: result.items,
searchTerm: searchTerm,
page: 1,
hasReachedMax: _reachedMax(result.items.length, result),
generation: generation,
),
);
} catch (error) {
emit(_errorState(error));
}
}

Future<void> _onRefreshed(
Refreshed event,
Emitter<GithubSearchState> emit,
) async {
final currentState = state;
if (currentState is! SearchStateSuccess) {
_log('Refreshed ignored — no active search');
return;
}
final searchTerm = currentState.searchTerm;
final generation = ++_generation;
_log('refresh "$searchTerm" g$generation (restartable) p1');

// ponytail: no full-screen SearchStateLoading — RefreshIndicator shows its
// own spinner; just emit the new Success (or an error) when data arrives.
try {
final result = await _githubRepository.search(
searchTerm,
forceRefresh: true,
);
emit(
SearchStateSuccess(
items: result.items,
searchTerm: searchTerm,
page: 1,
hasReachedMax: _reachedMax(result.items.length, result),
generation: generation,
),
);
} catch (error) {
emit(_errorState(error));
}
}

Future<void> _onNextPage(
NextPageRequested event,
Emitter<GithubSearchState> emit,
) async {
final currentState = state;
if (currentState is! SearchStateSuccess || currentState.hasReachedMax) {
_log('NextPageRequested ignored — no results or hasReachedMax');
return;
}
final searchTerm = currentState.searchTerm;
final generation = currentState.generation; // capture BEFORE await
final nextPage = currentState.page + 1;
_log('nextPage "$searchTerm" g$generation → p$nextPage (droppable)');

try {
final result = await _githubRepository.search(searchTerm, page: nextPage);
// ponytail: droppable only serialises NextPage against itself. A
// Refreshed/TextChanged (restartable/debounce) can replace state while
// this page is in flight and resolve first. Drop the stale page unless
// the same generation is still current.
final latest = state;
if (latest is! SearchStateSuccess || latest.generation != generation) {
final currentGen = latest is SearchStateSuccess
? '${latest.generation}'
: 'n/a';
_log('DROPPED stale p$nextPage g$generation ≠ current g$currentGen');
return;
}
final merged = [...latest.items, ...result.items];
_log('p$nextPage merged → ${merged.length} items g$generation');
emit(
error is SearchResultError
? SearchStateError(error.message)
: const SearchStateError('something went wrong'),
SearchStateSuccess(
items: merged,
searchTerm: searchTerm,
page: nextPage,
hasReachedMax: _reachedMax(merged.length, result),
generation: generation,
),
);
} catch (error) {
emit(_errorState(error));
}
}

bool _reachedMax(int accumulatedCount, SearchResult result) =>
result.items.isEmpty || accumulatedCount >= result.totalCount;
// ponytail: GitHub caps search results at 1000; totalCount can exceed that.
// Fine for an example.

GithubSearchState _errorState(Object error) => error is SearchResultError
? SearchStateError(error.message)
: const SearchStateError('something went wrong');
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@ final class TextChanged extends GithubSearchEvent {
@override
String toString() => 'TextChanged { text: $text }';
}

final class NextPageRequested extends GithubSearchEvent {
const NextPageRequested();

@override
List<Object> get props => [];
}

final class Refreshed extends GithubSearchEvent {
const Refreshed();

@override
List<Object> get props => [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,37 @@ final class SearchStateEmpty extends GithubSearchState {}
final class SearchStateLoading extends GithubSearchState {}

final class SearchStateSuccess extends GithubSearchState {
const SearchStateSuccess(this.items);
const SearchStateSuccess({
required this.items,
required this.searchTerm,
required this.page,
required this.hasReachedMax,
required this.generation,
});

final List<SearchResultItem> items;
final String searchTerm;
final int page;
final bool hasReachedMax;

/// Race-guard stamp bumped by every fresh search (TextChanged/Refreshed);
/// see GithubSearchBloc — a stale in-flight next page is dropped unless the
/// current success state still carries the same generation.
final int generation;

@override
List<Object> get props => [items];
List<Object> get props => [
items,
searchTerm,
page,
hasReachedMax,
generation,
];

@override
String toString() => 'SearchStateSuccess { items: ${items.length} }';
String toString() =>
'SearchStateSuccess { items: ${items.length}, page: $page, '
'hasReachedMax: $hasReachedMax, generation: $generation }';
}

final class SearchStateError extends GithubSearchState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:common_github_search/common_github_search.dart';

class SearchResult {
const SearchResult({required this.items});
const SearchResult({required this.items, required this.totalCount});

factory SearchResult.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>)
Expand All @@ -10,8 +10,12 @@ class SearchResult {
SearchResultItem.fromJson(item as Map<String, dynamic>),
)
.toList();
return SearchResult(items: items);
return SearchResult(
items: items,
totalCount: json['total_count'] as int? ?? 0,
);
}

final List<SearchResultItem> items;
final int totalCount;
}
Loading