15 — Flutter Implementation Guide (Search Module)
- 1. Feature folder
- 2. Models & mapping (exact shape)
- 3. Repository
- 4. Cubit (see
13for the full state machine) - 5. Debounce widget
- 6. Widget tree
- 7. Router entries
- 8. Tests
- 9. Non-goals in v1
How to build the search feature in the Flutter client. Extends 00-shared/11_Flutter_App_Architecture.md (folder layout, DI, dio, go_router, flutter_bloc). Forward-looking spec — no client repo exists yet (ledger A1).
1. Feature folder
features/search/
├── data/
│ ├── dto/search_result_dto.dart # envelope → SearchResult
│ └── repositories/search_repository.dart
├── domain/
│ └── models/search_result.dart # entityType, entityId, title, description, tags
├── presentation/
│ ├── cubit/search_cubit.dart
│ ├── widgets/search_bar_field.dart
│ ├── widgets/search_result_tile.dart
│ ├── widgets/search_group_header.dart
│ ├── widgets/search_results_screen.dart
│ └── widgets/search_landing.dart
└── test/
├── search_cubit_test.dart
└── search_repository_test.dart
2. Models & mapping (exact shape)
class SearchResult {
final String entityType, entityId, title, description;
final List<String> tags;
// fromJson: {entityType, entityId, title, description, tags}
// see search.service.ts:10-16
}
class SearchPage {
final List<SearchResult> items;
final PaginationMeta meta; // page, limit, totalItems, totalPages, hasNext, hasPrevious
// pagination-query.dto.ts:32-39
}
3. Repository
class SearchRepository {
SearchRepository(this._dio);
final Dio _dio;
Future<SearchPage> search(String q, {String? entityType, int page = 1, int limit = 20}) async {
final res = await _dio.get('/search', queryParameters: {
'q': q, if (entityType != null) 'entityType': entityType,
'page': page, 'limit': limit,
});
// map envelope.data + envelope.meta; throw ApiException on !=200
}
}
- Base URL
/api/v1prefix handled byAppDio(00-shared/11 §2). - Validation rules enforced by the server (
search-query.dto.ts:16-29) — keep client values in range; treat400 VALIDATION_ERRORas a bug. - No client cache initially — backend doesn't cache either
(
redis-cache.service.tsunwired); add(proposed)in-memory TTL cache only if the 2-query pattern (search.service.ts:36-37) misses budget.
4. Cubit (see 13 for the full state machine)
sealed class SearchState {}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState { final SearchPage? previous; }
class SearchSuccess extends SearchState { final SearchPage page; }
class SearchEmpty extends SearchState { final String query; }
class SearchError extends SearchState { final ApiException error; final String query; }
class SearchCubit extends Cubit<SearchState> {
SearchCubit(this._repo) : super(SearchInitial());
final SearchRepository _repo;
String _query = ''; String? _type; int _page = 1; int _guard = 0;
Future<void> queryChanged(String q) async { /* 300ms debounce handled by widget */
_query = q.trim(); if (_query.isEmpty) { emit(SearchInitial()); return; }
final token = ++_guard; _page = 1;
emit(SearchLoading(previous: currentState is SearchSuccess ? (currentState as SearchSuccess).page : null));
try {
final page = await _repo.search(_query, entityType: _type);
if (token != _guard) return; // stale guard: drop out-of-order response
emit(page.items.isEmpty ? SearchEmpty(_query) : SearchSuccess(page));
} on ApiException catch (e) {
if (token != _guard) return;
emit(SearchError(e, _query));
}
}
// setType(String?) → reset page 1, re-run; loadMore() → append if meta.hasNext
}
5. Debounce widget
class SearchBarField extends StatefulWidget { /* ... */ }
// _onChanged: Timer? _t; _t?.cancel(); _t = Timer(Duration(milliseconds: 300),
// () => widget.onQueryChanged(value.trim()));
// onSubmitted: _t?.cancel(); widget.onSubmit(value.trim());
Rules: timer restart per keystroke; dispose() cancels; IME composing
ignored until composition end (08 §1.5).
6. Widget tree
Shell (AppBar)
└── SearchBarField
└── GoRouter → /search
└── SearchResultsScreen
├── SearchLanding (SearchInitial)
├── SearchResultSkeleton (first loading)
├── SearchErrorState (SearchError)
├── SearchEmptyState (SearchEmpty)
└── CustomScrollView (SearchSuccess)
├── SearchGroupHeader ×N (per entityType — search.service.ts:11)
├── SearchResultTile ×N (onTap → owner detail route)
└── SearchBottomLoader (infinite scroll)
Grouping helper: groupBy(entityType) preserves first-seen order; group
order proposed in 06 §2.3.
7. Router entries
GoRoute(path: '/search', builder: SearchResultsScreen.new,
queryParameters: {'q': ..., 'type': ...}); // type maps to entityType (forward-looking deep links)
8. Tests
- Cubit: debounce via fake async; stale-guard (two rapid queries, first
response arrives last → discarded); empty →
SearchEmpty; error → retry. - Repository: mocked dio; envelope mapping;
400/401/403/429→ typedApiException(00-shared/06 §2). - Widget: golden for grouped list, empty, skeleton; a11y semantics
labels (
07 §2). - Integration: live tenant — type "rahul", expect Student group, tap →
detail (
14as acceptance list).
9. Non-goals in v1
- No local search history/suggestions (
(forward-looking)). - No QR scan wiring (
(forward-looking)— ledger B4). - No push deep-link handling (
(forward-looking)— ledger B3). - No Redis-backed result cache (
(proposed)).