Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15 — Flutter Implementation Guide (Search Module)

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/v1 prefix handled by AppDio (00-shared/11 §2).
  • Validation rules enforced by the server (search-query.dto.ts:16-29) — keep client values in range; treat 400 VALIDATION_ERROR as a bug.
  • No client cache initially — backend doesn't cache either (redis-cache.service.ts unwired); 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 → typed ApiException (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 (14 as 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)).