diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 0b8c043..4d4d8b1 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,24 +1,28 @@ -import 'package:api_client/api_client.dart'; import 'package:app_ui/app_ui.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:dash_ai_search/l10n/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:questions_repository/questions_repository.dart'; class App extends StatelessWidget { const App({ - required this.apiClient, + required this.questionsRepository, super.key, }); - final ApiClient apiClient; + final QuestionsRepository questionsRepository; @override Widget build(BuildContext context) { - return MaterialApp( - theme: VertexTheme.standard, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const HomePage(), + return RepositoryProvider.value( + value: questionsRepository, + child: MaterialApp( + theme: VertexTheme.standard, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomePage(), + ), ); } } diff --git a/lib/home/bloc/home_bloc.dart b/lib/home/bloc/home_bloc.dart index 5821979..49cdacb 100644 --- a/lib/home/bloc/home_bloc.dart +++ b/lib/home/bloc/home_bloc.dart @@ -1,28 +1,52 @@ import 'dart:async'; +import 'package:api_client/api_client.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:questions_repository/questions_repository.dart'; part 'home_event.dart'; part 'home_state.dart'; class HomeBloc extends Bloc { - HomeBloc() : super(const HomeState()) { + HomeBloc(this._questionsRepository) : super(const HomeState()) { on(_onFromWelcomeToQuestion); on(_onQuestion); + on(_queryUpdated); + on(_questionAsked); } - Future _onFromWelcomeToQuestion( + final QuestionsRepository _questionsRepository; + + void _onFromWelcomeToQuestion( FromWelcomeToQuestion event, Emitter emit, - ) async { + ) { emit(state.copyWith(status: Status.welcomeToAskQuestion)); } - Future _onQuestion( + void _onQuestion( AskQuestion event, Emitter emit, - ) async { + ) { emit(state.copyWith(status: Status.askQuestion)); } + + void _queryUpdated(QueryUpdated event, Emitter emit) { + emit(state.copyWith(query: event.query)); + } + + Future _questionAsked( + QuestionAsked event, + Emitter emit, + ) async { + emit(state.copyWith(status: Status.askQuestionToThinking)); + final result = await _questionsRepository.getVertexResponse(state.query); + emit( + state.copyWith( + status: Status.thinkingToResults, + vertexResponse: result, + ), + ); + } } diff --git a/lib/home/bloc/home_event.dart b/lib/home/bloc/home_event.dart index 48907fa..6dafda0 100644 --- a/lib/home/bloc/home_event.dart +++ b/lib/home/bloc/home_event.dart @@ -14,3 +14,15 @@ class FromWelcomeToQuestion extends HomeEvent { class AskQuestion extends HomeEvent { const AskQuestion(); } + +class QueryUpdated extends HomeEvent { + const QueryUpdated({required this.query}); + + final String query; + @override + List get props => [query]; +} + +class QuestionAsked extends HomeEvent { + const QuestionAsked(); +} diff --git a/lib/home/bloc/home_state.dart b/lib/home/bloc/home_state.dart index 80557db..558b0e8 100644 --- a/lib/home/bloc/home_state.dart +++ b/lib/home/bloc/home_state.dart @@ -1,27 +1,47 @@ part of 'home_bloc.dart'; -enum Status { welcome, welcomeToAskQuestion, askQuestion } +enum Status { + welcome, + welcomeToAskQuestion, + askQuestion, + askQuestionToThinking, + thinking, + thinkingToResults, + results, +} class HomeState extends Equatable { const HomeState({ this.status = Status.welcome, + this.query = '', + this.vertexResponse = const VertexResponse.empty(), }); final Status status; + final String query; + final VertexResponse vertexResponse; bool get isWelcomeVisible => status == Status.welcome || status == Status.welcomeToAskQuestion; bool get isQuestionVisible => status == Status.welcomeToAskQuestion || status == Status.askQuestion; + bool get isThinkingVisible => + status == Status.askQuestionToThinking || status == Status.thinking; + bool get isResultsVisible => + status == Status.thinkingToResults || status == Status.results; HomeState copyWith({ Status? status, + String? query, + VertexResponse? vertexResponse, }) { return HomeState( status: status ?? this.status, + query: query ?? this.query, + vertexResponse: vertexResponse ?? this.vertexResponse, ); } @override - List get props => [status]; + List get props => [status, query, vertexResponse]; } diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart index 9749373..0b0fbcb 100644 --- a/lib/home/view/home_page.dart +++ b/lib/home/view/home_page.dart @@ -2,6 +2,7 @@ import 'package:app_ui/app_ui.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:questions_repository/questions_repository.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -9,7 +10,7 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => HomeBloc(), + create: (_) => HomeBloc(context.read()), child: const HomeView(), ); } @@ -39,6 +40,8 @@ class HomeView extends StatelessWidget { ), if (state.isWelcomeVisible) const WelcomeView(), if (state.isQuestionVisible) const QuestionView(), + if (state.isThinkingVisible) const ThinkingView(), + if (state.isResultsVisible) const ResultsView(), const Positioned( bottom: 50, left: 50, diff --git a/lib/home/widgets/question_view.dart b/lib/home/widgets/question_view.dart index 329852c..54a8912 100644 --- a/lib/home/widgets/question_view.dart +++ b/lib/home/widgets/question_view.dart @@ -33,6 +33,12 @@ class QuestionView extends StatelessWidget { icon: vertexIcons.stars.image(), hint: l10n.questionHint, actionText: l10n.ask, + onTextUpdated: (String query) { + context.read().add(QueryUpdated(query: query)); + }, + onActionPressed: () { + context.read().add(const QuestionAsked()); + }, ), ], ), diff --git a/lib/home/widgets/results_view.dart b/lib/home/widgets/results_view.dart new file mode 100644 index 0000000..987d87e --- /dev/null +++ b/lib/home/widgets/results_view.dart @@ -0,0 +1,26 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:dash_ai_search/home/home.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ResultsView extends StatelessWidget { + const ResultsView({super.key}); + + @override + Widget build(BuildContext context) { + final response = + context.select((HomeBloc bloc) => bloc.state.vertexResponse); + return Center( + child: Container( + color: VertexColors.googleBlue, + width: 200, + constraints: const BoxConstraints(minWidth: 500, maxHeight: 700), + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [Text(response.summary)], + ), + ), + ); + } +} diff --git a/lib/home/widgets/thinking_view.dart b/lib/home/widgets/thinking_view.dart new file mode 100644 index 0000000..21a954e --- /dev/null +++ b/lib/home/widgets/thinking_view.dart @@ -0,0 +1,41 @@ +import 'package:dash_ai_search/home/home.dart'; +import 'package:dash_ai_search/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ThinkingView extends StatelessWidget { + const ThinkingView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + final state = context.watch().state; + final isAnimating = state.status == Status.askQuestionToThinking; + final query = context.select((HomeBloc bloc) => bloc.state.query); + return AnimatedOpacity( + opacity: isAnimating ? 1 : 0, + duration: const Duration(milliseconds: 500), + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.thinkingHeadline, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + Text( + query, + textAlign: TextAlign.center, + style: theme.textTheme.headlineLarge, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/home/widgets/widgets.dart b/lib/home/widgets/widgets.dart index 1d6f413..14803b2 100644 --- a/lib/home/widgets/widgets.dart +++ b/lib/home/widgets/widgets.dart @@ -1,4 +1,6 @@ export 'background.dart'; export 'logo.dart'; export 'question_view.dart'; +export 'results_view.dart'; +export 'thinking_view.dart'; export 'welcome_view.dart'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index acf22c4..8225b1b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -27,5 +27,9 @@ "ask": "Ask", "@ask": { "description": "Button text for ask" + }, + "thinkingHeadline": "Getting answers for", + "@thinkingHeadline": { + "description": "Headline on thinking screen" } } \ No newline at end of file diff --git a/lib/main_development.dart b/lib/main_development.dart index b51c32d..fa2ea24 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -1,6 +1,7 @@ import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/bootstrap.dart'; +import 'package:questions_repository/questions_repository.dart'; void main() { bootstrap( @@ -9,8 +10,11 @@ void main() { baseUrl: 'http://development', ); + final questionsRepository = + QuestionsRepository(apiClient.questionsResource); + return App( - apiClient: apiClient, + questionsRepository: questionsRepository, ); }, ); diff --git a/lib/main_production.dart b/lib/main_production.dart index b08e7d2..748b741 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -1,6 +1,7 @@ import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/bootstrap.dart'; +import 'package:questions_repository/questions_repository.dart'; void main() { bootstrap( @@ -9,8 +10,11 @@ void main() { baseUrl: 'http://production', ); + final questionsRepository = + QuestionsRepository(apiClient.questionsResource); + return App( - apiClient: apiClient, + questionsRepository: questionsRepository, ); }, ); diff --git a/packages/api_client/lib/src/models/vertex_metadata.dart b/packages/api_client/lib/src/models/vertex_metadata.dart index bba638d..d100117 100644 --- a/packages/api_client/lib/src/models/vertex_metadata.dart +++ b/packages/api_client/lib/src/models/vertex_metadata.dart @@ -30,7 +30,7 @@ class VertexMetadata extends Equatable { final String title; /// Description - final String description; + final String? description; @override List get props => [url, title, description]; diff --git a/packages/api_client/lib/src/models/vertex_metadata.g.dart b/packages/api_client/lib/src/models/vertex_metadata.g.dart index 1a5c12b..78e0650 100644 --- a/packages/api_client/lib/src/models/vertex_metadata.g.dart +++ b/packages/api_client/lib/src/models/vertex_metadata.g.dart @@ -10,7 +10,7 @@ VertexMetadata _$VertexMetadataFromJson(Map json) => VertexMetadata( url: json['url'] as String, title: json['title'] as String, - description: json['description'] as String, + description: json['description'] as String?, ); Map _$VertexMetadataToJson(VertexMetadata instance) => diff --git a/packages/api_client/lib/src/models/vertex_response.dart b/packages/api_client/lib/src/models/vertex_response.dart index decbf6d..145eb02 100644 --- a/packages/api_client/lib/src/models/vertex_response.dart +++ b/packages/api_client/lib/src/models/vertex_response.dart @@ -10,12 +10,14 @@ part 'vertex_response.g.dart'; @JsonSerializable() class VertexResponse extends Equatable { /// {@macro vertex_response} - const VertexResponse({ required this.summary, required this.documents, }); + /// {@macro vertex_response} + const VertexResponse.empty({this.summary = '', this.documents = const []}); + /// Convert from Map to [VertexResponse] factory VertexResponse.fromJson(Map json) => _$VertexResponseFromJson(json); diff --git a/packages/api_client/lib/src/resources/fake_response.dart b/packages/api_client/lib/src/resources/fake_response.dart new file mode 100644 index 0000000..98c90bf --- /dev/null +++ b/packages/api_client/lib/src/resources/fake_response.dart @@ -0,0 +1,170 @@ +// ignore_for_file: public_member_api_docs + +class FakeResponses { + static String whatIsFlutterResponse = ''' +{ + "summary": "Flutter is a free and open source software development kit (SDK) from Google [1]. It's used to create beautiful, fast user experiences for mobile, web, and desktop applications [1]. Flutter works with existing code and is used by developers and organizations around the world [1]. [3]. Flutter is a fully open source project [3].", + "total_size": 694, + "attribution_token": "X_BeCgwI4tH4qgYQvqzHpwESJDY1NWQ3NjljLTAwMDAtMmYyYS1iZDUzLTg4M2QyNGY2MWNiNCIHR0VORVJJQyogjr6dFdSynRWmi-8XwvCeFcXL8xecho4iooaOIqOAlyI", + "next_page_token": "QjYjFjNmRjMkNDO40yM1QmYtEmMmJTLwADMw0iY5YzNkVTN2QiGCopmOvNEGsKi6SOCMIBMxIgC", + "documents": [ + { + "id": "49e63c23-8376-440a-ac6e-98675fdd023e", + "metadata": { + "url": "https://api.flutter.dev/index.html", + "title": "Flutter - Dart API docs", + "tags": "missing", + "keywords": null, + "description": "Flutter API docs, for the Dart programming language.", + "image_uri": null + }, + "link": "https://api.flutter.dev/index.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-index-html.html", + "snippets": [ + "inspector. dart:math Mathematical constants and functions, plus a random number generator. dart:typed_data Lists that efficiently handle fixed sized data (for ..." + ] + }, + { + "id": "af6dcbfb-f11b-443f-8af0-5bdcfcdd8102", + "metadata": { + "url": "https://api.flutter.dev/javadoc/io/flutter/embedding/engine/FlutterEngine.html", + "title": "FlutterEngine", + "tags": "missing", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/javadoc/io/flutter/embedding/engine/FlutterEngine.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-javadoc-io-flutter-embedding-engine-flutterengine-html.html", + "snippets": [ + "to be notified of Flutter ... LocalizationPlugin getLocalizationPlugin() The LocalizationPlugin this FlutterEngine created. MouseCursorChannel ..." + ] + }, + { + "id": "2746919b-8bf2-4c5b-8289-742b533da9fa", + "metadata": { + "url": "https://api.flutter.dev/objcdoc/", + "title": "Flutter Reference", + "tags": "mock", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/objcdoc/", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-objcdoc.html", + "snippets": [ + "... FlutterStandardMessageCodec FlutterStandardMethodCodec FlutterStandardReader FlutterStandardReaderWriter FlutterStandardTypedData FlutterStandardWriter ..." + ] + }, + { + "id": "88318aee-2f63-456b-8ea0-1a5dd90b0ef3", + "metadata": { + "url": "https://api.flutter.dev/javadoc/io/flutter/app/package-summary.html", + "title": "io.flutter.app", + "tags": "fake", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/javadoc/io/flutter/app/package-summary.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-javadoc-io-flutter-app-package-summary-html.html", + "snippets": [] + }, + { + "id": "140f1ff5-9c62-4e4e-ba47-fcdb7c96de33", + "metadata": { + "url": "https://api.flutter.dev/objcdoc/Classes/FlutterEngine.html", + "title": "FlutterEngine Class Reference", + "tags": "missing", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/objcdoc/Classes/FlutterEngine.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-objcdoc-classes-flutterengine-html.html", + "snippets": [ + "libraryURI: String?, initialRoute: String?) -> Bool Parameters entrypoint The name of a top-level function from a Dart library. If this is ..." + ] + }, + { + "id": "57980044-93db-430a-a0dd-caacb6e0830c", + "metadata": { + "url": "https://api.flutter.dev/javadoc/io/flutter/embedding/android/FlutterActivity.html", + "title": "FlutterActivity", + "tags": "fake", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/javadoc/io/flutter/embedding/android/FlutterActivity.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-javadoc-io-flutter-embedding-android-flutteractivity-html.html", + "snippets": [ + "FlutterFragment. If Flutter ... ACCOUNT_SERVICE, ACTIVITY_SERVICE, ALARM_SERVICE, APP_OPS_SERVICE, APP_SEARCH_SERVICE, APPWIDGET_SERVICE, AUDIO_SERVICE, ..." + ] + }, + { + "id": "f961196f-7a50-4296-9867-20d94d5c92f4", + "metadata": { + "url": "https://api.flutter.dev/objcdoc/Classes/FlutterViewController.html", + "title": "FlutterViewController Class Reference", + "tags": "unknown", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/objcdoc/Classes/FlutterViewController.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-objcdoc-classes-flutterviewcontroller-html.html", + "snippets": [ + "... Swift init(coder aDecoder: NSCoder) -setFlutterViewDidRenderCallback: Registers a callback that will be invoked when the Flutter view has been rendered." + ] + }, + { + "id": "70177a4a-78fd-4540-91d1-165f918cd4f4", + "metadata": { + "url": "https://api.flutter.dev/javadoc/io/flutter/embedding/engine/plugins/FlutterPlugin.html", + "title": "FlutterPlugin", + "tags": "unknown", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/javadoc/io/flutter/embedding/engine/plugins/FlutterPlugin.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-javadoc-io-flutter-embedding-engine-plugins-flutterplugin-html.html", + "snippets": [ + "your browser. Summary: Nested | Field | Constr | Method Detail: Field | Constr | Method. https api flutter dev javadoc io flutter embedding engine plugins ..." + ] + }, + { + "id": "3821e382-276d-43b7-8368-0f9f9b9268d0", + "metadata": { + "url": "https://api.flutter.dev/flutter/widgets/Navigator-class.html", + "title": "Navigator class - widgets library - Dart API", + "tags": "unknown", + "keywords": null, + "description": "API docs for the Navigator class from the widgets library, for the Dart programming language.", + "image_uri": null + }, + "link": "https://api.flutter.dev/flutter/widgets/Navigator-class.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-flutter-widgets-navigator-class-html.html", + "snippets": [ + "pushed route's Future as described above. Callers can await the returned value to take an action when the route is popped, or to discover the route's value." + ] + }, + { + "id": "c3cd5194-8da3-4809-a88a-cc4ad1026871", + "metadata": { + "url": "https://api.flutter.dev/objcdoc/Classes/FlutterAppDelegate.html", + "title": "FlutterAppDelegate Class Reference", + "tags": "fake", + "keywords": null, + "description": null, + "image_uri": null + }, + "link": "https://api.flutter.dev/objcdoc/Classes/FlutterAppDelegate.html", + "doc_link": "gs://flutter-vertex-ai-demo-docs-v1/api-flutter-dev-objcdoc-classes-flutterappdelegate-html.html", + "snippets": [] + } + ] +} +'''; +} diff --git a/packages/api_client/lib/src/resources/fake_response.json b/packages/api_client/lib/src/resources/fake_response.json deleted file mode 100644 index 1daee5b..0000000 --- a/packages/api_client/lib/src/resources/fake_response.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "summary": "Hot reload loads code changes into the VM and re-builds the widget tree, preserving the app state [1]. Hot restart loads code changes into the VM, and restarts the Flutter app, losing the app state [1].", - "total_size": 701, - "attribution_token": "W_BaCgwIiN6vqgYQ5O6q0AMSJDY1NDNiODJhLTAwMDAtMjA3NS05YmQyLTE0YzE0ZWY0ZWVmMCIHR0VORVJJQyocpovvF8XL8xecho4iooaOIsLwnhXUsp0Vjr6dFQ", - "next_page_token": "AjZlVGNmVGNxMGNx0iMkJWOtUzNwITLwADMw0SOygjYzQTN2QiGCEM9JyMEGo6vHrICMIBMxIgC", - "documents": [ - { - "id": "fee364b1-5b75-4042-9669-2bd123364094", - "metadata": { - "url": "https://docs.flutter.dev/tools/hot-reload", - "title": "Hot reload | Flutter", - "tags": "missing", - "keywords": "", - "description": "Speed up development using Flutter's hot reload feature.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/tools/hot-reload", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-tools-hot-reload.html", - "snippets": [ - "... What is the difference between hot reload, hot restart, and full restart? Hot reload loads code changes into the VM and re-builds the widget tree ..." - ] - }, - { - "id": "8fec71e3-4dbb-4948-b3ca-ec13af65691e", - "metadata": { - "url": "https://docs.flutter.dev/tools/vs-code", - "title": "Visual Studio Code | Flutter", - "tags": "fake", - "keywords": "", - "description": "How to develop Flutter apps in Visual Studio Code.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/tools/vs-code", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-tools-vs-code.html", - "snippets": [ - "... with a placeholder Play ... command from the Command Palette. Hot reload vs. hot restart Hot reload works by injecting updated source code files into the running ..." - ] - }, - { - "id": "e5e6bfd4-6da8-44a9-8ced-5a9493960087", - "metadata": { - "url": "https://docs.flutter.dev/tools/android-studio", - "title": "Android Studio and IntelliJ | Flutter", - "tags": "missing", - "keywords": "", - "description": "How to develop Flutter apps in Android Studio or other IntelliJ products.\n", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/tools/android-studio", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-tools-android-studio.html", - "snippets": [ - "... between Android Studio and IntelliJ. In Android Studio: In the IDE, click ... Hot reload vs. hot restart Hot reload works by injecting updated source code files ..." - ] - }, - { - "id": "b5103539-2e8f-4450-952b-b21428972bb5", - "metadata": { - "url": "https://docs.flutter.dev/platform-integration/web/faq", - "title": "Web FAQ | Flutter", - "tags": "unknown", - "keywords": "", - "description": "Some gotchas and differences when writing or running web apps in Flutter.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/platform-integration/web/faq", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-platform-integration-web-faq.html", - "snippets": [ - "See building a web app with Flutter. Does hot reload work with a web app? No ... feature for Flutter mobile development. The only difference is that hot reload ..." - ] - }, - { - "id": "aa9bb7db-a635-4a85-b7ca-239a9386b53f", - "metadata": { - "url": "https://docs.flutter.dev/add-to-app/debugging", - "title": "Debug your add-to-app module | Flutter", - "tags": "fake", - "keywords": "", - "description": "How to run, debug, and hot reload your add-to-app Flutter module.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/add-to-app/debugging", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-add-to-app-debugging.html", - "snippets": [ - "... with a placeholder Play and pause a video Navigation & routing Overview Add ... Hot reload Profiling Install Flutter DevTools Cookbook Codelabs Get started ..." - ] - }, - { - "id": "b3559279-c9a9-4349-9fa6-5afbb30b572e", - "metadata": { - "url": "https://docs.flutter.dev/testing/build-modes", - "title": "Flutter's build modes | Flutter", - "tags": "missing", - "keywords": "", - "description": "Describes Flutter's build modes and when you should use debug, release, or profile mode.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/testing/build-modes", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-testing-build-modes.html", - "snippets": [ - "... with a placeholder Play and pause a video Navigation & routing ... Hot reload Profiling Install Flutter DevTools Cookbook Codelabs Get started Flutter 3.13 ..." - ] - }, - { - "id": "c0c9df6a-7f18-439b-b55a-269230829dc7", - "metadata": { - "url": "https://docs.flutter.dev/packages-and-plugins/using-packages", - "title": "Using packages | Flutter", - "tags": "unknown", - "keywords": "", - "description": "How to use packages in your Flutter app.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/packages-and-plugins/using-packages", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-packages-and-plugins-using-packages.html", - "snippets": [ - "scratch. What is the difference between a package and a plugin? A plugin is a ... Hot reload and hot restart only update the Dart code, so a full restart of ..." - ] - }, - { - "id": "f1652580-ef94-4620-9b9f-e012cef86977", - "metadata": { - "url": "https://docs.flutter.dev/resources/faq", - "title": "FAQ | Flutter", - "tags": "missing", - "keywords": "", - "description": "Frequently asked questions and answers about Flutter.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/resources/faq", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-resources-faq.html", - "snippets": [ - "between edit and refresh? How is hot reload different from hot restart? Where can I deploy my Flutter app? What devices and OS versions does Flutter run on ..." - ] - }, - { - "id": "9b8c10b0-0d70-4cc8-ba5c-f85237350ab2", - "metadata": { - "url": "https://docs.flutter.dev/get-started/test-drive", - "title": "Test drive | Flutter", - "tags": "missing", - "keywords": "", - "description": "How to create a templated Flutter app and use hot reload.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/get-started/test-drive", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-get-started-test-drive.html", - "snippets": [ - "... How to use \u201chot reload\u201d after you make changes to the app. Details for these ... restart it for VS Code's Flutter plugin to detect the Flutter SDK ..." - ] - }, - { - "id": "407b18f1-c655-438c-a033-2d42df430def", - "metadata": { - "url": "https://docs.flutter.dev/ui/interactivity", - "title": "Add interactivity to your Flutter app | Flutter", - "tags": "missing", - "keywords": "", - "description": "How to implement a stateful widget that responds to taps.", - "image_uri": "/assets/images/shared/brand/flutter/logo+text/horizontal/default.svg" - }, - "link": "https://docs.flutter.dev/ui/interactivity", - "doc_link": "gs://alanblount-sandbox-flutter-docs/docs-flutter-dev-ui-interactivity.html", - "snippets": [ - "Flutter Understanding constraints Flutter's build modes Hot reload ... How to create a custom widget. The difference between stateless and stateful widgets." - ] - } - ] -} \ No newline at end of file diff --git a/packages/api_client/lib/src/resources/questions_resource.dart b/packages/api_client/lib/src/resources/questions_resource.dart index 8f5aa6e..7b05322 100644 --- a/packages/api_client/lib/src/resources/questions_resource.dart +++ b/packages/api_client/lib/src/resources/questions_resource.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:api_client/api_client.dart'; -import 'package:cross_file/cross_file.dart'; +import 'package:api_client/src/resources/fake_response.dart'; /// {@template questions_resource} /// An api resource to get response to question from the VERTEX api @@ -40,8 +40,8 @@ class QuestionsResource { } body = response.body; } else { - const path = 'lib/src/resources/fake_response.json'; - body = await XFile(path).readAsString(); + await Future.delayed(const Duration(seconds: 1)); + body = FakeResponses.whatIsFlutterResponse; } try { diff --git a/packages/api_client/test/src/models/vertex_response_test.dart b/packages/api_client/test/src/models/vertex_response_test.dart index 1ed761f..8312109 100644 --- a/packages/api_client/test/src/models/vertex_response_test.dart +++ b/packages/api_client/test/src/models/vertex_response_test.dart @@ -3,6 +3,13 @@ import 'package:test/test.dart'; void main() { group('VertexResponse', () { + test('empty constructor', () { + expect( + VertexResponse.empty(), + equals(VertexResponse(summary: '', documents: const [])), + ); + }); + test('supports equality', () { expect( VertexResponse( diff --git a/packages/app_ui/lib/src/widgets/question_input_text_field.dart b/packages/app_ui/lib/src/widgets/question_input_text_field.dart index 77864e5..a99f429 100644 --- a/packages/app_ui/lib/src/widgets/question_input_text_field.dart +++ b/packages/app_ui/lib/src/widgets/question_input_text_field.dart @@ -11,6 +11,8 @@ class QuestionInputTextField extends StatefulWidget { required this.icon, required this.hint, required this.actionText, + required this.onTextUpdated, + required this.onActionPressed, super.key, }); @@ -23,6 +25,12 @@ class QuestionInputTextField extends StatefulWidget { /// The text to display on the right side of the text field. final String actionText; + /// Function called when text is updated + final ValueChanged onTextUpdated; + + /// + final VoidCallback onActionPressed; + @override State createState() => _QuestionTextFieldState(); } @@ -34,6 +42,9 @@ class _QuestionTextFieldState extends State { void initState() { super.initState(); _controller = TextEditingController(); + _controller.addListener(() { + widget.onTextUpdated(_controller.text); + }); } @override @@ -61,6 +72,7 @@ class _QuestionTextFieldState extends State { padding: const EdgeInsets.only(right: 12), child: CTAButton( label: widget.actionText, + onPressed: () => widget.onActionPressed(), ), ), ), diff --git a/packages/app_ui/test/src/widgets/question_input_text_field_test.dart b/packages/app_ui/test/src/widgets/question_input_text_field_test.dart index f8e5f17..694bb04 100644 --- a/packages/app_ui/test/src/widgets/question_input_text_field_test.dart +++ b/packages/app_ui/test/src/widgets/question_input_text_field_test.dart @@ -13,6 +13,8 @@ void main() { icon: SizedBox.shrink(), hint: 'hint', actionText: 'actionText', + onActionPressed: () {}, + onTextUpdated: (_) {}, ), ), ); @@ -20,5 +22,44 @@ void main() { expect(find.text('hint'), findsOneWidget); expect(find.byType(CTAButton), findsOneWidget); }); + + testWidgets('calls onTextUpdated typing on the text field', (tester) async { + var text = ''; + await tester.pumpApp( + Material( + child: QuestionInputTextField( + icon: SizedBox.shrink(), + hint: 'hint', + actionText: 'actionText', + onActionPressed: () {}, + onTextUpdated: (newText) { + text = newText; + }, + ), + ), + ); + await tester.enterText(find.byType(TextField), 'test'); + expect(text, equals('test')); + }); + + testWidgets('calls onActionPressed clicking on CTAButton', (tester) async { + var called = false; + await tester.pumpApp( + Material( + child: QuestionInputTextField( + icon: SizedBox.shrink(), + hint: 'hint', + actionText: 'actionText', + onActionPressed: () { + called = true; + }, + onTextUpdated: (_) {}, + ), + ), + ); + await tester.tap(find.byType(CTAButton)); + + expect(called, equals(true)); + }); }); } diff --git a/pubspec.lock b/pubspec.lock index 06ef6c2..7d96a5e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -542,6 +542,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + questions_repository: + dependency: "direct main" + description: + path: "packages/questions_repository" + relative: true + source: path + version: "0.1.0+1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index eebf0d8..b689f12 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: sdk: flutter intl: ^0.18.0 path_drawing: ^1.0.1 + questions_repository: + path: packages/questions_repository dev_dependencies: bloc_test: ^9.1.4 diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 5fcad3e..b4acc99 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -1,23 +1,23 @@ -import 'package:api_client/api_client.dart'; import 'package:dash_ai_search/app/app.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:questions_repository/questions_repository.dart'; -class _MockApiClient extends Mock implements ApiClient {} +class _MockQuestionsRepository extends Mock implements QuestionsRepository {} void main() { group('App', () { - late ApiClient apiClient; + late QuestionsRepository questionsRepository; setUp(() { - apiClient = _MockApiClient(); + questionsRepository = _MockQuestionsRepository(); }); testWidgets('renders HomePage', (tester) async { await tester.pumpWidget( App( - apiClient: apiClient, + questionsRepository: questionsRepository, ), ); expect(find.byType(HomePage), findsOneWidget); diff --git a/test/home/bloc/home_bloc_test.dart b/test/home/bloc/home_bloc_test.dart index 412364e..21c193d 100644 --- a/test/home/bloc/home_bloc_test.dart +++ b/test/home/bloc/home_bloc_test.dart @@ -1,13 +1,28 @@ +import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:questions_repository/questions_repository.dart'; + +class _MockQuestionsRepository extends Mock implements QuestionsRepository {} void main() { group('HomeBloc', () { + late QuestionsRepository questionsRepository; + + setUp(() { + questionsRepository = _MockQuestionsRepository(); + }); + + HomeBloc buildBloc() { + return HomeBloc(questionsRepository); + } + group('FromWelcomeToQuestion', () { blocTest( 'emits [welcomeToAskQuestion]', - build: HomeBloc.new, + build: buildBloc, act: (bloc) => bloc.add(FromWelcomeToQuestion()), expect: () => [ isA().having( @@ -22,7 +37,7 @@ void main() { group('AskQuestion', () { blocTest( 'emits [askQuestion]', - build: HomeBloc.new, + build: buildBloc, act: (bloc) => bloc.add(AskQuestion()), expect: () => [ isA().having( @@ -33,5 +48,36 @@ void main() { ], ); }); + + group('QueryUpdated', () { + blocTest( + 'emits query updated', + build: buildBloc, + act: (bloc) => bloc.add(QueryUpdated(query: 'new query')), + expect: () => [ + HomeState(query: 'new query'), + ], + ); + }); + + group('QuestionAsked', () { + blocTest( + 'emits [Status.askQuestionToThinking, Status.thinkingToResults] ' + 'with vertex response from _questionsRepository.getVertexResponse', + setUp: () { + when(() => questionsRepository.getVertexResponse(any())) + .thenAnswer((_) async => VertexResponse.empty()); + }, + build: buildBloc, + act: (bloc) => bloc.add(QuestionAsked()), + expect: () => [ + HomeState(status: Status.askQuestionToThinking), + HomeState( + status: Status.thinkingToResults, + vertexResponse: VertexResponse.empty(), + ), + ], + ); + }); }); } diff --git a/test/home/bloc/home_event_test.dart b/test/home/bloc/home_event_test.dart index a659d4d..245cc51 100644 --- a/test/home/bloc/home_event_test.dart +++ b/test/home/bloc/home_event_test.dart @@ -16,5 +16,19 @@ void main() { equals(AskQuestion()), ); }); + + test('QueryUpdated supports value equality', () { + expect( + QueryUpdated(query: 'query'), + equals(QueryUpdated(query: 'query')), + ); + }); + + test('QuestionAsked supports value equality', () { + expect( + QuestionAsked(), + equals(QuestionAsked()), + ); + }); }); } diff --git a/test/home/view/home_page_test.dart b/test/home/view/home_page_test.dart index c019535..7840954 100644 --- a/test/home/view/home_page_test.dart +++ b/test/home/view/home_page_test.dart @@ -3,17 +3,29 @@ import 'package:dash_ai_search/home/home.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:questions_repository/questions_repository.dart'; import '../../helpers/helpers.dart'; class _MockHomeBloc extends MockBloc implements HomeBloc {} +class _MockQuestionsRepository extends Mock implements QuestionsRepository {} + void main() { group('HomePage', () { + late QuestionsRepository questionsRepository; + + setUp(() { + questionsRepository = _MockQuestionsRepository(); + }); + testWidgets('renders HomeView', (tester) async { await tester.pumpApp( - HomePage(), + RepositoryProvider.value( + value: questionsRepository, + child: HomePage(), + ), ); expect(find.byType(HomeView), findsOneWidget); @@ -28,7 +40,8 @@ void main() { when(() => homeBloc.state).thenReturn(HomeState()); }); - testWidgets('renders correctly', (tester) async { + testWidgets('renders WelcomeView if isWelcomeVisible', (tester) async { + when(() => homeBloc.state).thenReturn(HomeState()); await tester.pumpApp( BlocProvider.value( value: homeBloc, @@ -36,9 +49,55 @@ void main() { ), ); - expect(find.byType(Background), findsOneWidget); - expect(find.byType(Logo), findsOneWidget); expect(find.byType(WelcomeView), findsOneWidget); }); + + testWidgets('renders QuestionView if isQuestionVisible', (tester) async { + when(() => homeBloc.state).thenReturn( + HomeState( + status: Status.askQuestion, + ), + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: HomeView(), + ), + ); + + expect(find.byType(QuestionView), findsOneWidget); + }); + + testWidgets('renders ThinkingView if isThinkingVisible', (tester) async { + when(() => homeBloc.state).thenReturn( + HomeState( + status: Status.thinking, + ), + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: HomeView(), + ), + ); + + expect(find.byType(ThinkingView), findsOneWidget); + }); + + testWidgets('renders ResultsView if isResultsVisible', (tester) async { + when(() => homeBloc.state).thenReturn( + HomeState( + status: Status.results, + ), + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: HomeView(), + ), + ); + + expect(find.byType(ResultsView), findsOneWidget); + }); }); } diff --git a/test/home/widgets/question_view_test.dart b/test/home/widgets/question_view_test.dart index 954f022..18983d4 100644 --- a/test/home/widgets/question_view_test.dart +++ b/test/home/widgets/question_view_test.dart @@ -34,5 +34,34 @@ void main() { expect(find.text(l10n.questionScreenTitle), findsOneWidget); expect(find.byType(QuestionInputTextField), findsOneWidget); }); + + testWidgets( + 'calls QueryUpdated writing on the TextField', + (WidgetTester tester) async { + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: Material(child: QuestionView()), + ), + ); + const newText = 'text'; + await tester.enterText(find.byType(TextField), newText); + verify(() => homeBloc.add(QueryUpdated(query: newText))).called(1); + }, + ); + + testWidgets( + 'calls QuestionAsked clicking on the CTAButton ', + (WidgetTester tester) async { + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: Material(child: QuestionView()), + ), + ); + await tester.tap(find.byType(CTAButton)); + verify(() => homeBloc.add(QuestionAsked())).called(1); + }, + ); }); }