diff --git a/03-custom-server/apps/calaverita_app/analysis_options.yaml b/03-human-in-the-loop/apps/calaverita_app/analysis_options.yaml similarity index 100% rename from 03-custom-server/apps/calaverita_app/analysis_options.yaml rename to 03-human-in-the-loop/apps/calaverita_app/analysis_options.yaml diff --git a/03-human-in-the-loop/apps/calaverita_app/lib/app/app.dart b/03-human-in-the-loop/apps/calaverita_app/lib/app/app.dart new file mode 100644 index 0000000..69483e9 --- /dev/null +++ b/03-human-in-the-loop/apps/calaverita_app/lib/app/app.dart @@ -0,0 +1,50 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:assistant_presentation/assistant_presentation.dart'; +import 'package:calaverita_app/app/app_config.dart'; +import 'package:calaverita_data/calaverita_data.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// Composes the calaverita and assistant features into an app. +class App extends StatelessWidget { + const App({required this.skullArt, super.key}); + + /// The SVGs of the skull, loaded at startup. + final SkullArt skullArt; + + static const _suggestions = [ + 'Ana is a pilot, make it sarcastic', + 'Give the skull a sombrero and teal color', + 'Write a calaverita for Mario, a chef', + ]; + + @override + Widget build(BuildContext context) { + return CopilotKit( + runtimeUrl: AppConfig.runtimeUrl, + headers: AppConfig.headers, + child: MaterialApp( + title: 'Calaverita Generator', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + // One agent for the whole screen: the details and the stage read + // its shared state, and the chat talks to it. + home: CopilotAgentScope( + agentId: AppConfig.agentId, + child: CalaveritaModule( + calaveritasRepository: const TemplateCalaveritasRepository(), + skullArt: skullArt, + // The tool sits around the chat, so the cards of its calls draw + // there. + trailingPanel: const ShowCalaveritaTool( + child: ChatModule( + agentId: AppConfig.agentId, + suggestions: _suggestions, + ), + ), + ), + ), + ), + ); + } +} diff --git a/03-human-in-the-loop/apps/calaverita_app/lib/app/app_config.dart b/03-human-in-the-loop/apps/calaverita_app/lib/app/app_config.dart new file mode 100644 index 0000000..cbe99d5 --- /dev/null +++ b/03-human-in-the-loop/apps/calaverita_app/lib/app/app_config.dart @@ -0,0 +1,56 @@ +import 'dart:math'; + +/// The one place that says where the agent lives. +/// +/// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with +/// `--dart-define=RUNTIME_URL=`). +class AppConfig { + const AppConfig._(); + + /// The Copilot Runtime of the `runtime/` folder of this repository. + static const defaultRuntimeUrl = 'http://localhost:8200/api/copilotkit'; + + /// The Copilot Runtime the app talks to. + static final Uri runtimeUrl = Uri.parse( + const String.fromEnvironment( + 'RUNTIME_URL', + defaultValue: defaultRuntimeUrl, + ), + ); + + /// The agent the app chats with. + /// + /// A runtime can serve several, so a different backend may use another id: + /// run with `--dart-define=AGENT_ID=`. + static const agentId = String.fromEnvironment( + 'AGENT_ID', + defaultValue: 'default', + ); + + /// The password of the shared backend, when it asks for one. + /// + /// Run with `--dart-define=WORKSHOP_CODE=`. A local runtime asks for + /// nothing, so leave it out there. + static const workshopCode = String.fromEnvironment('WORKSHOP_CODE'); + + /// A random id for this run of the app, so a server can tell sessions apart. + static final String sessionId = _randomId(); + + /// The headers sent with every run. + /// + /// Custom names on purpose: a runtime answers the browser's preflight with + /// `Access-Control-Allow-Headers: *`, which covers them but not + /// `Authorization`. A server that does not know them ignores them. + static Future> headers() async => { + 'X-Workshop-Session': sessionId, + if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode, + }; + + static String _randomId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } +} diff --git a/03-custom-server/apps/calaverita_app/lib/main.dart b/03-human-in-the-loop/apps/calaverita_app/lib/main.dart similarity index 100% rename from 03-custom-server/apps/calaverita_app/lib/main.dart rename to 03-human-in-the-loop/apps/calaverita_app/lib/main.dart diff --git a/03-custom-server/apps/calaverita_app/pubspec.yaml b/03-human-in-the-loop/apps/calaverita_app/pubspec.yaml similarity index 100% rename from 03-custom-server/apps/calaverita_app/pubspec.yaml rename to 03-human-in-the-loop/apps/calaverita_app/pubspec.yaml diff --git a/03-human-in-the-loop/apps/calaverita_app/test/app_test.dart b/03-human-in-the-loop/apps/calaverita_app/test/app_test.dart new file mode 100644 index 0000000..4eb0783 --- /dev/null +++ b/03-human-in-the-loop/apps/calaverita_app/test/app_test.dart @@ -0,0 +1,43 @@ +import 'package:calaverita_app/app/app.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pumpApp(WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(1600, 1000); + addTearDown(tester.view.reset); + await tester.pumpWidget(const App(skullArt: SkullArt.fromTemplates({}))); + await tester.pump(); + } + + testWidgets('shows the details, the stage and the copilot', (tester) async { + await pumpApp(tester); + + expect(find.text('Details'), findsOneWidget); + expect(find.text('Copilot'), findsOneWidget); + expect(find.byKey(const Key('calaverita_name_field')), findsOneWidget); + expect(find.text('Write a calaverita for Mario, a chef'), findsOneWidget); + }); + + testWidgets('writes a calaverita from the details', (tester) async { + await pumpApp(tester); + + await tester.enterText( + find.byKey(const Key('calaverita_name_field')), + 'Ana', + ); + await tester.enterText( + find.byKey(const Key('calaverita_job_field')), + 'Designer', + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('calaverita_generate_button'))); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.text('Calaverita for Ana'), findsOneWidget); + }); +} diff --git a/03-custom-server/apps/calaverita_app/web/favicon.png b/03-human-in-the-loop/apps/calaverita_app/web/favicon.png similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/favicon.png rename to 03-human-in-the-loop/apps/calaverita_app/web/favicon.png diff --git a/03-custom-server/apps/calaverita_app/web/icons/Icon-192.png b/03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-192.png similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/icons/Icon-192.png rename to 03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-192.png diff --git a/03-custom-server/apps/calaverita_app/web/icons/Icon-512.png b/03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-512.png similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/icons/Icon-512.png rename to 03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-512.png diff --git a/03-custom-server/apps/calaverita_app/web/icons/Icon-maskable-192.png b/03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-maskable-192.png similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/icons/Icon-maskable-192.png rename to 03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-maskable-192.png diff --git a/03-custom-server/apps/calaverita_app/web/icons/Icon-maskable-512.png b/03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-maskable-512.png similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/icons/Icon-maskable-512.png rename to 03-human-in-the-loop/apps/calaverita_app/web/icons/Icon-maskable-512.png diff --git a/03-custom-server/apps/calaverita_app/web/index.html b/03-human-in-the-loop/apps/calaverita_app/web/index.html similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/index.html rename to 03-human-in-the-loop/apps/calaverita_app/web/index.html diff --git a/03-custom-server/apps/calaverita_app/web/manifest.json b/03-human-in-the-loop/apps/calaverita_app/web/manifest.json similarity index 100% rename from 03-custom-server/apps/calaverita_app/web/manifest.json rename to 03-human-in-the-loop/apps/calaverita_app/web/manifest.json diff --git a/03-custom-server/features/assistant/assistant_presentation/analysis_options.yaml b/03-human-in-the-loop/features/assistant/assistant_presentation/analysis_options.yaml similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/analysis_options.yaml rename to 03-human-in-the-loop/features/assistant/assistant_presentation/analysis_options.yaml diff --git a/03-custom-server/features/assistant/assistant_presentation/lib/assistant_presentation.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/assistant_presentation.dart similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/lib/assistant_presentation.dart rename to 03-human-in-the-loop/features/assistant/assistant_presentation/lib/assistant_presentation.dart diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_args.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_args.dart new file mode 100644 index 0000000..70b158c --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_args.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; + +/// One answer the agent offers in an `askUser` question. +class AskUserOption extends Equatable { + const AskUserOption({required this.id, required this.label, this.hint}); + + /// Reads an option from JSON, or returns `null` without an id or a label. + static AskUserOption? tryFromJson(Object? json) { + if (json is! Map) return null; + final id = json['id']; + final label = json['label']; + final hint = json['hint']; + if (id is! String || id.isEmpty || label is! String || label.isEmpty) { + return null; + } + return AskUserOption( + id: id, + label: label, + hint: hint is String && hint.isNotEmpty ? hint : null, + ); + } + + /// What goes back to the agent when this option is picked. + final String id; + + /// What the person reads on the button. + final String label; + + /// A short line under the label, if the agent gave one. + final String? hint; + + @override + List get props => [id, label, hint]; +} + +/// The arguments of the `askUser` tool: a question and its options. +class AskUserArgs extends Equatable { + const AskUserArgs({this.question = '', this.options = const []}); + + factory AskUserArgs.fromJson(Map json) { + final question = json['question']; + final options = json['options']; + return AskUserArgs( + question: question is String ? question : '', + options: options is List + ? [for (final option in options) ?AskUserOption.tryFromJson(option)] + : const [], + ); + } + + final String question; + final List options; + + @override + List get props => [question, options]; +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_card.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_card.dart new file mode 100644 index 0000000..4579026 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_card.dart @@ -0,0 +1,48 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// The generative UI of an `askUser` call: the question with its options as +/// tiles. +/// +/// The tiles answer the call while it waits for the person (`respond` is set), +/// and the card flattens to show the answer once the call is complete. +class AskUserCard extends StatelessWidget { + const AskUserCard({ + required this.call, + required this.respond, + this.args, + super.key, + }); + + /// The call to show. + final ToolCallView call; + + /// Answers the call, or `null` unless it waits for the person. + final ToolCallResponder? respond; + + /// The arguments of [call], or `null` while they can't be decoded yet. + final AskUserArgs? args; + + @override + Widget build(BuildContext context) { + final answered = call.status == ToolCallStatus.complete; + final question = args?.question ?? ''; + return AppDecisionCard( + kindLabel: 'Question', + kindIcon: AppIcons.chat, + title: question.isEmpty ? null : question, + loading: question.isEmpty && !answered, + complete: answered, + children: [ + for (final option in args?.options ?? const []) + AppOptionTile( + label: option.label, + detail: option.hint, + selected: answered && call.result == option.id, + onTap: respond == null ? null : () => respond!(option.id), + ), + ], + ); + } +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_tool.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_tool.dart new file mode 100644 index 0000000..0d86614 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/ask_user/ask_user_tool.dart @@ -0,0 +1,54 @@ +import 'package:ack/ack.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_card.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/widgets.dart'; + +/// Gives the agent an `askUser` tool and draws its calls as an [AskUserCard] +/// in [child], e.g. the chat. +/// +/// This is human in the loop: the agent asks, its run waits, and the answer +/// the person taps goes back to the agent as the tool result. The answer is +/// the id of the option. +class AskUserTool extends StatefulWidget { + const AskUserTool({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _AskUserToolState(); +} + +class _AskUserToolState extends State { + /// Kept in a field: the SDK compares tools to know whether to register + /// again. + late final _tool = HumanInTheLoopTool( + name: 'askUser', + description: + 'Ask the user to pick one of a few options, then wait for the answer. ' + 'It comes back as the id of the option they picked. Use it for a ' + 'choice that is theirs to make, one question at a time.', + // Keep in sync with AskUserArgs. + parameters: Ack.object({ + 'question': Ack.string().describe('The question, one short sentence'), + 'options': Ack.list( + Ack.object({ + 'id': Ack.string().describe('A short id, e.g. playful'), + 'label': Ack.string().describe('What the button says'), + 'hint': Ack.string().optional().describe('An optional short line'), + }), + ).describe('Two to four options'), + }), + decode: AskUserArgs.fromJson, + ); + + @override + Widget build(BuildContext context) { + return CopilotHumanInTheLoop( + tool: _tool, + builder: (context, call, args, respond) => + AskUserCard(call: call, args: args, respond: respond), + child: widget.child, + ); + } +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/chat_module.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/chat_module.dart new file mode 100644 index 0000000..424c33c --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/chat_module.dart @@ -0,0 +1,43 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_tool.dart'; +import 'package:assistant_presentation/chat/views/activity_line.dart'; +import 'package:assistant_presentation/chat/views/chat_panel.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// The chat with the agent [agentId]. +/// +/// It must sit below a `CopilotKit`, which connects it to the runtime. +class ChatModule extends StatelessWidget { + const ChatModule({ + required this.agentId, + this.suggestions = const [], + super.key, + }); + + /// Id of the agent on the runtime, e.g. `default`. + final String agentId; + + /// Messages offered as chips while the chat is empty. + final List suggestions; + + @override + Widget build(BuildContext context) { + // The scope shares its agent with any other scope of the same id, so + // this is the agent the rest of the screen reads and edits. + return CopilotAgentScope( + agentId: agentId, + child: CopilotChatProvider( + agentId: agentId, + // The agent's own tool calls (changing the shared state) show as one + // quiet line instead of raw arguments. + child: AskUserTool( + child: ToolRenderer( + name: ToolRenderer.nameFallback, + builder: (context, call) => ActivityLine(call: call), + child: ChatPanel(suggestions: suggestions), + ), + ), + ), + ); + } +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart new file mode 100644 index 0000000..6b4cf12 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart @@ -0,0 +1,28 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// A quiet line for a tool call of the agent that has no card of its own. +class ActivityLine extends StatelessWidget { + const ActivityLine({required this.call, super.key}); + + final ToolCallView call; + + @override + Widget build(BuildContext context) { + final label = switch (call.name) { + 'AGUISendStateDelta' || 'AGUISendStateSnapshot' => 'Updated the form', + _ => null, + }; + if (label == null) return const SizedBox.shrink(); + final p = context.palette; + return Align( + alignment: Alignment.centerLeft, + child: AppChip( + label: label, + icon: AppIcons.wrench, + foreground: p.onNavyMuted, + background: p.onNavyFill, + ), + ); + } +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart new file mode 100644 index 0000000..b9ec41f --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart @@ -0,0 +1,188 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The conversation: the messages and tool calls, an empty state with +/// suggestions, and a composer. +class ChatPanel extends StatelessWidget { + const ChatPanel({required this.suggestions, super.key}); + + final List suggestions; + + @override + Widget build(BuildContext context) { + return AppPanel( + navy: true, + title: 'Copilot', + icon: AppIcons.sparkle, + trailing: const _StopButton(), + child: Column( + children: [ + Expanded(child: _Feed(suggestions: suggestions)), + const _Composer(), + ], + ), + ); + } +} + +class _StopButton extends StatelessWidget { + const _StopButton(); + + @override + Widget build(BuildContext context) { + return AgentStateBuilder( + // While a question waits for the person the run is open, but there is + // nothing to stop: the card has its own way out. + selector: (state) => state.isRunning && state.awaitingResponseIds.isEmpty, + builder: (context, busy) => busy + ? AppButton.ghost( + label: 'Stop', + size: AppButtonSize.compact, + onNavy: true, + onPressed: () => context.read().add( + const ChatStopRequested(), + ), + ) + : const SizedBox.shrink(), + ); + } +} + +class _Feed extends StatelessWidget { + const _Feed({required this.suggestions}); + + final List suggestions; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return BlocBuilder( + builder: (context, state) { + final rows = state.rows.reversed.toList(); + final last = rows.firstOrNull; + final working = + state.isRunning && + !(last is ChatMessageRow && last.message is AssistantMessage) && + !(last is ChatToolCallRow && last.call.awaitingResponse); + return Column( + children: [ + Expanded( + child: rows.isEmpty && !working + ? _Empty(suggestions: suggestions) + : ListView.separated( + // Newest first and anchored to the bottom, like a chat. + reverse: true, + padding: const EdgeInsets.all(AppSpacing.xl), + itemCount: rows.length + (working ? 1 : 0), + separatorBuilder: (_, _) => + const SizedBox(height: AppSpacing.lg), + itemBuilder: (context, index) { + if (working && index == 0) { + return const AppTypingBubble(onNavy: true); + } + return switch (rows[working ? index - 1 : index]) { + ChatMessageRow(:final message) => AppChatBubble( + key: ValueKey(message.id), + text: switch (message) { + UserMessage(:final content) => content, + AssistantMessage(:final content) => content ?? '', + _ => '', + }, + role: message is UserMessage + ? AppChatRole.manager + : AppChatRole.customer, + onNavy: true, + ), + ChatToolCallRow(:final call) => ToolCallWidget( + call: call, + ), + }; + }, + ), + ), + if (state.error != null) + Padding( + key: const Key('chat_error'), + padding: const EdgeInsets.all(AppSpacing.lg), + child: Text( + 'The copilot could not answer. Is the runtime running?', + style: AppTypography.small.copyWith(color: p.negative), + ), + ), + ], + ); + }, + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty({required this.suggestions}); + + final List suggestions; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.x3l), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.x3l, + children: [ + Text( + 'Tell me about your friend or coworker. I fill in the details ' + 'and dress the skull, and you can edit them by hand too.', + textAlign: TextAlign.center, + style: AppTypography.small.copyWith( + color: context.palette.onNavyMuted, + ), + ), + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.md, + runSpacing: AppSpacing.md, + children: [ + for (final text in suggestions) + AppSuggestionChip( + label: text, + onTap: () => context.read().add( + ChatMessageSent(text), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer(); + + @override + Widget build(BuildContext context) { + return AgentStateBuilder<({bool running, bool asking})>( + selector: (state) => ( + running: state.isRunning, + asking: state.awaitingResponseIds.isNotEmpty, + ), + builder: (context, state) => Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: AppComposer( + onNavy: true, + compact: true, + enabled: !state.running, + hint: state.asking + ? 'Answer the question above' + : 'Tell me about your friend…', + onSend: (text) => + context.read().add(ChatMessageSent(text)), + ), + ), + ); + } +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/pubspec.yaml b/03-human-in-the-loop/features/assistant/assistant_presentation/pubspec.yaml new file mode 100644 index 0000000..51b3e57 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/pubspec.yaml @@ -0,0 +1,23 @@ +name: assistant_presentation +description: The chat with the agent, a presentation-only feature. +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + flutter: ^3.47.0 + +dependencies: + ack: ^1.7.0 + app_ui: any + copilotkit_flutter: any + equatable: ^3.0.0 + flutter: + sdk: flutter + flutter_bloc: ^9.1.1 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^11.0.0 diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_args_test.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_args_test.dart new file mode 100644 index 0000000..4734267 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_args_test.dart @@ -0,0 +1,50 @@ +import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(AskUserArgs, () { + test('reads the question and the options', () { + final args = AskUserArgs.fromJson(const { + 'question': 'How should I roast Mario?', + 'options': [ + {'id': 'playful', 'label': 'Playful', 'hint': 'Friendly teasing'}, + {'id': 'sarcastic', 'label': 'Sarcastic'}, + ], + }); + + expect(args.question, 'How should I roast Mario?'); + expect(args.options, const [ + AskUserOption( + id: 'playful', + label: 'Playful', + hint: 'Friendly teasing', + ), + AskUserOption(id: 'sarcastic', label: 'Sarcastic'), + ]); + }); + + test('skips options without an id or a label', () { + final args = AskUserArgs.fromJson(const { + 'options': [ + {'id': 'a', 'label': 'A'}, + {'id': '', 'label': 'No id'}, + {'id': 'c'}, + 'not an option', + {'id': 'd', 'label': 'D', 'hint': ''}, + ], + }); + + expect(args.options, const [ + AskUserOption(id: 'a', label: 'A'), + AskUserOption(id: 'd', label: 'D'), + ]); + }); + + test('is empty while nothing has arrived', () { + final args = AskUserArgs.fromJson(const {}); + + expect(args.question, isEmpty); + expect(args.options, isEmpty); + }); + }); +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_card_test.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_card_test.dart new file mode 100644 index 0000000..8f4713f --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_card_test.dart @@ -0,0 +1,98 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_args.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_card.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const args = AskUserArgs( + question: 'How should I roast Mario?', + options: [ + AskUserOption(id: 'playful', label: 'Playful'), + AskUserOption(id: 'sarcastic', label: 'Sarcastic', hint: 'A bit sharper'), + ], + ); + + ToolCallView call(ToolCallStatus status, {String? result}) => ToolCallView( + id: 'call-1', + name: 'askUser', + status: status, + result: result, + ); + + Future pump( + WidgetTester tester, { + required ToolCallView call, + AskUserArgs? args, + ToolCallResponder? respond, + }) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: AskUserCard(call: call, args: args, respond: respond), + ), + ), + ); + } + + group(AskUserCard, () { + testWidgets('shows the question and answers with the id of the option', ( + tester, + ) async { + Object? answer; + await pump( + tester, + call: call(ToolCallStatus.executing), + args: args, + respond: (result) => answer = result, + ); + + expect(find.text('How should I roast Mario?'), findsOneWidget); + expect(find.text('A bit sharper'), findsOneWidget); + + await tester.tap(find.text('Sarcastic')); + + expect(answer, 'sarcastic'); + }); + + testWidgets('cannot be answered while it does not wait for the person', ( + tester, + ) async { + await pump(tester, call: call(ToolCallStatus.inProgress), args: args); + + // Nothing to call: tapping an option does nothing and does not throw. + await tester.tap(find.text('Playful')); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps a placeholder while the question is still arriving', ( + tester, + ) async { + await pump(tester, call: call(ToolCallStatus.inProgress)); + + expect(find.text('How should I roast Mario?'), findsNothing); + expect(find.byType(AppDecisionCard), findsOneWidget); + }); + + testWidgets('shows the answer once the call is complete', (tester) async { + await pump( + tester, + call: call(ToolCallStatus.complete, result: 'sarcastic'), + args: args, + ); + + final tiles = tester.widgetList( + find.byType(AppOptionTile), + ); + expect( + {for (final tile in tiles) tile.label: tile.selected}, + {'Playful': false, 'Sarcastic': true}, + ); + expect( + tester.widget(find.byType(AppDecisionCard)).complete, + isTrue, + ); + }); + }); +} diff --git a/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_tool_test.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_tool_test.dart new file mode 100644 index 0000000..59cc584 --- /dev/null +++ b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/ask_user/ask_user_tool_test.dart @@ -0,0 +1,51 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_card.dart'; +import 'package:assistant_presentation/chat/ask_user/ask_user_tool.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + /// Renders [call] the way the chat does: through a `ToolCallWidget` below + /// the tool. + Future pumpCall(WidgetTester tester, ToolCallView call) { + return tester.pumpWidget( + CopilotKit( + // Never called: nothing in these tests runs the agent. + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: AskUserTool(child: ToolCallWidget(call: call)), + ), + ), + ), + ); + } + + group(AskUserTool, () { + testWidgets('draws a call to askUser as a card with its options', ( + tester, + ) async { + await pumpCall( + tester, + const ToolCallView( + id: 'call-1', + name: 'askUser', + status: ToolCallStatus.executing, + awaitingResponse: true, + args: { + 'question': 'How should I roast Mario?', + 'options': [ + {'id': 'playful', 'label': 'Playful'}, + {'id': 'dramatic', 'label': 'Dramatic'}, + ], + }, + ), + ); + + expect(find.byType(AskUserCard), findsOneWidget); + expect(find.text('How should I roast Mario?'), findsOneWidget); + expect(find.text('Dramatic'), findsOneWidget); + }); + }); +} diff --git a/03-custom-server/features/assistant/assistant_presentation/test/chat/chat_module_test.dart b/03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/chat_module_test.dart similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/test/chat/chat_module_test.dart rename to 03-human-in-the-loop/features/assistant/assistant_presentation/test/chat/chat_module_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_data/analysis_options.yaml b/03-human-in-the-loop/features/calaverita/calaverita_data/analysis_options.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/analysis_options.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_data/analysis_options.yaml diff --git a/03-custom-server/features/calaverita/calaverita_data/lib/calaverita_data.dart b/03-human-in-the-loop/features/calaverita/calaverita_data/lib/calaverita_data.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/lib/calaverita_data.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_data/lib/calaverita_data.dart diff --git a/03-custom-server/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart b/03-human-in-the-loop/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart diff --git a/03-custom-server/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart b/03-human-in-the-loop/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart diff --git a/03-custom-server/features/calaverita/calaverita_data/pubspec.yaml b/03-human-in-the-loop/features/calaverita/calaverita_data/pubspec.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/pubspec.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_data/pubspec.yaml diff --git a/03-custom-server/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/analysis_options.yaml b/03-human-in-the-loop/features/calaverita/calaverita_domain/analysis_options.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/analysis_options.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/analysis_options.yaml diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/calaverita_domain.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/calaverita_domain.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/calaverita_domain.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/calaverita_domain.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/models/calaverita.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/calaverita.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/models/calaverita.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/calaverita.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/models/skull_part.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/skull_part.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/models/skull_part.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/skull_part.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/models/skull_style.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/skull_style.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/models/skull_style.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/skull_style.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/models/tone.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/tone.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/models/tone.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/models/tone.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/pubspec.yaml b/03-human-in-the-loop/features/calaverita/calaverita_domain/pubspec.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/pubspec.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/pubspec.yaml diff --git a/03-custom-server/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/test/models/calaverita_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/calaverita_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/test/models/calaverita_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/calaverita_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_domain/test/models/skull_style_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/skull_style_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_domain/test/models/skull_style_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_domain/test/models/skull_style_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/analysis_options.yaml b/03-human-in-the-loop/features/calaverita/calaverita_presentation/analysis_options.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/analysis_options.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/analysis_options.yaml diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/base/round.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/base/round.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/base/round.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/base/round.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg b/03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart diff --git a/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart new file mode 100644 index 0000000..669a754 --- /dev/null +++ b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart @@ -0,0 +1,252 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_cubit.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_state.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/draft_edits.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/shared_draft.dart'; +import 'package:calaverita_presentation/calaverita/views/details_panel.dart'; +import 'package:calaverita_presentation/calaverita/views/stage.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Width at or above which the columns are side by side. +const _desktopBreakpoint = 1000.0; + +/// The one screen: the details on the left and the stage in the middle, +/// with [trailingPanel] on the right. +/// +/// The details and the stage read the form from the agent's shared state, so +/// the page needs a `CopilotAgentScope` above it. +class CalaveritaPage extends StatefulWidget { + const CalaveritaPage({this.trailingPanel, super.key}); + + /// A panel the app puts beside the stage, or below it on a narrow screen. + final Widget? trailingPanel; + + @override + State createState() => _CalaveritaPageState(); +} + +class _CalaveritaPageState extends State { + @override + void initState() { + super.initState(); + // Waits a frame: sharing state while the tree builds would rebuild + // widgets that are still building. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) DraftEdits.of(context).ensureInitialized(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.palette.ground, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1680), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.x4l, + AppSpacing.x3l, + AppSpacing.x4l, + AppSpacing.x4l, + ), + child: Column( + children: [ + const _Header(), + const SizedBox(height: AppSpacing.x3l), + Expanded(child: _Layout(trailingPanel: widget.trailingPanel)), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [p.navy, p.navyAlt]), + borderRadius: AppRadii.cardBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.x5l, + vertical: AppSpacing.xl, + ), + child: Row( + spacing: AppSpacing.x5l, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Calaverita Generator', + style: AppTypography.title.copyWith(color: p.onNavy), + ), + Text( + 'A funny rhyming skull for someone you love.', + style: AppTypography.small.copyWith(color: p.onNavyMuted), + ), + ], + ), + ), + const _Logos(), + ], + ), + ), + ); + } +} + +class _Layout extends StatelessWidget { + const _Layout({this.trailingPanel}); + + final Widget? trailingPanel; + + @override + Widget build(BuildContext context) { + final panel = trailingPanel; + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= _desktopBreakpoint) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.x3l, + children: [ + const SizedBox(width: 380, child: _Details()), + const Expanded(child: _Stage()), + if (panel != null) SizedBox(width: 420, child: panel), + ], + ); + } + return SingleChildScrollView( + child: Column( + spacing: AppSpacing.x3l, + children: [ + const SizedBox(height: 640, child: _Stage()), + if (panel != null) SizedBox(height: 520, child: panel), + const SizedBox(height: 760, child: _Details()), + ], + ), + ); + }, + ); + } +} + +class _Details extends StatelessWidget { + const _Details(); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final edits = DraftEdits.of(context); + return SharedDraft( + builder: (context, draft) => + BlocBuilder( + builder: (context, state) { + final isGenerating = state.status == GenerationStatus.generating; + return DetailsPanel( + draft: draft, + canGenerate: draft.isComplete && !isGenerating, + isGenerating: isGenerating, + onNameChanged: edits.nameChanged, + onJobChanged: edits.jobChanged, + onToneChanged: edits.toneChanged, + onSkullPartChanged: edits.skullPartChanged, + onSkullColorChanged: edits.skullColorChanged, + onGenerate: () => cubit.generate(draft), + ); + }, + ), + ); + } +} + +class _Stage extends StatelessWidget { + const _Stage(); + + @override + Widget build(BuildContext context) { + return SharedDraft( + builder: (context, draft) => + BlocBuilder( + builder: (context, state) => Stage( + skull: draft.skull, + status: state.status, + calaverita: state.calaverita, + badge: const _AgentBadge(), + ), + ), + ); + } +} + +/// "Copilot is working…" while the agent handles a request. +class _AgentBadge extends StatelessWidget { + const _AgentBadge(); + + @override + Widget build(BuildContext context) { + return AgentStateBuilder( + selector: (state) => state.isRunning && state.awaitingResponseIds.isEmpty, + builder: (context, busy) => AnimatedOpacity( + duration: AppMotion.quick, + opacity: busy ? 1 : 0, + child: const AppChip( + label: 'Copilot is working…', + icon: AppIcons.sparkle, + tone: AppTone.accent, + ), + ), + ); + } +} + +/// The CopilotKit and VGV logos on the right of the banner. They make way for +/// the title on a narrow screen. +class _Logos extends StatelessWidget { + const _Logos(); + + /// Width of the screen from which the logos are shown. + static const _minWidth = 640.0; + + @override + Widget build(BuildContext context) { + if (MediaQuery.sizeOf(context).width < _minWidth) { + return const SizedBox.shrink(); + } + final p = context.palette; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.x4l, + children: [ + const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadii.pillBorder, + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.md, + ), + child: AppCopilotKitLogo(height: 26), + ), + ), + AppVgvLockup(height: 28, color: p.onNavy), + ], + ); + } +} diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/pubspec.yaml b/03-human-in-the-loop/features/calaverita/calaverita_presentation/pubspec.yaml similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/pubspec.yaml rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/pubspec.yaml diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart diff --git a/03-custom-server/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart b/03-human-in-the-loop/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart rename to 03-human-in-the-loop/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart diff --git a/03-human-in-the-loop/pubspec.lock b/03-human-in-the-loop/pubspec.lock new file mode 100644 index 0000000..40e2733 --- /dev/null +++ b/03-human-in-the-loop/pubspec.lock @@ -0,0 +1,696 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" + url: "https://pub.dev" + source: hosted + version: "103.0.0" + ack: + dependency: transitive + description: + name: ack + sha256: "58546330839c455e5816c31be0b708a33f6b57ecd580d61f86d21c3df612f081" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + ag_ui: + dependency: transitive + description: + name: ag_ui + sha256: f963810252e5c080af0ff779b03949ac8d45362042bf2ec2b7c711737d3e6457 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bloc: + dependency: transitive + description: + name: bloc + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 + url: "https://pub.dev" + source: hosted + version: "9.2.1" + bloc_concurrency: + dependency: transitive + description: + name: bloc_concurrency + sha256: "86b7b17a0a78f77fca0d7c030632b59b593b22acea2d96972588f40d4ef53a94" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + bloc_test: + dependency: transitive + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + copilotkit: + dependency: "direct overridden" + description: + path: "packages/copilotkit" + ref: e52039c1729d57358ddac9c956fd2a2052928bbd + resolved-ref: e52039c1729d57358ddac9c956fd2a2052928bbd + url: "https://github.com/VGVentures/copilotkit_flutter_sdk.git" + source: git + version: "0.1.0+1" + copilotkit_flutter: + dependency: "direct overridden" + description: + path: "packages/copilotkit_flutter" + ref: e52039c1729d57358ddac9c956fd2a2052928bbd + resolved-ref: e52039c1729d57358ddac9c956fd2a2052928bbd + url: "https://github.com/VGVentures/copilotkit_flutter_sdk.git" + source: git + version: "0.1.0+1" + coverage: + dependency: transitive + description: + name: coverage + sha256: ff89e37c19355194e279d26dafe8e4bece46c9ea63a30be4dc68214f5bc290d3 + url: "https://pub.dev" + source: hosted + version: "1.16.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: ea36e500cbf0ec18361deabca755434f73e42fe53c5130021c7e2d6b5ebe1702 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: transitive + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "2b56423afc7651a81da53ed3a4f9f51e92572293f54d0d54e19b759f13714849" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_utils: + dependency: "direct overridden" + description: + path: "packages/json_utils" + ref: e52039c1729d57358ddac9c956fd2a2052928bbd + resolved-ref: e52039c1729d57358ddac9c956fd2a2052928bbd + url: "https://github.com/VGVentures/copilotkit_flutter_sdk.git" + source: git + version: "0.1.0+1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 + url: "https://pub.dev" + source: hosted + version: "1.4.3" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: "480504af9e5323899c5be26a4f6c2fc4437d50954d39842f0290f4ae733d57b4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "0964703d02677ac8636b4aef95de0b7e816224ff0082908c2c950529cb9a2f5e" + url: "https://pub.dev" + source: hosted + version: "1.2.4" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: a8e2c19796144da2f81bcc96dad1bb19c4677b561c452081b97ee737b9faed65 + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + very_good_analysis: + dependency: transitive + description: + name: very_good_analysis + sha256: "0bd5eb8d6be76aa0d71e70cbdae6cac8f784f761170568e3c2214a6948c7f6d3" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0 <4.0.0" diff --git a/03-custom-server/pubspec.yaml b/03-human-in-the-loop/pubspec.yaml similarity index 100% rename from 03-custom-server/pubspec.yaml rename to 03-human-in-the-loop/pubspec.yaml diff --git a/03-custom-server/shared/app_ui/.gitignore b/03-human-in-the-loop/shared/app_ui/.gitignore similarity index 100% rename from 03-custom-server/shared/app_ui/.gitignore rename to 03-human-in-the-loop/shared/app_ui/.gitignore diff --git a/03-custom-server/shared/app_ui/LICENSE b/03-human-in-the-loop/shared/app_ui/LICENSE similarity index 100% rename from 03-custom-server/shared/app_ui/LICENSE rename to 03-human-in-the-loop/shared/app_ui/LICENSE diff --git a/03-custom-server/shared/app_ui/README.md b/03-human-in-the-loop/shared/app_ui/README.md similarity index 100% rename from 03-custom-server/shared/app_ui/README.md rename to 03-human-in-the-loop/shared/app_ui/README.md diff --git a/03-custom-server/shared/app_ui/analysis_options.yaml b/03-human-in-the-loop/shared/app_ui/analysis_options.yaml similarity index 100% rename from 03-custom-server/shared/app_ui/analysis_options.yaml rename to 03-human-in-the-loop/shared/app_ui/analysis_options.yaml diff --git a/03-custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf b/03-human-in-the-loop/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf diff --git a/03-custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf b/03-human-in-the-loop/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf diff --git a/03-custom-server/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt b/03-human-in-the-loop/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt diff --git a/03-custom-server/shared/app_ui/assets/fonts/OFL.txt b/03-human-in-the-loop/shared/app_ui/assets/fonts/OFL.txt similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/OFL.txt rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/OFL.txt diff --git a/03-custom-server/shared/app_ui/assets/fonts/Poppins-Medium.ttf b/03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-Medium.ttf similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/Poppins-Medium.ttf rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-Medium.ttf diff --git a/03-custom-server/shared/app_ui/assets/fonts/Poppins-Regular.ttf b/03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-Regular.ttf similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/Poppins-Regular.ttf rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-Regular.ttf diff --git a/03-custom-server/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf b/03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf similarity index 100% rename from 03-custom-server/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf rename to 03-human-in-the-loop/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf diff --git a/03-custom-server/shared/app_ui/assets/images/copilotkit-logo.png b/03-human-in-the-loop/shared/app_ui/assets/images/copilotkit-logo.png similarity index 100% rename from 03-custom-server/shared/app_ui/assets/images/copilotkit-logo.png rename to 03-human-in-the-loop/shared/app_ui/assets/images/copilotkit-logo.png diff --git a/03-custom-server/shared/app_ui/assets/images/unicorn-dark.svg b/03-human-in-the-loop/shared/app_ui/assets/images/unicorn-dark.svg similarity index 100% rename from 03-custom-server/shared/app_ui/assets/images/unicorn-dark.svg rename to 03-human-in-the-loop/shared/app_ui/assets/images/unicorn-dark.svg diff --git a/03-custom-server/shared/app_ui/assets/images/unicorn.svg b/03-human-in-the-loop/shared/app_ui/assets/images/unicorn.svg similarity index 100% rename from 03-custom-server/shared/app_ui/assets/images/unicorn.svg rename to 03-human-in-the-loop/shared/app_ui/assets/images/unicorn.svg diff --git a/03-custom-server/shared/app_ui/assets/images/vgv-lockup.svg b/03-human-in-the-loop/shared/app_ui/assets/images/vgv-lockup.svg similarity index 100% rename from 03-custom-server/shared/app_ui/assets/images/vgv-lockup.svg rename to 03-human-in-the-loop/shared/app_ui/assets/images/vgv-lockup.svg diff --git a/03-custom-server/shared/app_ui/lib/app_ui.dart b/03-human-in-the-loop/shared/app_ui/lib/app_ui.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/app_ui.dart rename to 03-human-in-the-loop/shared/app_ui/lib/app_ui.dart diff --git a/03-custom-server/shared/app_ui/lib/src/theme/app_palette.dart b/03-human-in-the-loop/shared/app_ui/lib/src/theme/app_palette.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/theme/app_palette.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/theme/app_palette.dart diff --git a/03-custom-server/shared/app_ui/lib/src/theme/app_theme.dart b/03-human-in-the-loop/shared/app_ui/lib/src/theme/app_theme.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/theme/app_theme.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/theme/app_theme.dart diff --git a/03-custom-server/shared/app_ui/lib/src/theme/theme.dart b/03-human-in-the-loop/shared/app_ui/lib/src/theme/theme.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/theme/theme.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/theme/theme.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_colors.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_colors.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_colors.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_colors.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_elevation.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_elevation.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_elevation.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_elevation.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_motion.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_motion.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_motion.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_motion.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_radii.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_radii.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_radii.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_radii.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_sizes.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_sizes.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_sizes.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_sizes.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_spacing.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_spacing.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_spacing.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_spacing.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/app_typography.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_typography.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/app_typography.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/app_typography.dart diff --git a/03-custom-server/shared/app_ui/lib/src/tokens/tokens.dart b/03-human-in-the-loop/shared/app_ui/lib/src/tokens/tokens.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/tokens/tokens.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/tokens/tokens.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_brand.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_brand.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_brand.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_brand.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_button.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_button.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_button.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_button.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_card.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_card.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_card.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_card.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_chat.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_chat.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_chat.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_chat.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_chip.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_chip.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_chip.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_chip.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_decision.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_decision.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_decision.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_decision.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_dialog.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_dialog.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_dialog.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_dialog.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_icons.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_icons.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_icons.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_icons.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_order_row.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_order_row.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_order_row.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_order_row.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_person.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_person.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_person.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_person.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_pressable.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_pressable.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_pressable.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_pressable.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_progress.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_progress.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_progress.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_progress.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_segmented.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_segmented.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_segmented.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_segmented.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_skeleton.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_skeleton.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_skeleton.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_skeleton.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_stat.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_stat.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_stat.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_stat.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_toast.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_toast.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_toast.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_toast.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/app_tone.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_tone.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/app_tone.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/app_tone.dart diff --git a/03-custom-server/shared/app_ui/lib/src/widgets/widgets.dart b/03-human-in-the-loop/shared/app_ui/lib/src/widgets/widgets.dart similarity index 100% rename from 03-custom-server/shared/app_ui/lib/src/widgets/widgets.dart rename to 03-human-in-the-loop/shared/app_ui/lib/src/widgets/widgets.dart diff --git a/03-custom-server/shared/app_ui/pubspec.yaml b/03-human-in-the-loop/shared/app_ui/pubspec.yaml similarity index 100% rename from 03-custom-server/shared/app_ui/pubspec.yaml rename to 03-human-in-the-loop/shared/app_ui/pubspec.yaml diff --git a/03-custom-server/shared/app_ui/test/helpers/helpers.dart b/03-human-in-the-loop/shared/app_ui/test/helpers/helpers.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/helpers/helpers.dart rename to 03-human-in-the-loop/shared/app_ui/test/helpers/helpers.dart diff --git a/03-custom-server/shared/app_ui/test/helpers/pump_app.dart b/03-human-in-the-loop/shared/app_ui/test/helpers/pump_app.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/helpers/pump_app.dart rename to 03-human-in-the-loop/shared/app_ui/test/helpers/pump_app.dart diff --git a/03-custom-server/shared/app_ui/test/src/theme/app_theme_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/theme/app_theme_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/theme/app_theme_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/theme/app_theme_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/tokens/tokens_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/tokens/tokens_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/tokens/tokens_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/tokens/tokens_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_button_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_button_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_button_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_button_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_card_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_card_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_card_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_card_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_chat_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_chat_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_chat_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_chat_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_chip_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_chip_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_chip_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_chip_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_decision_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_decision_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_decision_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_decision_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_dialog_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_dialog_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_dialog_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_dialog_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_misc_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_misc_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_misc_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_misc_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_order_row_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_order_row_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_order_row_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_order_row_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_skeleton_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_skeleton_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_skeleton_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_skeleton_test.dart diff --git a/03-custom-server/shared/app_ui/test/src/widgets/app_stat_test.dart b/03-human-in-the-loop/shared/app_ui/test/src/widgets/app_stat_test.dart similarity index 100% rename from 03-custom-server/shared/app_ui/test/src/widgets/app_stat_test.dart rename to 03-human-in-the-loop/shared/app_ui/test/src/widgets/app_stat_test.dart diff --git a/README.md b/README.md index ad60598..21b1d43 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ before it, so compare two folders to see exactly what changed. | [`00-foundations`](00-foundations) | The app with local state and no agent. You fill the form, dress the skull and press a button. | The architecture: a domain, a data and a presentation package per feature. | | [`01-shared-state`](01-shared-state) | A copilot panel appears. You tell it about your friend and it fills the form and dresses the skull. | **Shared state**: `AgentStateBuilder`, `patchSharedState` and `setSharedState`. | | [`02-generative-ui`](02-generative-ui) | You ask for the calaverita and the agent writes it: a card with the skull appears in the chat and the verse shows under the skull on the stage. | **Generative UI**: a frontend tool registered with `CopilotTool` whose builder renders a widget. | -| [`03-custom-server`](03-custom-server) | The same app, talking to a small server of its own instead of the built-in agent. *Optional.* | **Server logic**: validating what the agent changes and shaping its prompt. | +| [`03-human-in-the-loop`](03-human-in-the-loop) | You ask for a calaverita without a tone and the agent asks you how to roast, with buttons in the chat. | **Human in the loop**: a tool registered with `CopilotHumanInTheLoop`, whose run waits for your answer. | ## Quick start @@ -82,8 +82,7 @@ node tool/doctor.mjs ## Run a step The app talks to a *Copilot Runtime*, a small server that sits between the app -and the model. Steps 01 and 02 use the one in [`runtime/`](runtime); step 03 has -its own. +and the model. Steps 01 to 03 use the one in [`runtime/`](runtime). **1. Start the runtime** (steps 01 and 02) in a first terminal: @@ -117,7 +116,7 @@ To use another model, set `COPILOTKIT_MODEL` in `runtime/.env`. The default is **2. Run the app** in a second terminal. `00-foundations` needs no runtime. ```sh -cd 01-shared-state # or 00-foundations, 02-generative-ui +cd 01-shared-state # or 00-foundations, 02-generative-ui, 03-human-in-the-loop fvm flutter pub get cd apps/calaverita_app fvm flutter run -d chrome @@ -127,7 +126,7 @@ fvm flutter run -d chrome Each step adds one thing the one before it couldn't do. The **Write my calaverita** button always uses the offline template, so it works in every step. -In steps 02 and 03 the agent can write a verse too. +From step 02 the agent can write a verse too. #### 00 · Foundations (no agent) @@ -163,25 +162,20 @@ shows under the skull on the stage. If the name or the job is missing, the agent asks for it instead. -#### 03 · Custom server (optional) +#### 03 · Human in the loop (the agent asks you) -Start the server in a first terminal. It uses port 4100, so the runtime of steps -01 and 02 can keep running: +Type a request **without a tone**: *"Write a calaverita for Mario, a chef"*. You +can also click that suggestion. -```sh -cd 03-custom-server/server -cp .env.example .env # set ANTHROPIC_API_KEY -npm install -npm start # http://localhost:4100/api/copilotkit -``` - -Then run the app from `03-custom-server` as above and try the same messages as -in step 02. +**You should see:** the form fill in with Mario and chef, then a **Question** +card in the chat: *"How should I roast Mario?"* with **Playful**, **Sarcastic** +and **Dramatic**. The agent waits. Tap one, and it sets the tone, writes the +calaverita and shows it in the chat and on the stage. -**What's different:** this server checks every change the agent makes to the -form and tells the model what it rejected, so it can fix it and try again. It -also puts the current form in the prompt on every run. `npm run smoke` runs its -checks offline, with no key. +Name a tone in your message (*"Write a sarcastic calaverita for Mario, a chef"*) +and it doesn't ask. While the question waits, the copilot isn't "working": the +**Stop** button and the typing dots go away, and the message box points to the +card. ## Point the app at another backend @@ -242,12 +236,15 @@ packages. - Give the skull more parts: a beard, a different pattern, a bandana. Add the SVG and the variant in `SkullPart`, and teach the agent in the prompt. -- Let the agent ask before it guesses, with a human-in-the-loop tool - (`CopilotHumanInTheLoop`): *"make it fancier"* could offer three options. +- Let the agent ask more: *"make it fancier"* could offer three skull styles with + the `askUser` tool of step 03. - Replace the template repository with a call to the agent, so the *Write my calaverita* button uses the model too. - Save the calaverita as an image, or share it. -- Move the step 03 server somewhere public and point `AppConfig` at it. +- Run your own agent server. `extras/custom-server/server` is a small custom one that + checks every change the agent makes to the form. Start it (`npm install`, then + `npm start`) and point the app at it with + `node tool/setup.mjs --backend custom --url http://localhost:4100/api/copilotkit --agent calaverita`. ## Troubleshooting diff --git a/extras/custom-server/apps/calaverita_app/analysis_options.yaml b/extras/custom-server/apps/calaverita_app/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/apps/calaverita_app/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/03-custom-server/apps/calaverita_app/lib/app/app.dart b/extras/custom-server/apps/calaverita_app/lib/app/app.dart similarity index 100% rename from 03-custom-server/apps/calaverita_app/lib/app/app.dart rename to extras/custom-server/apps/calaverita_app/lib/app/app.dart diff --git a/03-custom-server/apps/calaverita_app/lib/app/app_config.dart b/extras/custom-server/apps/calaverita_app/lib/app/app_config.dart similarity index 100% rename from 03-custom-server/apps/calaverita_app/lib/app/app_config.dart rename to extras/custom-server/apps/calaverita_app/lib/app/app_config.dart diff --git a/extras/custom-server/apps/calaverita_app/lib/main.dart b/extras/custom-server/apps/calaverita_app/lib/main.dart new file mode 100644 index 0000000..777761e --- /dev/null +++ b/extras/custom-server/apps/calaverita_app/lib/main.dart @@ -0,0 +1,8 @@ +import 'package:calaverita_app/app/app.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter/widgets.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp(App(skullArt: await SkullArt.load())); +} diff --git a/extras/custom-server/apps/calaverita_app/pubspec.yaml b/extras/custom-server/apps/calaverita_app/pubspec.yaml new file mode 100644 index 0000000..6a74b65 --- /dev/null +++ b/extras/custom-server/apps/calaverita_app/pubspec.yaml @@ -0,0 +1,26 @@ +name: calaverita_app +description: Calaverita Generator, a Flutter web app that writes funny rhyming skulls. +version: 0.1.0+1 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + flutter: ^3.47.0 + +dependencies: + app_ui: any + assistant_presentation: any + calaverita_data: any + calaverita_presentation: any + copilotkit_flutter: any + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^11.0.0 + +flutter: + uses-material-design: true diff --git a/03-custom-server/apps/calaverita_app/test/app_test.dart b/extras/custom-server/apps/calaverita_app/test/app_test.dart similarity index 100% rename from 03-custom-server/apps/calaverita_app/test/app_test.dart rename to extras/custom-server/apps/calaverita_app/test/app_test.dart diff --git a/extras/custom-server/apps/calaverita_app/web/favicon.png b/extras/custom-server/apps/calaverita_app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/extras/custom-server/apps/calaverita_app/web/favicon.png differ diff --git a/extras/custom-server/apps/calaverita_app/web/icons/Icon-192.png b/extras/custom-server/apps/calaverita_app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/extras/custom-server/apps/calaverita_app/web/icons/Icon-192.png differ diff --git a/extras/custom-server/apps/calaverita_app/web/icons/Icon-512.png b/extras/custom-server/apps/calaverita_app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/extras/custom-server/apps/calaverita_app/web/icons/Icon-512.png differ diff --git a/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-192.png b/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-192.png differ diff --git a/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-512.png b/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/extras/custom-server/apps/calaverita_app/web/icons/Icon-maskable-512.png differ diff --git a/extras/custom-server/apps/calaverita_app/web/index.html b/extras/custom-server/apps/calaverita_app/web/index.html new file mode 100644 index 0000000..19ed8ee --- /dev/null +++ b/extras/custom-server/apps/calaverita_app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + calaverita_app + + + + + + + diff --git a/extras/custom-server/apps/calaverita_app/web/manifest.json b/extras/custom-server/apps/calaverita_app/web/manifest.json new file mode 100644 index 0000000..2fc8ead --- /dev/null +++ b/extras/custom-server/apps/calaverita_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "calaverita_app", + "short_name": "calaverita_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/extras/custom-server/features/assistant/assistant_presentation/analysis_options.yaml b/extras/custom-server/features/assistant/assistant_presentation/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/features/assistant/assistant_presentation/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/extras/custom-server/features/assistant/assistant_presentation/lib/assistant_presentation.dart b/extras/custom-server/features/assistant/assistant_presentation/lib/assistant_presentation.dart new file mode 100644 index 0000000..13bee2e --- /dev/null +++ b/extras/custom-server/features/assistant/assistant_presentation/lib/assistant_presentation.dart @@ -0,0 +1 @@ +export 'chat/chat_module.dart'; diff --git a/03-custom-server/features/assistant/assistant_presentation/lib/chat/chat_module.dart b/extras/custom-server/features/assistant/assistant_presentation/lib/chat/chat_module.dart similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/lib/chat/chat_module.dart rename to extras/custom-server/features/assistant/assistant_presentation/lib/chat/chat_module.dart diff --git a/03-custom-server/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart b/extras/custom-server/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart rename to extras/custom-server/features/assistant/assistant_presentation/lib/chat/views/activity_line.dart diff --git a/03-custom-server/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart b/extras/custom-server/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart rename to extras/custom-server/features/assistant/assistant_presentation/lib/chat/views/chat_panel.dart diff --git a/03-custom-server/features/assistant/assistant_presentation/pubspec.yaml b/extras/custom-server/features/assistant/assistant_presentation/pubspec.yaml similarity index 100% rename from 03-custom-server/features/assistant/assistant_presentation/pubspec.yaml rename to extras/custom-server/features/assistant/assistant_presentation/pubspec.yaml diff --git a/extras/custom-server/features/assistant/assistant_presentation/test/chat/chat_module_test.dart b/extras/custom-server/features/assistant/assistant_presentation/test/chat/chat_module_test.dart new file mode 100644 index 0000000..c97aa92 --- /dev/null +++ b/extras/custom-server/features/assistant/assistant_presentation/test/chat/chat_module_test.dart @@ -0,0 +1,46 @@ +import 'package:assistant_presentation/assistant_presentation.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('offers the suggestions while the chat is empty', (tester) async { + await tester.pumpWidget( + CopilotKit( + // Never called: nothing in this test runs the agent. + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: const MaterialApp( + home: Scaffold( + body: ChatModule(agentId: 'default', suggestions: ['Hola']), + ), + ), + ), + ); + + expect(find.text('Copilot'), findsOneWidget); + expect(find.text('Hola'), findsOneWidget); + }); + + testWidgets('sends a suggestion as a message', (tester) async { + await tester.pumpWidget( + CopilotKit( + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: const MaterialApp( + home: Scaffold( + body: ChatModule(agentId: 'default', suggestions: ['Hola']), + ), + ), + ), + ); + + await tester.tap(find.text('Hola')); + await tester.pump(); + + expect(find.text('Hola'), findsOneWidget); + // The suggestion became a user bubble, so the empty state is gone. + expect( + find.textContaining('Tell me about your friend or coworker'), + findsNothing, + ); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_data/analysis_options.yaml b/extras/custom-server/features/calaverita/calaverita_data/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/extras/custom-server/features/calaverita/calaverita_data/lib/calaverita_data.dart b/extras/custom-server/features/calaverita/calaverita_data/lib/calaverita_data.dart new file mode 100644 index 0000000..a920091 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/lib/calaverita_data.dart @@ -0,0 +1 @@ +export 'repositories/template_calaveritas_repository.dart'; diff --git a/extras/custom-server/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart b/extras/custom-server/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart new file mode 100644 index 0000000..6d58b98 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/lib/repositories/template_calaveritas_repository.dart @@ -0,0 +1,41 @@ +import 'package:calaverita_data/templates/calaverita_templates.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; + +/// Writes calaveritas from ready-made verses, with no network and no agent. +/// +/// The same draft always gets the same verse, so results are repeatable. +class TemplateCalaveritasRepository implements ICalaveritasRepository { + const TemplateCalaveritasRepository({ + CalaveritaTemplates templates = const CalaveritaTemplates(), + Duration delay = const Duration(milliseconds: 600), + }) : _templates = templates, + _delay = delay; + + final CalaveritaTemplates _templates; + + /// How long writing takes, so the loading state is visible. + final Duration _delay; + + @override + Future generate(CalaveritaDraft draft) async { + if (!draft.isComplete) { + throw ArgumentError.value(draft, 'draft', 'needs a name and a job'); + } + await Future.delayed(_delay); + final name = draft.name.trim(); + final job = draft.job.trim().toLowerCase(); + final verses = _templates.of(draft.tone); + final seed = '$name$job'.codeUnits.fold(0, (sum, unit) => sum + unit); + final verse = verses[seed % verses.length]; + return Calaverita( + name: name, + job: job, + tone: draft.tone, + skull: draft.skull, + lines: [ + for (final line in verse) + line.replaceAll('{name}', name).replaceAll('{job}', job), + ], + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart b/extras/custom-server/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart new file mode 100644 index 0000000..9cbcaaf --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/lib/templates/calaverita_templates.dart @@ -0,0 +1,78 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; + +/// Four-line verses with `{name}` and `{job}` slots, per [Tone]. +/// +/// The rhymes sit on fixed words, so they hold for any name and job. +class CalaveritaTemplates { + const CalaveritaTemplates(); + + /// The verses written in [tone]. + List> of(Tone tone) => switch (tone) { + Tone.playful => _playful, + Tone.sarcastic => _sarcastic, + Tone.dramatic => _dramatic, + }; + + static const _playful = [ + [ + "Doña Calavera knocked on {name}'s door,", + '"Leave your {job} desk, there are tamales and more!"', + 'But {name} said, "One more email, por favor,"', + 'And that is why the skull still waits at the door.', + ], + [ + '{name} the {job}, so charming, so sweet,', + 'Danced with a skeleton, two left feet.', + 'The mariachis played and the skulls all cheered,', + '"Best {job} ever!" ... and then disappeared.', + ], + [ + 'Little skull {name}, with sugar so bright,', + 'Left the {job} life for a fiesta tonight.', + 'With cempasúchil crowns and a conga line, too,', + 'Even Death asked, "Hey {name}, can I come with you?"', + ], + ]; + + static const _sarcastic = [ + [ + '{name}, the {job}, so busy, so wise,', + 'Met Death in a meeting and rolled both eyes.', + '"Could this have been an email?" {name} replied,', + '"Too late," said La Muerte, "enjoy the ride."', + ], + [ + 'They say a {job} never truly rests,', + 'Yet {name} is napping, put that to the test.', + 'La Catrina brings flowers, and says with a grin,', + '"Finally on time, now let the party begin."', + ], + [ + 'Here lies {name}, the pride of the {job} crew,', + 'Who always knew best, and told us too.', + 'The skeleton asked, "Any last words, my friend?"', + '"Actually," said {name}... and that was not the end.', + ], + ]; + + static const _dramatic = [ + [ + 'Oh {name}, great {job} of tragic renown,', + 'The marigolds weep as the sun goes down.', + 'Yet fear not, dear soul, for the music will play,', + 'And your {job} legend will dance on the day.', + ], + [ + 'Beneath the moon, so pale and so wide,', + '{name} the {job} walks with Death side by side.', + 'They share pan de muerto, they laugh, they sing,', + 'And the underworld crowns them the {job} king.', + ], + [ + 'A candle for {name}, a flame in the night,', + 'The {job} who worked with all of their might.', + 'Now skulls made of sugar bring tears and delight,', + 'And {name} gets the last word, as is only right.', + ], + ]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_data/pubspec.yaml b/extras/custom-server/features/calaverita/calaverita_data/pubspec.yaml new file mode 100644 index 0000000..9ac6151 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/pubspec.yaml @@ -0,0 +1,15 @@ +name: calaverita_data +description: Offline, template-based writer of calaveritas. +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + +dependencies: + calaverita_domain: any + +dev_dependencies: + test: ^1.26.0 + very_good_analysis: ^11.0.0 diff --git a/extras/custom-server/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart b/extras/custom-server/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart new file mode 100644 index 0000000..31c75c9 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_data/test/repositories/template_calaveritas_repository_test.dart @@ -0,0 +1,45 @@ +import 'package:calaverita_data/calaverita_data.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:test/test.dart'; + +void main() { + group(TemplateCalaveritasRepository, () { + const repository = TemplateCalaveritasRepository(delay: Duration.zero); + + test('fills the name and the job into every line', () async { + final calaverita = await repository.generate( + const CalaveritaDraft(name: 'Ana', job: 'Designer'), + ); + expect(calaverita.lines, hasLength(4)); + final text = calaverita.lines.join('\n'); + expect(text, contains('Ana')); + expect(text, contains('designer')); + expect(text, isNot(contains('{'))); + }); + + test('writes the same verse for the same draft', () async { + const draft = CalaveritaDraft(name: 'Ana', job: 'Chef'); + expect( + await repository.generate(draft), + await repository.generate(draft), + ); + }); + + test('writes a verse for every tone', () async { + for (final tone in Tone.values) { + final calaverita = await repository.generate( + CalaveritaDraft(name: 'Ana', job: 'Chef', tone: tone), + ); + expect(calaverita.tone, tone); + expect(calaverita.lines, hasLength(4)); + } + }); + + test('rejects an incomplete draft', () { + expect( + () => repository.generate(const CalaveritaDraft(name: 'Ana')), + throwsArgumentError, + ); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/analysis_options.yaml b/extras/custom-server/features/calaverita/calaverita_domain/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/calaverita_domain.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/calaverita_domain.dart new file mode 100644 index 0000000..1419bff --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/calaverita_domain.dart @@ -0,0 +1,6 @@ +export 'models/calaverita.dart'; +export 'models/calaverita_draft.dart'; +export 'models/skull_part.dart'; +export 'models/skull_style.dart'; +export 'models/tone.dart'; +export 'repositories/i_calaveritas_repository.dart'; diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita.dart new file mode 100644 index 0000000..3fdf3df --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita.dart @@ -0,0 +1,50 @@ +import 'package:calaverita_domain/models/skull_style.dart'; +import 'package:calaverita_domain/models/tone.dart'; +import 'package:equatable/equatable.dart'; + +/// A finished rhyming "literary skull". +class Calaverita extends Equatable { + const Calaverita({ + required this.name, + required this.job, + required this.tone, + required this.lines, + this.skull = const SkullStyle(), + }); + + /// Reads a calaverita from JSON, ignoring unknown or invalid fields. + factory Calaverita.fromJson(Map json) { + final lines = json['lines']; + final skull = json['skull']; + return Calaverita( + name: json['name'] is String ? json['name']! as String : '', + job: json['job'] is String ? json['job']! as String : '', + tone: Tone.values.asNameMap()[json['tone']] ?? Tone.playful, + lines: lines is List ? lines.whereType().toList() : [], + skull: skull is Map + ? SkullStyle.fromJson(skull) + : const SkullStyle(), + ); + } + + final String name; + final String job; + final Tone tone; + + /// The portrait that goes with the verse. + final SkullStyle skull; + + /// The verse, one entry per line. + final List lines; + + Map toJson() => { + 'name': name, + 'job': job, + 'tone': tone.name, + 'lines': lines, + 'skull': skull.toJson(), + }; + + @override + List get props => [name, job, tone, lines, skull]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart new file mode 100644 index 0000000..49c74f7 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/calaverita_draft.dart @@ -0,0 +1,60 @@ +import 'package:calaverita_domain/models/skull_style.dart'; +import 'package:calaverita_domain/models/tone.dart'; +import 'package:equatable/equatable.dart'; + +/// What we know about the person a calaverita is written for. +/// +/// This is the shape of the form, and later the shape the agent shares with +/// the app, so it knows how to read and write itself as JSON. +class CalaveritaDraft extends Equatable { + const CalaveritaDraft({ + this.name = '', + this.job = '', + this.tone = Tone.playful, + this.skull = const SkullStyle(), + }); + + /// Reads a draft from JSON, ignoring unknown or invalid fields. + factory CalaveritaDraft.fromJson(Map json) { + final tone = json['tone']; + final skull = json['skull']; + return CalaveritaDraft( + name: json['name'] is String ? json['name']! as String : '', + job: json['job'] is String ? json['job']! as String : '', + tone: Tone.values.asNameMap()[tone] ?? Tone.playful, + skull: skull is Map + ? SkullStyle.fromJson(skull) + : const SkullStyle(), + ); + } + + final String name; + final String job; + final Tone tone; + final SkullStyle skull; + + /// Whether there is enough to write a calaverita. + bool get isComplete => name.trim().isNotEmpty && job.trim().isNotEmpty; + + CalaveritaDraft copyWith({ + String? name, + String? job, + Tone? tone, + SkullStyle? skull, + }) => CalaveritaDraft( + name: name ?? this.name, + job: job ?? this.job, + tone: tone ?? this.tone, + skull: skull ?? this.skull, + ); + + Map toJson() => { + 'name': name, + 'job': job, + 'tone': tone.name, + 'skull': skull.toJson(), + }; + + @override + List get props => [name, job, tone, skull]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_part.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_part.dart new file mode 100644 index 0000000..26d6783 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_part.dart @@ -0,0 +1,18 @@ +/// An accessory the skull can wear, and the looks it comes in. +/// +/// Keep the variants in step with the SVGs of `calaverita_presentation` and +/// with the prompt of the runtime. +enum SkullPart { + hat('Hat', ['sombrero', 'tophat', 'party', 'crown']), + glasses('Glasses', ['round', 'square', 'cateye', 'heart']), + mustache('Mustache', ['handlebar', 'walrus', 'pencil']), + flowers('Flowers', ['crown', 'side', 'trio']); + + SkullPart(this.label, this.variants); + + /// The name shown to people. + final String label; + + /// The looks of this accessory, besides `none`. + final List variants; +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_style.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_style.dart new file mode 100644 index 0000000..a1cde9c --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/skull_style.dart @@ -0,0 +1,84 @@ +import 'package:calaverita_domain/models/skull_part.dart'; +import 'package:equatable/equatable.dart'; + +/// How the sugar skull looks: its color and what it wears. +/// +/// Each accessory is the id of one of its [SkullPart.variants], or +/// [SkullStyle.none]. +class SkullStyle extends Equatable { + const SkullStyle({ + this.color = defaultColor, + this.hat = none, + this.glasses = none, + this.mustache = none, + this.flowers = none, + }); + + /// Reads a style from JSON, ignoring unknown or invalid fields. + factory SkullStyle.fromJson(Map json) { + String variant(SkullPart part) { + final value = json[part.name]; + return value is String && part.variants.contains(value) ? value : none; + } + + final color = json['color']; + return SkullStyle( + color: color is String && _hex.hasMatch(color) + ? color.toLowerCase() + : defaultColor, + hat: variant(SkullPart.hat), + glasses: variant(SkullPart.glasses), + mustache: variant(SkullPart.mustache), + flowers: variant(SkullPart.flowers), + ); + } + + /// The id of an accessory the skull doesn't wear. + static const none = 'none'; + + /// Bone, the color of a plain skull. + static const defaultColor = '#f6efe0'; + + static final _hex = RegExp(r'^#[0-9a-fA-F]{6}$'); + + /// The skull color, as `#rrggbb`. + final String color; + final String hat; + final String glasses; + final String mustache; + final String flowers; + + /// The variant of [part] the skull wears, or [none]. + String of(SkullPart part) => switch (part) { + SkullPart.hat => hat, + SkullPart.glasses => glasses, + SkullPart.mustache => mustache, + SkullPart.flowers => flowers, + }; + + /// Returns this style with [part] set to [variant], or [none] to take it + /// off. + SkullStyle wearing(SkullPart part, String variant) => SkullStyle( + color: color, + hat: part == SkullPart.hat ? variant : hat, + glasses: part == SkullPart.glasses ? variant : glasses, + mustache: part == SkullPart.mustache ? variant : mustache, + flowers: part == SkullPart.flowers ? variant : flowers, + ); + + SkullStyle withColor(String color) => SkullStyle( + color: color, + hat: hat, + glasses: glasses, + mustache: mustache, + flowers: flowers, + ); + + Map toJson() => { + 'color': color, + for (final part in SkullPart.values) part.name: of(part), + }; + + @override + List get props => [color, hat, glasses, mustache, flowers]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/models/tone.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/tone.dart new file mode 100644 index 0000000..c779bf9 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/models/tone.dart @@ -0,0 +1,11 @@ +/// How a calaverita talks about its subject. +enum Tone { + playful('Playful'), + sarcastic('Sarcastic'), + dramatic('Dramatic'); + + Tone(this.label); + + /// The name shown to people. + final String label; +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart b/extras/custom-server/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart new file mode 100644 index 0000000..d32ac1b --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/lib/repositories/i_calaveritas_repository.dart @@ -0,0 +1,10 @@ +import 'package:calaverita_domain/models/calaverita.dart'; +import 'package:calaverita_domain/models/calaverita_draft.dart'; + +/// Writes calaveritas. +abstract interface class ICalaveritasRepository { + /// Writes a calaverita for [draft]. + /// + /// Throws an [ArgumentError] when [CalaveritaDraft.isComplete] is false. + Future generate(CalaveritaDraft draft); +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/pubspec.yaml b/extras/custom-server/features/calaverita/calaverita_domain/pubspec.yaml new file mode 100644 index 0000000..80f43e3 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/pubspec.yaml @@ -0,0 +1,15 @@ +name: calaverita_domain +description: Models and repository contract of the calaverita feature. +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + +dependencies: + equatable: ^3.0.0 + +dev_dependencies: + test: ^1.26.0 + very_good_analysis: ^11.0.0 diff --git a/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart b/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart new file mode 100644 index 0000000..0baed67 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_draft_test.dart @@ -0,0 +1,50 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:test/test.dart'; + +void main() { + group(CalaveritaDraft, () { + test('is incomplete until it has a name and a job', () { + expect(const CalaveritaDraft().isComplete, isFalse); + expect(const CalaveritaDraft(name: 'Ana').isComplete, isFalse); + expect(const CalaveritaDraft(name: 'Ana', job: ' ').isComplete, isFalse); + expect( + const CalaveritaDraft(name: 'Ana', job: 'Designer').isComplete, + isTrue, + ); + }); + + test('round-trips through JSON', () { + const draft = CalaveritaDraft( + name: 'Ana', + job: 'Designer', + tone: Tone.sarcastic, + skull: SkullStyle(color: '#12a5a0', hat: 'sombrero', flowers: 'trio'), + ); + expect(CalaveritaDraft.fromJson(draft.toJson()), draft); + }); + + test('ignores invalid JSON fields', () { + final draft = CalaveritaDraft.fromJson(const { + 'name': 42, + 'job': 'Chef', + 'tone': 'grumpy', + 'skull': {'hat': 'helmet', 'color': 'red', 'glasses': 'heart'}, + }); + expect( + draft, + const CalaveritaDraft( + job: 'Chef', + skull: SkullStyle(glasses: 'heart'), + ), + ); + }); + + test('copyWith keeps what it is not given', () { + const draft = CalaveritaDraft(name: 'Ana', job: 'Chef'); + expect( + draft.copyWith(tone: Tone.dramatic), + const CalaveritaDraft(name: 'Ana', job: 'Chef', tone: Tone.dramatic), + ); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_test.dart b/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_test.dart new file mode 100644 index 0000000..98dc472 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/test/models/calaverita_test.dart @@ -0,0 +1,36 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:test/test.dart'; + +void main() { + group(Calaverita, () { + test('round-trips through JSON', () { + const calaverita = Calaverita( + name: 'Ana', + job: 'pilot', + tone: Tone.sarcastic, + lines: ['one', 'two', 'three', 'four'], + skull: SkullStyle(hat: 'crown', color: '#12a5a0'), + ); + expect(Calaverita.fromJson(calaverita.toJson()), calaverita); + }); + + test('ignores invalid JSON fields', () { + final calaverita = Calaverita.fromJson(const { + 'name': 7, + 'job': 'chef', + 'tone': 'grumpy', + 'lines': ['one', 2, 'three'], + 'skull': 'round', + }); + expect( + calaverita, + const Calaverita( + name: '', + job: 'chef', + tone: Tone.playful, + lines: ['one', 'three'], + ), + ); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_domain/test/models/skull_style_test.dart b/extras/custom-server/features/calaverita/calaverita_domain/test/models/skull_style_test.dart new file mode 100644 index 0000000..7457cbe --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_domain/test/models/skull_style_test.dart @@ -0,0 +1,45 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:test/test.dart'; + +void main() { + group(SkullStyle, () { + test('starts plain', () { + const style = SkullStyle(); + for (final part in SkullPart.values) { + expect(style.of(part), SkullStyle.none); + } + expect(style.color, SkullStyle.defaultColor); + }); + + test('wears one accessory at a time', () { + final style = const SkullStyle().wearing(SkullPart.hat, 'sombrero'); + expect(style.hat, 'sombrero'); + expect(style.glasses, SkullStyle.none); + expect(style.wearing(SkullPart.hat, SkullStyle.none), const SkullStyle()); + }); + + test('keeps the accessories when the color changes', () { + final style = const SkullStyle(hat: 'party').withColor('#12a5a0'); + expect(style.hat, 'party'); + expect(style.color, '#12a5a0'); + }); + + test('round-trips through JSON', () { + const style = SkullStyle(color: '#d6336c', glasses: 'cateye'); + expect(SkullStyle.fromJson(style.toJson()), style); + }); + + test('ignores unknown variants and invalid colors', () { + final style = SkullStyle.fromJson(const { + 'color': 'pink', + 'hat': 'helmet', + 'mustache': 'walrus', + }); + expect(style, const SkullStyle(mustache: 'walrus')); + }); + + test('keeps a valid color in lower case', () { + expect(SkullStyle.fromJson(const {'color': '#F5A524'}).color, '#f5a524'); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/analysis_options.yaml b/extras/custom-server/features/calaverita/calaverita_presentation/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/base/round.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/base/round.svg new file mode 100644 index 0000000..2045630 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/base/round.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg new file mode 100644 index 0000000..1f4f401 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/eyes/marigold.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg new file mode 100644 index 0000000..945318c --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg new file mode 100644 index 0000000..4feefe9 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/side.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg new file mode 100644 index 0000000..6110b8d --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/flowers/trio.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg new file mode 100644 index 0000000..8962d64 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/cateye.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg new file mode 100644 index 0000000..94e0582 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg new file mode 100644 index 0000000..7b90822 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/round.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg new file mode 100644 index 0000000..5156ff7 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/glasses/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg new file mode 100644 index 0000000..7f56aee --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg new file mode 100644 index 0000000..40036e6 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/party.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg new file mode 100644 index 0000000..b871b3f --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/sombrero.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg new file mode 100644 index 0000000..9c9ca93 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/hat/tophat.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg new file mode 100644 index 0000000..e75174e --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mouth/stitched.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg new file mode 100644 index 0000000..5554aed --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/handlebar.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg new file mode 100644 index 0000000..eae7e7f --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg new file mode 100644 index 0000000..14f365a --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/mustache/walrus.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg new file mode 100644 index 0000000..42b0412 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/nose/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg new file mode 100644 index 0000000..db33ebe --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/assets/skull/pattern/swirls.svg @@ -0,0 +1,3 @@ + + + diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart new file mode 100644 index 0000000..9b2deff --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_cubit.dart @@ -0,0 +1,40 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Writes the calaverita for the form. +/// +/// The form is the agent's shared state, so the caller hands the current +/// draft in. +class CalaveritaCubit extends Cubit { + CalaveritaCubit({required ICalaveritasRepository calaveritasRepository}) + : _calaveritasRepository = calaveritasRepository, + super(const CalaveritaState()); + + final ICalaveritasRepository _calaveritasRepository; + + /// Shows [calaverita], one the agent wrote, on the stage. + void calaveritaShown(Calaverita calaverita) => emit( + state.copyWith(status: GenerationStatus.success, calaverita: calaverita), + ); + + /// Writes a calaverita for [draft], if it is complete. + Future generate(CalaveritaDraft draft) async { + if (!draft.isComplete || state.status == GenerationStatus.generating) { + return; + } + emit(state.copyWith(status: GenerationStatus.generating)); + try { + final calaverita = await _calaveritasRepository.generate(draft); + emit( + state.copyWith( + status: GenerationStatus.success, + calaverita: calaverita, + ), + ); + } on Object catch (error, stackTrace) { + addError(error, stackTrace); + emit(state.copyWith(status: GenerationStatus.failure)); + } + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart new file mode 100644 index 0000000..5ba20aa --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/bloc/calaverita_state.dart @@ -0,0 +1,28 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:equatable/equatable.dart'; + +/// Where writing a calaverita stands. +enum GenerationStatus { idle, generating, success, failure } + +/// The calaverita being written, or the last one written. +/// +/// The form itself is not here: it lives in the agent's shared state. +class CalaveritaState extends Equatable { + const CalaveritaState({this.status = GenerationStatus.idle, this.calaverita}); + + final GenerationStatus status; + + /// The last calaverita written, if any. + final Calaverita? calaverita; + + CalaveritaState copyWith({ + GenerationStatus? status, + Calaverita? calaverita, + }) => CalaveritaState( + status: status ?? this.status, + calaverita: calaverita ?? this.calaverita, + ); + + @override + List get props => [status, calaverita]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart new file mode 100644 index 0000000..ae8b53a --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/calaverita_module.dart @@ -0,0 +1,44 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_cubit.dart'; +import 'package:calaverita_presentation/calaverita/skull/skull_art.dart'; +import 'package:calaverita_presentation/calaverita/views/calaverita_page.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +/// The calaverita screen with everything it needs. +/// +/// It must sit below a `CopilotAgentScope`: the form is the agent's shared +/// state. +class CalaveritaModule extends StatelessWidget { + const CalaveritaModule({ + required this.calaveritasRepository, + required this.skullArt, + this.trailingPanel, + super.key, + }); + + final ICalaveritasRepository calaveritasRepository; + + /// The SVGs of the skull, loaded with [SkullArt.load]. + final SkullArt skullArt; + + /// Filled by the app: a panel beside the stage (below it on a narrow + /// screen). + final Widget? trailingPanel; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider.value(value: calaveritasRepository), + Provider.value(value: skullArt), + ], + child: BlocProvider( + create: (context) => + CalaveritaCubit(calaveritasRepository: context.read()), + child: CalaveritaPage(trailingPanel: trailingPanel), + ), + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart new file mode 100644 index 0000000..dac72b0 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/draft_edits.dart @@ -0,0 +1,54 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/shared_draft.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/widgets.dart'; + +/// What the person does to the form. Each edit patches the shared state, so +/// the next run of the agent receives it and the agent always sees what the +/// person changed. +class DraftEdits { + const DraftEdits._(this._agent); + + /// The edits of the agent of the nearest `CopilotAgentScope`. + factory DraftEdits.of(BuildContext context) => + DraftEdits._(CopilotAgentScope.of(context)); + + final CopilotAgent _agent; + + CalaveritaDraft get _draft => SharedDraft.draftOf(_agent.state); + + /// Shares the empty form if nothing is shared yet. + /// + /// The agent only sees state that is not empty, so the form has to be + /// there before the first message. + void ensureInitialized() { + if (_agent.state.sharedState == null) reset(); + } + + /// Clears the form. + void reset() => + _agent.setSharedState(SharedState(const CalaveritaDraft().toJson())); + + void nameChanged(String name) => _set('/name', name); + + void jobChanged(String job) => _set('/job', job); + + void toneChanged(Tone tone) => _set('/tone', tone.name); + + void skullPartChanged(SkullPart part, String variant) => + _set('/skull/${part.name}', variant); + + void skullColorChanged(String color) => _set('/skull/color', color); + + void _set(String path, String value) { + final operations = [StatePatchAdd(path: path, value: value)]; + try { + _agent.patchSharedState(operations); + } on FormatException { + // The agent replaced the state with one that lacks this path. Start + // again from the form as we can read it, which has every path. + _agent.setSharedState(SharedState(_draft.toJson())); + _agent.patchSharedState(operations); + } + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart new file mode 100644 index 0000000..0c5aeac --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/shared_draft/shared_draft.dart @@ -0,0 +1,29 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/widgets.dart'; + +/// Rebuilds with the [CalaveritaDraft] in the agent's shared state, and only +/// when it changes. +/// +/// The form lives in the shared state: the agent changes it with state +/// updates and the app changes it with `DraftEdits`. Nothing else holds a +/// copy, so the details, the stage and the agent all see the same draft. +/// Needs a `CopilotAgentScope` above it. +class SharedDraft extends StatelessWidget { + const SharedDraft({required this.builder, super.key}); + + final Widget Function(BuildContext context, CalaveritaDraft draft) builder; + + @override + Widget build(BuildContext context) { + return AgentStateBuilder( + selector: draftOf, + builder: builder, + ); + } + + /// The draft in [state], or an empty one while the agent shares nothing. + static CalaveritaDraft draftOf(CopilotAgentState state) => + state.sharedState?.decode(CalaveritaDraft.fromJson) ?? + const CalaveritaDraft(); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart new file mode 100644 index 0000000..f9a8a9f --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_args.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; + +/// The arguments of the `showCalaverita` tool: the verse the agent wrote. +class ShowCalaveritaArgs extends Equatable { + const ShowCalaveritaArgs({this.lines = const []}); + + factory ShowCalaveritaArgs.fromJson(Map json) { + final lines = json['lines']; + return ShowCalaveritaArgs( + lines: lines is List ? lines.whereType().toList() : [], + ); + } + + /// The four lines of the verse. + final List lines; + + @override + List get props => [lines]; +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart new file mode 100644 index 0000000..8211377 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_card.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_args.dart'; +import 'package:calaverita_presentation/calaverita/views/calaverita_card.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// The generative UI of a `showCalaverita` call: a card in the chat. +/// +/// While the agent is still writing it shows a placeholder, and once the +/// call is done the finished [CalaveritaCard]. +class ShowCalaveritaCard extends StatelessWidget { + const ShowCalaveritaCard({required this.call, this.args, super.key}); + + /// The call to show. + final ToolCallView call; + + /// The arguments of [call], or `null` while they can't be decoded yet. + final ShowCalaveritaArgs? args; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return switch (call.status) { + ToolCallStatus.inProgress || ToolCallStatus.executing => AppChip( + label: 'Writing the calaverita…', + icon: AppIcons.sparkle, + foreground: p.onNavyMuted, + background: p.onNavyFill, + ), + ToolCallStatus.complete => _finished(context, call.result), + }; + } + + Widget _finished(BuildContext context, String? result) { + final calaverita = _calaveritaFrom(result); + if (calaverita != null) return CalaveritaCard(calaverita: calaverita); + return Text( + result ?? '', + style: AppTypography.small.copyWith(color: context.palette.onNavyMuted), + ); + } + + /// Decodes the JSON-encoded calaverita in [result], or returns `null` when + /// it isn't one, e.g. a message saying the form is incomplete. + Calaverita? _calaveritaFrom(String? result) { + if (result == null) return null; + try { + return switch (jsonDecode(result)) { + final Map json => Calaverita.fromJson(json), + _ => null, + }; + } on FormatException { + return null; + } + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart new file mode 100644 index 0000000..e034f24 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_handler.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_args.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; + +/// Runs the `showCalaverita` tool: puts the verse the agent wrote together +/// with the form and the skull as they are right now, and hands the result to +/// the stage. +class ShowCalaveritaHandler { + const ShowCalaveritaHandler({ + required CalaveritaDraft Function() readDraft, + required void Function(Calaverita calaverita) onWritten, + }) : _readDraft = readDraft, + _onWritten = onWritten; + + final CalaveritaDraft Function() _readDraft; + final void Function(Calaverita calaverita) _onWritten; + + /// Returns the calaverita as JSON, or a sentence saying what is missing. + /// + /// The card of the tool shows the result, and the agent gets it back too. + Future handle( + ShowCalaveritaArgs args, { + required CancellationSignal signal, + }) async { + final draft = _readDraft(); + if (!draft.isComplete) { + return 'The form still needs a name and a job, so nothing was shown.'; + } + if (args.lines.isEmpty) return 'There was no verse to show.'; + final calaverita = Calaverita( + name: draft.name.trim(), + job: draft.job.trim().toLowerCase(), + tone: draft.tone, + lines: args.lines, + skull: draft.skull, + ); + _onWritten(calaverita); + return jsonEncode(calaverita.toJson()); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart new file mode 100644 index 0000000..5c0f3b1 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/show_calaverita/show_calaverita_tool.dart @@ -0,0 +1,67 @@ +import 'package:ack/ack.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_cubit.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/shared_draft.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_args.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_card.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_handler.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Gives the agent a `showCalaverita` tool and draws its calls as a +/// [ShowCalaveritaCard] in [child], e.g. the chat. +/// +/// The agent writes the verse and calls the tool, the tool adds the form and +/// the skull as they are right now, and the card in the chat shows the +/// result. The stage shows it too, so the verse the agent wrote is the one on +/// the main screen. Needs a `CopilotAgentScope` and the calaverita screen's +/// cubit above it. +class ShowCalaveritaTool extends StatefulWidget { + const ShowCalaveritaTool({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _ShowCalaveritaToolState(); +} + +class _ShowCalaveritaToolState extends State { + /// Kept in a field: the SDK compares tools to know whether to register + /// again. + FrontendTool? _tool; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_tool != null) return; + final agent = CopilotAgentScope.of(context); + final cubit = context.read(); + _tool = FrontendTool( + name: 'showCalaverita', + description: + 'Show a finished calaverita to the user as a card in the chat, with ' + 'the skull portrait that is on screen. Call it once the name and ' + 'the job are filled in and the user asks for the calaverita.', + // Keep in sync with ShowCalaveritaArgs. + parameters: Ack.object({ + 'lines': Ack.list(Ack.string()) + .describe('The four rhyming lines of the calaverita, in order'), + }), + decode: ShowCalaveritaArgs.fromJson, + handler: ShowCalaveritaHandler( + readDraft: () => SharedDraft.draftOf(agent.state), + onWritten: cubit.calaveritaShown, + ).handle, + ); + } + + @override + Widget build(BuildContext context) { + return CopilotTool( + tool: _tool!, + builder: (context, call, args) => + ShowCalaveritaCard(call: call, args: args), + child: widget.child, + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart new file mode 100644 index 0000000..2e95caf --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/skull/skull_art.dart @@ -0,0 +1,170 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:flutter/services.dart'; + +/// One piece of the skull: which SVG it draws and in which colors. +/// +/// Each SVG is a template: `{{fill}}`, `{{stroke}}`, `{{strokeWidth}}` and +/// `{{accent}}` are replaced with the colors below. +class SkullLayer { + const SkullLayer( + this.kind, { + required this.fill, + this.stroke = '#191817', + this.strokeWidth = 3, + this.accent = '#191817', + }); + + /// The folder of the SVGs, e.g. `hat`. + final String kind; + final String fill; + final String stroke; + final double strokeWidth; + final String accent; + + /// Returns this layer with [fill] as its main color. + SkullLayer withFill(String fill) => SkullLayer( + kind, + fill: fill, + stroke: stroke, + strokeWidth: strokeWidth, + accent: accent, + ); +} + +/// The SVGs of the skull, loaded once at startup so a piece draws the moment +/// its look changes. +class SkullArt { + /// Creates the art from SVG `templates`, keyed by `kind/variant`. + const SkullArt.fromTemplates(this._templates); + + /// Loads every SVG the skull can use. + static Future load([AssetBundle? bundle]) async { + final assets = bundle ?? rootBundle; + final templates = {}; + for (final path in _paths) { + templates[path] = await assets.loadString( + 'packages/calaverita_presentation/assets/skull/$path.svg', + ); + } + return SkullArt.fromTemplates(templates); + } + + /// The pieces of the skull that never change. + static const base = SkullLayer( + 'base', + fill: '#f6efe0', + strokeWidth: 5, + accent: '#ff8fb1', + ); + static const pattern = SkullLayer( + 'pattern', + fill: '#d6336c', + strokeWidth: 0, + accent: '#f5a524', + ); + static const eyes = SkullLayer('eyes', fill: '#191817', accent: '#f5a524'); + static const nose = SkullLayer('nose', fill: '#191817', strokeWidth: 2); + static const mouth = SkullLayer( + 'mouth', + fill: '#191817', + strokeWidth: 4, + accent: '#f6efe0', + ); + + /// How each accessory is colored. + static const hat = SkullLayer( + 'hat', + fill: '#e0353b', + strokeWidth: 4, + accent: '#f5a524', + ); + static const glasses = SkullLayer( + 'glasses', + fill: '#8fa8ff', + strokeWidth: 5, + accent: '#ffffff', + ); + static const mustache = SkullLayer( + 'mustache', + fill: '#2b1d3a', + strokeWidth: 2, + ); + static const flowers = SkullLayer( + 'flowers', + fill: '#f5a524', + strokeWidth: 2, + accent: '#d6336c', + ); + + /// How [part] is colored. + static SkullLayer layerOf(SkullPart part) => switch (part) { + SkullPart.hat => hat, + SkullPart.glasses => glasses, + SkullPart.mustache => mustache, + SkullPart.flowers => flowers, + }; + + /// The rectangle of the 400 x 500 canvas where [part] sits. + static Rect focusOf(SkullPart part) => switch (part) { + SkullPart.hat => const Rect.fromLTWH(10, 0, 380, 110), + SkullPart.glasses => const Rect.fromLTWH(60, 160, 280, 110), + SkullPart.mustache => const Rect.fromLTWH(115, 295, 170, 50), + SkullPart.flowers => const Rect.fromLTWH(20, 40, 360, 130), + }; + + /// The variant of each fixed piece. + static const _fixed = { + 'base': 'round', + 'pattern': 'swirls', + 'eyes': 'marigold', + 'nose': 'heart', + 'mouth': 'stitched', + }; + + static const _paths = [ + 'base/round', + 'pattern/swirls', + 'eyes/marigold', + 'nose/heart', + 'mouth/stitched', + 'hat/sombrero', + 'hat/tophat', + 'hat/party', + 'hat/crown', + 'glasses/round', + 'glasses/square', + 'glasses/cateye', + 'glasses/heart', + 'mustache/handlebar', + 'mustache/walrus', + 'mustache/pencil', + 'flowers/crown', + 'flowers/side', + 'flowers/trio', + ]; + + final Map _templates; + + /// The SVG of [layer] in [variant], or `null` when there is none. + /// + /// A fixed piece ignores [variant]. + String? svg(SkullLayer layer, [String? variant]) { + final template = + _templates['${layer.kind}/${_fixed[layer.kind] ?? variant}']; + if (template == null) return null; + return template + .replaceAll('{{fill}}', layer.fill) + .replaceAll('{{stroke}}', layer.stroke) + .replaceAll('{{strokeWidth}}', layer.strokeWidth.toStringAsFixed(1)) + .replaceAll('{{accent}}', layer.accent); + } + + /// Like [svg], cropped to [crop], a rectangle of the 400 x 500 canvas. + String? thumbnail(SkullLayer layer, String variant, Rect crop) { + final out = svg(layer, variant); + return out?.replaceFirst( + RegExp('viewBox="[^"]*" width="[^"]*" height="[^"]*"'), + 'viewBox="${crop.left} ${crop.top} ${crop.width} ${crop.height}"', + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart new file mode 100644 index 0000000..7e67811 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_card.dart @@ -0,0 +1,64 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/views/skull_view.dart'; + +/// A finished calaverita: the verse, and the skull portrait that goes with +/// it. +class CalaveritaCard extends StatelessWidget { + const CalaveritaCard({ + required this.calaverita, + this.showPortrait = true, + super.key, + }); + + final Calaverita calaverita; + + /// Whether the skull is drawn next to the verse. The stage turns it off + /// because the skull is already there. + final bool showPortrait; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return AppCard( + // The wash is see-through, so on its own it takes the color of what is + // behind it: bluish on the stage, navy in the chat. Lay it on the + // surface to get the same card anywhere. + color: Color.alphaBlend(p.accentWash, p.surface), + padding: const EdgeInsets.all(AppSpacing.x4l), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.x4l, + children: [ + if (showPortrait) + SizedBox(width: 96, child: SkullView(style: calaverita.skull)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Calaverita for ${calaverita.name}', + style: AppTypography.title.copyWith(color: p.heading), + ), + const SizedBox(height: AppSpacing.xs), + Text( + '${calaverita.job} · ${calaverita.tone.label}', + style: AppTypography.small.copyWith(color: p.inkTertiary), + ), + const Divider(height: AppSpacing.x5l * 2), + for (final line in calaverita.lines) + Text( + line, + style: AppTypography.body.copyWith( + color: p.ink, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart similarity index 100% rename from 03-custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart rename to extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/calaverita_page.dart diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart new file mode 100644 index 0000000..1c572f7 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/details_panel.dart @@ -0,0 +1,97 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/views/skull_controls.dart'; +import 'package:calaverita_presentation/calaverita/views/synced_text_field.dart'; + +/// The form: who the calaverita is for, how it sounds and how the skull +/// looks, and the button that writes it. +/// +/// It draws the [draft] it is given and reports what the person does through +/// callbacks, so it doesn't care where the draft lives. +class DetailsPanel extends StatelessWidget { + const DetailsPanel({ + required this.draft, + required this.canGenerate, + required this.isGenerating, + required this.onNameChanged, + required this.onJobChanged, + required this.onToneChanged, + required this.onSkullPartChanged, + required this.onSkullColorChanged, + required this.onGenerate, + super.key, + }); + + final CalaveritaDraft draft; + final bool canGenerate; + final bool isGenerating; + final ValueChanged onNameChanged; + final ValueChanged onJobChanged; + final ValueChanged onToneChanged; + final void Function(SkullPart part, String variant) onSkullPartChanged; + final ValueChanged onSkullColorChanged; + final VoidCallback onGenerate; + + @override + Widget build(BuildContext context) { + return AppPanel( + title: 'Details', + icon: AppIcons.package, + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.x3l, + 0, + AppSpacing.x3l, + AppSpacing.x3l, + ), + children: [ + SyncedTextField( + key: const Key('calaverita_name_field'), + label: 'Name', + hint: 'Who is the calaverita for?', + value: draft.name, + onChanged: onNameChanged, + ), + const SizedBox(height: AppSpacing.xl), + SyncedTextField( + key: const Key('calaverita_job_field'), + label: 'Job', + hint: 'What do they do all day?', + value: draft.job, + onChanged: onJobChanged, + ), + const SizedBox(height: AppSpacing.xl), + AppSegmented( + key: const Key('calaverita_tone_selector'), + segments: {for (final tone in Tone.values) tone: tone.label}, + value: draft.tone, + onChanged: onToneChanged, + compact: true, + ), + const SizedBox(height: AppSpacing.x4l), + SkullControls( + style: draft.skull, + onPartChanged: onSkullPartChanged, + onColorChanged: onSkullColorChanged, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.x3l), + child: AppButton.primary( + key: const Key('calaverita_generate_button'), + label: isGenerating ? 'Writing…' : 'Write my calaverita', + icon: AppIcons.sparkle, + expanded: true, + onPressed: canGenerate ? onGenerate : null, + ), + ), + ], + ), + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart new file mode 100644 index 0000000..511e834 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_controls.dart @@ -0,0 +1,215 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/skull/skull_art.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; + +/// What the person can do to the skull: take an accessory off or put one on, +/// and paint it. +class SkullControls extends StatelessWidget { + const SkullControls({ + required this.style, + required this.onPartChanged, + required this.onColorChanged, + super.key, + }); + + final SkullStyle style; + final void Function(SkullPart part, String variant) onPartChanged; + final ValueChanged onColorChanged; + + /// Bone, white and the festive colors of the art. + static const swatches = { + '#f6efe0': 'Bone', + '#ffffff': 'White', + '#12a5a0': 'Teal', + '#1fa567': 'Green', + '#f5a524': 'Marigold', + '#ff8fb1': 'Pink', + '#8fa8ff': 'Sky', + '#7048e8': 'Violet', + }; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.xl, + children: [ + for (final part in SkullPart.values) + _PartPicker( + part: part, + selected: style.of(part), + onChanged: (variant) => onPartChanged(part, variant), + ), + _ColorPicker(value: style.color, onChanged: onColorChanged), + ], + ); + } +} + +class _Label extends StatelessWidget { + const _Label(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text, + style: AppTypography.smallMedium.copyWith( + color: context.palette.inkSecondary, + ), + ); +} + +class _PartPicker extends StatelessWidget { + const _PartPicker({ + required this.part, + required this.selected, + required this.onChanged, + }); + + final SkullPart part; + final String selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.md, + children: [ + _Label(part.label), + Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.md, + children: [ + for (final variant in [SkullStyle.none, ...part.variants]) + _Option( + part: part, + variant: variant, + selected: variant == selected, + onTap: () => onChanged(variant), + ), + ], + ), + ], + ); + } +} + +class _Option extends StatelessWidget { + const _Option({ + required this.part, + required this.variant, + required this.selected, + required this.onTap, + }); + + final SkullPart part; + final String variant; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final art = context.read(); + final svg = variant == SkullStyle.none + ? null + : art.thumbnail( + SkullArt.layerOf(part), + variant, + SkullArt.focusOf(part), + ); + return Semantics( + button: true, + selected: selected, + label: '${part.label} $variant', + child: AppPressable( + onTap: onTap, + pressedScale: 0.97, + child: AnimatedContainer( + duration: AppMotion.quick, + width: 62, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: selected ? p.accentWash : p.cardGround, + borderRadius: BorderRadius.circular(AppRadii.option), + border: Border.all( + color: selected ? p.accent : Colors.transparent, + width: 2, + ), + ), + child: Column( + spacing: AppSpacing.xs, + children: [ + SizedBox( + height: 44, + child: svg == null + ? Icon(Icons.block, color: p.inkTertiary) + : SvgPicture.string(svg), + ), + Text( + variant == SkullStyle.none ? 'None' : variant, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.micro.copyWith(color: p.inkSecondary), + ), + ], + ), + ), + ), + ); + } +} + +class _ColorPicker extends StatelessWidget { + const _ColorPicker({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.md, + children: [ + const _Label('Skull color'), + Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.md, + children: [ + for (final MapEntry(key: hex, value: name) + in SkullControls.swatches.entries) + Semantics( + button: true, + label: name, + selected: hex == value, + child: GestureDetector( + onTap: () => onChanged(hex), + child: AnimatedContainer( + duration: AppMotion.quick, + width: 28, + height: 28, + decoration: BoxDecoration( + color: Color( + int.parse('ff${hex.substring(1)}', radix: 16), + ), + shape: BoxShape.circle, + border: Border.all( + color: hex == value ? p.accent : p.hairline, + width: hex == value ? 3 : 1, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart new file mode 100644 index 0000000..689b482 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/skull_view.dart @@ -0,0 +1,52 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/skull/skull_art.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; + +/// Draws the skull: its fixed pieces and the accessories it wears, stacked +/// bottom first. +/// +/// It only reads [style], so it draws the same wherever it is: the stage, a +/// card in the chat. Needs a [SkullArt] above it. +class SkullView extends StatelessWidget { + const SkullView({required this.style, super.key}); + + final SkullStyle style; + + @override + Widget build(BuildContext context) { + final art = context.read(); + final pieces = <(String, String?)>[ + ('base', art.svg(SkullArt.base.withFill(style.color))), + ('pattern', art.svg(SkullArt.pattern)), + ('eyes', art.svg(SkullArt.eyes)), + ('nose', art.svg(SkullArt.nose)), + _accessory(art, SkullPart.mustache), + ('mouth', art.svg(SkullArt.mouth)), + _accessory(art, SkullPart.glasses), + _accessory(art, SkullPart.flowers), + _accessory(art, SkullPart.hat), + ]; + return AspectRatio( + aspectRatio: 400 / 500, + child: Stack( + fit: StackFit.expand, + children: [ + for (final (key, svg) in pieces) + if (svg != null) SvgPicture.string(svg, key: ValueKey(key)), + ], + ), + ); + } + + (String, String?) _accessory(SkullArt art, SkullPart part) { + final variant = style.of(part); + return ( + part.name, + variant == SkullStyle.none + ? null + : art.svg(SkullArt.layerOf(part), variant), + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart new file mode 100644 index 0000000..eeb440b --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/stage.dart @@ -0,0 +1,51 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/bloc/calaverita_state.dart'; +import 'package:calaverita_presentation/calaverita/views/calaverita_card.dart'; +import 'package:calaverita_presentation/calaverita/views/skull_view.dart'; + +/// The skull as it looks right now, and the calaverita once it is written. +class Stage extends StatelessWidget { + const Stage({ + required this.skull, + required this.status, + this.calaverita, + this.badge, + super.key, + }); + + final SkullStyle skull; + final GenerationStatus status; + + /// The last calaverita written, if any. + final Calaverita? calaverita; + + /// A small widget in the top left corner, e.g. what the agent is doing. + final Widget? badge; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final calaverita = this.calaverita; + return AppCard( + padding: const EdgeInsets.all(AppSpacing.x4l), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.x3l, + children: [ + Expanded( + child: Center(child: SkullView(style: skull)), + ), + if (status == GenerationStatus.failure) + Text( + 'The skull lost its voice. Try again.', + textAlign: TextAlign.center, + style: AppTypography.small.copyWith(color: p.negative), + ) + else if (calaverita != null) + CalaveritaCard(calaverita: calaverita, showPortrait: false), + ], + ), + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart new file mode 100644 index 0000000..1a12292 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita/views/synced_text_field.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +/// A text field that shows [value], even when it changes from outside. +/// +/// The text the person types and the [value] stay in step, so whatever sets +/// the state (the keyboard, later an agent) shows up in the field. A field +/// that is being typed in is left alone. +class SyncedTextField extends StatefulWidget { + const SyncedTextField({ + required this.label, + required this.hint, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String hint; + final String value; + final ValueChanged onChanged; + + @override + State createState() => _SyncedTextFieldState(); +} + +class _SyncedTextFieldState extends State { + late final _controller = TextEditingController(text: widget.value); + + @override + void didUpdateWidget(SyncedTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller.text != widget.value) _controller.text = widget.value; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + onChanged: widget.onChanged, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + ), + ); + } +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart new file mode 100644 index 0000000..a891146 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/lib/calaverita_presentation.dart @@ -0,0 +1,7 @@ +export 'calaverita/bloc/calaverita_cubit.dart'; +export 'calaverita/bloc/calaverita_state.dart'; +export 'calaverita/calaverita_module.dart'; +export 'calaverita/show_calaverita/show_calaverita_tool.dart'; +export 'calaverita/skull/skull_art.dart'; +export 'calaverita/views/calaverita_card.dart'; +export 'calaverita/views/skull_view.dart'; diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/pubspec.yaml b/extras/custom-server/features/calaverita/calaverita_presentation/pubspec.yaml new file mode 100644 index 0000000..e5ecdaf --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/pubspec.yaml @@ -0,0 +1,40 @@ +name: calaverita_presentation +description: The details, the stage and the skull portrait of the calaverita screen. +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + flutter: ^3.47.0 + +dependencies: + ack: ^1.7.0 + app_ui: any + calaverita_domain: any + copilotkit_flutter: any + equatable: ^3.0.0 + flutter: + sdk: flutter + flutter_bloc: ^9.1.1 + flutter_svg: ^2.2.4 + provider: ^6.1.5 + +dev_dependencies: + bloc_test: ^10.0.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.5 + very_good_analysis: ^11.0.0 + +flutter: + assets: + - assets/skull/base/ + - assets/skull/pattern/ + - assets/skull/eyes/ + - assets/skull/nose/ + - assets/skull/mouth/ + - assets/skull/mustache/ + - assets/skull/glasses/ + - assets/skull/flowers/ + - assets/skull/hat/ diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart new file mode 100644 index 0000000..3d3be48 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/bloc/calaverita_cubit_test.dart @@ -0,0 +1,76 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockRepository extends Mock implements ICalaveritasRepository; + +void main() { + const draft = CalaveritaDraft(name: 'Ana', job: 'Chef'); + const calaverita = Calaverita( + name: 'Ana', + job: 'chef', + tone: Tone.playful, + lines: ['a', 'b', 'c', 'd'], + ); + + late _MockRepository repository; + + setUpAll(() => registerFallbackValue(draft)); + setUp(() => repository = _MockRepository()); + + CalaveritaCubit build() => CalaveritaCubit(calaveritasRepository: repository); + + group(CalaveritaCubit, () { + blocTest( + 'writes a calaverita for a complete draft', + setUp: () => + when(() => repository.generate(any())) + .thenAnswer((_) async => calaverita), + build: build, + act: (cubit) => cubit.generate(draft), + expect: () => const [ + CalaveritaState(status: GenerationStatus.generating), + CalaveritaState( + status: GenerationStatus.success, + calaverita: calaverita, + ), + ], + verify: (_) => verify(() => repository.generate(draft)).called(1), + ); + + blocTest( + 'shows a calaverita the agent wrote', + build: build, + act: (cubit) => cubit.calaveritaShown(calaverita), + expect: () => const [ + CalaveritaState( + status: GenerationStatus.success, + calaverita: calaverita, + ), + ], + ); + + blocTest( + 'does nothing while the draft is incomplete', + build: build, + act: (cubit) => cubit.generate(const CalaveritaDraft(name: 'Ana')), + expect: () => const [], + verify: (_) => verifyNever(() => repository.generate(any())), + ); + + blocTest( + 'fails when the repository throws', + setUp: () => + when(() => repository.generate(any())).thenThrow(StateError('no')), + build: build, + act: (cubit) => cubit.generate(draft), + errors: () => [isA()], + expect: () => const [ + CalaveritaState(status: GenerationStatus.generating), + CalaveritaState(status: GenerationStatus.failure), + ], + ); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart new file mode 100644 index 0000000..abc41d7 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/shared_draft/shared_draft_test.dart @@ -0,0 +1,145 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/draft_edits.dart'; +import 'package:calaverita_presentation/calaverita/shared_draft/shared_draft.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const agentId = 'default'; + late DraftEdits edits; + late CopilotAgent agent; + CalaveritaDraft? shown; + + Future settle(WidgetTester tester) async { + await tester.runAsync(pumpEventQueue); + await tester.pump(); + } + + Future pumpScope(WidgetTester tester) async { + shown = null; + await tester.pumpWidget( + CopilotKit( + // Never called: nothing in these tests runs the agent. + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: MaterialApp( + home: CopilotAgentScope( + agentId: agentId, + child: SharedDraft( + builder: (context, draft) { + shown = draft; + edits = DraftEdits.of(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + agent = CopilotKit.of(tester.element(find.byType(SizedBox))).agent(agentId); + } + + Map? shared() => agent.state.sharedState?.toJson(); + + group(DraftEdits, () { + testWidgets('shares the empty form once', (tester) async { + await pumpScope(tester); + expect(shared(), isNull); + + edits.ensureInitialized(); + expect(shared(), const CalaveritaDraft().toJson()); + + edits + ..nameChanged('Ana') + ..ensureInitialized(); + expect(shared()!['name'], 'Ana'); + }); + + testWidgets('patches one field at a time', (tester) async { + await pumpScope(tester); + edits + ..ensureInitialized() + ..nameChanged('Ana') + ..jobChanged('Pilot') + ..toneChanged(Tone.sarcastic) + ..skullPartChanged(SkullPart.hat, 'sombrero') + ..skullColorChanged('#12a5a0'); + + expect( + shared(), + const CalaveritaDraft( + name: 'Ana', + job: 'Pilot', + tone: Tone.sarcastic, + skull: SkullStyle(color: '#12a5a0', hat: 'sombrero'), + ).toJson(), + ); + }); + + testWidgets('recovers when the agent shares a state without the path', ( + tester, + ) async { + await pumpScope(tester); + agent.setSharedState(const SharedState({'name': 'Luz'})); + + edits.skullPartChanged(SkullPart.glasses, 'heart'); + + expect( + shared(), + const CalaveritaDraft( + name: 'Luz', + skull: SkullStyle(glasses: 'heart'), + ).toJson(), + ); + }); + + testWidgets('resets the form', (tester) async { + await pumpScope(tester); + edits + ..nameChanged('Ana') + ..reset(); + + expect(shared(), const CalaveritaDraft().toJson()); + }); + }); + + group(SharedDraft, () { + testWidgets('shows an empty draft while nothing is shared', (tester) async { + await pumpScope(tester); + + expect(shown, const CalaveritaDraft()); + }); + + testWidgets('shows what the agent shares', (tester) async { + await pumpScope(tester); + + agent.patchSharedState(const [ + StatePatchAdd(path: '/name', value: 'Ana'), + StatePatchAdd(path: '/skull', value: {'hat': 'crown'}), + ]); + await settle(tester); + + expect( + shown, + const CalaveritaDraft( + name: 'Ana', + skull: SkullStyle(hat: 'crown'), + ), + ); + }); + + testWidgets('shows a snapshot of the agent', (tester) async { + await pumpScope(tester); + + agent.setSharedState( + const SharedState({'name': 'Luz', 'job': 'Pilot', 'tone': 'dramatic'}), + ); + await settle(tester); + + expect( + shown, + const CalaveritaDraft(name: 'Luz', job: 'Pilot', tone: Tone.dramatic), + ); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart new file mode 100644 index 0000000..0cd6ba4 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_handler_test.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_args.dart'; +import 'package:calaverita_presentation/calaverita/show_calaverita/show_calaverita_handler.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const args = ShowCalaveritaArgs(lines: ['one', 'two', 'three', 'four']); + + final written = []; + setUp(written.clear); + + Future run(CalaveritaDraft draft, ShowCalaveritaArgs args) => + ShowCalaveritaHandler( + readDraft: () => draft, + onWritten: written.add, + ).handle(args, signal: CancellationSignal()); + + group(ShowCalaveritaHandler, () { + test('puts the verse together with the form and the skull', () async { + final result = await run( + const CalaveritaDraft( + name: ' Ana ', + job: 'Pilot', + tone: Tone.sarcastic, + skull: SkullStyle(hat: 'crown'), + ), + args, + ); + + expect( + Calaverita.fromJson(jsonDecode(result) as Map), + const Calaverita( + name: 'Ana', + job: 'pilot', + tone: Tone.sarcastic, + lines: ['one', 'two', 'three', 'four'], + skull: SkullStyle(hat: 'crown'), + ), + ); + }); + + test('hands the calaverita to the stage', () async { + await run(const CalaveritaDraft(name: 'Ana', job: 'Pilot'), args); + + expect(written, [ + const Calaverita( + name: 'Ana', + job: 'pilot', + tone: Tone.playful, + lines: ['one', 'two', 'three', 'four'], + ), + ]); + }); + + test('hands the calaverita to the stage', () async { + await run(const CalaveritaDraft(name: 'Ana', job: 'Pilot'), args); + + expect(written, [ + const Calaverita( + name: 'Ana', + job: 'pilot', + tone: Tone.playful, + lines: ['one', 'two', 'three', 'four'], + ), + ]); + }); + + test('says so when the form is incomplete', () async { + final result = await run(const CalaveritaDraft(name: 'Ana'), args); + + expect(result, contains('name and a job')); + expect(written, isEmpty); + }); + + test('says so when there is no verse', () async { + final result = await run( + const CalaveritaDraft(name: 'Ana', job: 'Pilot'), + const ShowCalaveritaArgs(), + ); + + expect(result, contains('no verse')); + expect(written, isEmpty); + }); + }); + + group(ShowCalaveritaArgs, () { + test('reads the lines and ignores what is not text', () { + final args = ShowCalaveritaArgs.fromJson(const { + 'lines': ['one', 2, 'three'], + }); + + expect(args.lines, ['one', 'three']); + expect(ShowCalaveritaArgs.fromJson(const {}).lines, isEmpty); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart new file mode 100644 index 0000000..b6f4b9d --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/show_calaverita/show_calaverita_tool_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; + +import '../../helpers/test_skull_art.dart'; + +class _MockRepository extends Mock implements ICalaveritasRepository; + +void main() { + const calaverita = Calaverita( + name: 'Ana', + job: 'pilot', + tone: Tone.sarcastic, + lines: ['one', 'two', 'three', 'four'], + ); + + /// Renders [call] the way the chat does: through a `ToolCallWidget` below + /// the tool. + Future pumpCall(WidgetTester tester, ToolCallView call) { + return tester.pumpWidget( + CopilotKit( + // Never called: nothing in these tests runs the agent. + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: MaterialApp( + home: Provider.value( + value: TestSkullArt.build(), + child: CopilotAgentScope( + agentId: 'default', + child: BlocProvider( + create: (_) => + CalaveritaCubit(calaveritasRepository: _MockRepository()), + child: Scaffold( + body: ShowCalaveritaTool(child: ToolCallWidget(call: call)), + ), + ), + ), + ), + ), + ), + ); + } + + ToolCallView callWith(ToolCallStatus status, {String? result}) => + ToolCallView( + id: 'call-1', + name: 'showCalaverita', + status: status, + result: result, + ); + + group(ShowCalaveritaTool, () { + testWidgets('shows a placeholder while the agent writes', (tester) async { + await pumpCall(tester, callWith(ToolCallStatus.inProgress)); + + expect(find.text('Writing the calaverita…'), findsOneWidget); + expect(find.byType(CalaveritaCard), findsNothing); + }); + + testWidgets('shows the card once the call is complete', (tester) async { + await pumpCall( + tester, + callWith( + ToolCallStatus.complete, + result: jsonEncode(calaverita.toJson()), + ), + ); + + expect(find.byType(CalaveritaCard), findsOneWidget); + expect(find.text('Calaverita for Ana'), findsOneWidget); + expect(find.text('three'), findsOneWidget); + }); + + testWidgets('shows the message when the result is not a calaverita', ( + tester, + ) async { + await pumpCall( + tester, + callWith( + ToolCallStatus.complete, + result: 'The form still needs a name and a job.', + ), + ); + + expect(find.byType(CalaveritaCard), findsNothing); + expect(find.textContaining('still needs a name'), findsOneWidget); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart new file mode 100644 index 0000000..6d34c81 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/skull/skull_art_test.dart @@ -0,0 +1,43 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/test_skull_art.dart'; + +void main() { + group(SkullArt, () { + final art = TestSkullArt.build(); + + test('fills the colors into the template', () { + final svg = art.svg(SkullArt.hat, 'sombrero')!; + expect(svg, contains('fill="#e0353b"')); + expect(svg, contains('stroke-width="4.0"')); + expect(svg, isNot(contains('{{'))); + }); + + test('draws a fixed piece whatever the variant', () { + expect(art.svg(SkullArt.base.withFill('#12a5a0')), contains('#12a5a0')); + }); + + test('has no SVG for a variant it was not given', () { + expect(art.svg(SkullArt.hat, 'crown'), isNull); + }); + + test('crops a thumbnail to where the part sits', () { + final svg = art.thumbnail( + SkullArt.glasses, + 'heart', + SkullArt.focusOf(SkullPart.glasses), + )!; + expect(svg, contains('viewBox="60.0 160.0 280.0 110.0"')); + }); + + test('knows where every accessory sits', () { + for (final part in SkullPart.values) { + expect(SkullArt.focusOf(part), isNot(Rect.zero)); + expect(SkullArt.layerOf(part).kind, part.name); + } + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart new file mode 100644 index 0000000..aa96fce --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_card_test.dart @@ -0,0 +1,72 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import '../../helpers/test_skull_art.dart'; + +void main() { + const calaverita = Calaverita( + name: 'Ana', + job: 'chef', + tone: Tone.sarcastic, + lines: ['one', 'two', 'three', 'four'], + ); + + Future pumpCard(WidgetTester tester, {required bool showPortrait}) { + return tester.pumpWidget( + Provider.value( + value: TestSkullArt.build(), + child: MaterialApp( + home: Scaffold( + body: CalaveritaCard( + calaverita: calaverita, + showPortrait: showPortrait, + ), + ), + ), + ), + ); + } + + group(CalaveritaCard, () { + testWidgets('shows the title, the subject and every line', (tester) async { + await pumpCard(tester, showPortrait: true); + + expect(find.text('Calaverita for Ana'), findsOneWidget); + expect(find.text('chef · Sarcastic'), findsOneWidget); + for (final line in calaverita.lines) { + expect(find.text(line), findsOneWidget); + } + }); + + testWidgets('has the same opaque background on any surface', ( + tester, + ) async { + await pumpCard(tester, showPortrait: false); + + final decoration = + tester + .widget( + find + .descendant( + of: find.byType(CalaveritaCard), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + expect(decoration.color!.a, 1); + }); + + testWidgets('shows the portrait unless asked not to', (tester) async { + await pumpCard(tester, showPortrait: true); + expect(find.byType(SkullView), findsOneWidget); + + await pumpCard(tester, showPortrait: false); + expect(find.byType(SkullView), findsNothing); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart new file mode 100644 index 0000000..f8aa2e0 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/calaverita_page_test.dart @@ -0,0 +1,131 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:copilotkit_flutter/copilotkit_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/test_skull_art.dart'; + +class _MockRepository extends Mock implements ICalaveritasRepository; + +void main() { + const agentId = 'default'; + late _MockRepository repository; + + setUpAll(() => registerFallbackValue(const CalaveritaDraft())); + setUp(() => repository = _MockRepository()); + + /// Lets the agent's stream deliver its last state, then rebuilds. + Future settle(WidgetTester tester) async { + await tester.runAsync(pumpEventQueue); + await tester.pump(); + } + + Future pumpPage(WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(1400, 1000); + addTearDown(tester.view.reset); + await tester.pumpWidget( + CopilotKit( + // Never called: nothing in these tests runs the agent. + runtimeUrl: Uri.parse('http://localhost:1/api/copilotkit'), + child: MaterialApp( + home: CopilotAgentScope( + agentId: agentId, + child: CalaveritaModule( + calaveritasRepository: repository, + skullArt: TestSkullArt.build(), + ), + ), + ), + ), + ); + await tester.pump(); + return CopilotKit.of(tester.element(find.byType(CalaveritaModule))) + .agent(agentId); + } + + String textOf(WidgetTester tester, String key) => tester + .widget( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(TextField), + ), + ) + .controller! + .text; + + group(CalaveritaModule, () { + testWidgets('shares the empty form with the agent', (tester) async { + final agent = await pumpPage(tester); + + expect( + agent.state.sharedState?.toJson(), + const CalaveritaDraft().toJson(), + ); + }); + + testWidgets('fills the details from what the agent shares', (tester) async { + final agent = await pumpPage(tester); + + agent.patchSharedState(const [ + StatePatchReplace(path: '/name', value: 'Ana'), + StatePatchReplace(path: '/job', value: 'Chef'), + StatePatchReplace(path: '/skull/hat', value: 'sombrero'), + ]); + await settle(tester); + + expect(textOf(tester, 'calaverita_name_field'), 'Ana'); + expect(textOf(tester, 'calaverita_job_field'), 'Chef'); + expect(find.byKey(const ValueKey('hat')), findsOneWidget); + }); + + testWidgets('writes what the person types into the shared state', ( + tester, + ) async { + final agent = await pumpPage(tester); + + await tester.enterText( + find.byKey(const Key('calaverita_name_field')), + 'Ana', + ); + await tester.tap(find.bySemanticsLabel('Glasses heart')); + await tester.pump(); + + expect(agent.state.sharedState!.toJson(), { + ...const CalaveritaDraft().toJson(), + 'name': 'Ana', + 'skull': const SkullStyle(glasses: 'heart').toJson(), + }); + }); + + testWidgets('writes the calaverita for the shared draft', (tester) async { + when(() => repository.generate(any())).thenAnswer( + (_) async => const Calaverita( + name: 'Ana', + job: 'chef', + tone: Tone.playful, + lines: ['one', 'two', 'three', 'four'], + ), + ); + final agent = await pumpPage(tester); + agent.patchSharedState(const [ + StatePatchReplace(path: '/name', value: 'Ana'), + StatePatchReplace(path: '/job', value: 'Chef'), + ]); + await settle(tester); + + await tester.tap(find.byKey(const Key('calaverita_generate_button'))); + await settle(tester); + + verify( + () => repository.generate( + const CalaveritaDraft(name: 'Ana', job: 'Chef'), + ), + ).called(1); + expect(find.text('Calaverita for Ana'), findsOneWidget); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart new file mode 100644 index 0000000..fe2e9e0 --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/calaverita/views/skull_view_test.dart @@ -0,0 +1,46 @@ +import 'package:calaverita_domain/calaverita_domain.dart'; +import 'package:calaverita_presentation/calaverita_presentation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import '../../helpers/test_skull_art.dart'; + +void main() { + Future pumpSkull(WidgetTester tester, SkullStyle style) { + return tester.pumpWidget( + Provider.value( + value: TestSkullArt.build(), + child: MaterialApp( + home: Scaffold(body: SkullView(style: style)), + ), + ), + ); + } + + group(SkullView, () { + testWidgets('draws the five fixed pieces on a plain skull', (tester) async { + await pumpSkull(tester, const SkullStyle()); + + expect(find.byType(SvgPicture), findsNWidgets(5)); + }); + + testWidgets('adds what the skull wears', (tester) async { + await pumpSkull( + tester, + const SkullStyle(hat: 'sombrero', glasses: 'heart'), + ); + + expect(find.byType(SvgPicture), findsNWidgets(7)); + expect(find.byKey(const ValueKey('hat')), findsOneWidget); + expect(find.byKey(const ValueKey('glasses')), findsOneWidget); + }); + + testWidgets('skips an accessory it has no art for', (tester) async { + await pumpSkull(tester, const SkullStyle(mustache: 'walrus')); + + expect(find.byType(SvgPicture), findsNWidgets(5)); + }); + }); +} diff --git a/extras/custom-server/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart b/extras/custom-server/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart new file mode 100644 index 0000000..57f337a --- /dev/null +++ b/extras/custom-server/features/calaverita/calaverita_presentation/test/helpers/test_skull_art.dart @@ -0,0 +1,27 @@ +import 'package:calaverita_presentation/calaverita_presentation.dart'; + +/// A few one-rectangle SVGs, enough to tell which pieces are drawn. +class TestSkullArt { + const TestSkullArt._(); + + static String _svg(String id) => [ + '', + '', + '', + ].join(); + + static SkullArt build() => SkullArt.fromTemplates({ + for (final path in [ + 'base/round', + 'pattern/swirls', + 'eyes/marigold', + 'nose/heart', + 'mouth/stitched', + 'hat/sombrero', + 'glasses/heart', + ]) + path: _svg(path.replaceAll('/', '-')), + }); +} diff --git a/03-custom-server/pubspec.lock b/extras/custom-server/pubspec.lock similarity index 100% rename from 03-custom-server/pubspec.lock rename to extras/custom-server/pubspec.lock diff --git a/extras/custom-server/pubspec.yaml b/extras/custom-server/pubspec.yaml new file mode 100644 index 0000000..37721f1 --- /dev/null +++ b/extras/custom-server/pubspec.yaml @@ -0,0 +1,33 @@ +name: calaverita_workspace +publish_to: none + +environment: + sdk: ^3.13.0 + +workspace: + - apps/calaverita_app + - features/assistant/assistant_presentation + - features/calaverita/calaverita_domain + - features/calaverita/calaverita_data + - features/calaverita/calaverita_presentation + - shared/app_ui + +# The CopilotKit Flutter SDK is not on pub.dev yet. Its packages depend on +# each other through a workspace, so pub needs every one of them pinned to +# the same commit. +dependency_overrides: + copilotkit: + git: + url: https://github.com/VGVentures/copilotkit_flutter_sdk.git + path: packages/copilotkit + ref: e52039c1729d57358ddac9c956fd2a2052928bbd + copilotkit_flutter: + git: + url: https://github.com/VGVentures/copilotkit_flutter_sdk.git + path: packages/copilotkit_flutter + ref: e52039c1729d57358ddac9c956fd2a2052928bbd + json_utils: + git: + url: https://github.com/VGVentures/copilotkit_flutter_sdk.git + path: packages/json_utils + ref: e52039c1729d57358ddac9c956fd2a2052928bbd diff --git a/03-custom-server/server/.env.example b/extras/custom-server/server/.env.example similarity index 100% rename from 03-custom-server/server/.env.example rename to extras/custom-server/server/.env.example diff --git a/03-custom-server/server/.gitignore b/extras/custom-server/server/.gitignore similarity index 100% rename from 03-custom-server/server/.gitignore rename to extras/custom-server/server/.gitignore diff --git a/03-custom-server/server/package-lock.json b/extras/custom-server/server/package-lock.json similarity index 100% rename from 03-custom-server/server/package-lock.json rename to extras/custom-server/server/package-lock.json diff --git a/03-custom-server/server/package.json b/extras/custom-server/server/package.json similarity index 100% rename from 03-custom-server/server/package.json rename to extras/custom-server/server/package.json diff --git a/03-custom-server/server/src/agent.ts b/extras/custom-server/server/src/agent.ts similarity index 100% rename from 03-custom-server/server/src/agent.ts rename to extras/custom-server/server/src/agent.ts diff --git a/03-custom-server/server/src/catalog.ts b/extras/custom-server/server/src/catalog.ts similarity index 100% rename from 03-custom-server/server/src/catalog.ts rename to extras/custom-server/server/src/catalog.ts diff --git a/03-custom-server/server/src/env.ts b/extras/custom-server/server/src/env.ts similarity index 100% rename from 03-custom-server/server/src/env.ts rename to extras/custom-server/server/src/env.ts diff --git a/03-custom-server/server/src/index.ts b/extras/custom-server/server/src/index.ts similarity index 100% rename from 03-custom-server/server/src/index.ts rename to extras/custom-server/server/src/index.ts diff --git a/03-custom-server/server/src/prompt.ts b/extras/custom-server/server/src/prompt.ts similarity index 100% rename from 03-custom-server/server/src/prompt.ts rename to extras/custom-server/server/src/prompt.ts diff --git a/03-custom-server/server/src/smoke.ts b/extras/custom-server/server/src/smoke.ts similarity index 100% rename from 03-custom-server/server/src/smoke.ts rename to extras/custom-server/server/src/smoke.ts diff --git a/03-custom-server/server/src/state.ts b/extras/custom-server/server/src/state.ts similarity index 100% rename from 03-custom-server/server/src/state.ts rename to extras/custom-server/server/src/state.ts diff --git a/03-custom-server/server/src/tools.ts b/extras/custom-server/server/src/tools.ts similarity index 100% rename from 03-custom-server/server/src/tools.ts rename to extras/custom-server/server/src/tools.ts diff --git a/03-custom-server/server/tsconfig.json b/extras/custom-server/server/tsconfig.json similarity index 100% rename from 03-custom-server/server/tsconfig.json rename to extras/custom-server/server/tsconfig.json diff --git a/extras/custom-server/shared/app_ui/.gitignore b/extras/custom-server/shared/app_ui/.gitignore new file mode 100644 index 0000000..06ef8e6 --- /dev/null +++ b/extras/custom-server/shared/app_ui/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/* + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/extras/custom-server/shared/app_ui/LICENSE b/extras/custom-server/shared/app_ui/LICENSE new file mode 100644 index 0000000..650d32a --- /dev/null +++ b/extras/custom-server/shared/app_ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Very Good Ventures + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/extras/custom-server/shared/app_ui/README.md b/extras/custom-server/shared/app_ui/README.md new file mode 100644 index 0000000..706ef94 --- /dev/null +++ b/extras/custom-server/shared/app_ui/README.md @@ -0,0 +1,56 @@ +# app_ui + +Design system of the **Very Good Coffee Manager** Flutter demo, ported from the +React demo's `styles.css`. Presentational and domain-free: widgets take plain +strings, numbers and enums, and the app maps its own models onto them. + +```dart +import 'package:app_ui/app_ui.dart'; + +MaterialApp(theme: AppTheme.light, darkTheme: AppTheme.dark, ...); +``` + +`app_ui.dart` also re-exports `package:flutter/material.dart`. + +Run the gallery: `cd example && flutter run -d chrome`. + +## Tokens + +| Class | Contents | +| --- | --- | +| `AppColors` | Raw brand constants: navy `#0C1A45`, accent `#2A48DE`, ground `#E9EDF8`, stage colors, wash colors, on-navy tints. | +| `AppPalette` | `ThemeExtension` of semantic roles (`ground`, `surface`, `cardGround`, `navy`, `ink`, `inkSecondary`, `inkTertiary`, `heading`, `accent`, `accentWash`, `positive`, `negative`, ...). Light and dark. Read with `context.palette`. | +| `AppSpacing` | `xxs` 2, `xs` 4, `sm` 6, `md` 8, `lg` 10, `xl` 12, `xxl` 14, `x3l` 16, `x4l` 20, `x5l` 24, `x6l` 28, `x7l` 32. | +| `AppRadii` | `card` 28, `dialog` 32, `proposal` 24, `row` 18, `option` 16, `chip` 14, `pill`, plus `BorderRadius` constants. | +| `AppTypography` | Poppins (bundled): `big`, `display`, `headline`, `brand`, `title`, `subtitle`, `bodyLarge`, `body`, `bodyMedium`, `small`, `smallMedium`, `micro`, `chip`, `tag`, `button`, `buttonBig`, `mono` (tabular figures). | +| `AppElevation` | `card`, `dialog`, `segment`, `iconButton` shadows; `scrim`, `brandScrim`, `resultScrim`. | +| `AppMotion` | Durations and the `ease` curve `cubic-bezier(0.2, 0.8, 0.2, 1)`. | +| `AppSizes` | Control heights, icon sizes, dot, bar, avatar, dialog widths. | + +`AppTheme.light` / `AppTheme.dark` are Material 3 themes built from these. + +## Widgets + +- Buttons: `AppButton` (`.primary`, `.ghost`, `size`, `icon`, `onNavy`, `expanded`), `AppIconButton`, `AppSegmented`. +- Labels: `AppChip` (+ `AppTone`), `AppVerdictChip`, `AppSeverityChip`, `AppCountBadge`, `AppStageDot`, `AppStepPill`, `AppGoalChip`. +- Surfaces: `AppCard`, `AppPanel`, `AppSectionHeader`. +- Data: `AppStatTile`, `AppProgressBar`, `AppKeyValueRow`, `AppAvatar`, `AppPersonRow`, `AppSkeleton`, `AppSkeletonBlock`. +- Orders: `AppOrderRow` (`AppUrgency`: normal, warn, urgent, done). +- Chat: `AppChatBubble` (`AppChatRole`: customer, courier, manager), `AppTypingBubble`, `AppComposer`, `AppSuggestionChip`. +- Copilot: `AppDecisionCard`, `AppOptionTile`. +- Overlays: `showAppDialog`, `AppDialogScaffold`, `AppWelcomeDialog`, `AppDialogStep`, `AppResultDialog`, `AppToast`. +- Brand: `AppUnicorn`, `AppVgvLockup`, `AppAssets` (SVGs bundled under `assets/images`). +- Icons: `AppIcons` + `AppIcon` (Material rounded glyphs; see below). + +## Notes + +- `phosphor_flutter` 2.1.0 (latest) does not compile on Flutter 3.47 (`IconData` + is a final class), so icons use Material. `AppIcons` is the single seam to swap + Phosphor back in later. +- Poppins is bundled as a font asset (no network). JetBrains Mono is not + bundled; `AppTypography.mono` falls back to the platform monospace with + tabular figures. + +## Tests + +`flutter test --coverage` (tests live in `test/src`, one file per widget group). diff --git a/extras/custom-server/shared/app_ui/analysis_options.yaml b/extras/custom-server/shared/app_ui/analysis_options.yaml new file mode 100644 index 0000000..317f632 --- /dev/null +++ b/extras/custom-server/shared/app_ui/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + exclude: + - build/** + - web/** + - android/** + - ios/** + - windows/** + - macos/** + - linux/** +linter: + rules: + # The workshop uses classic constructors; Dart 3.13 offers shorthands. + prefer_initializing_formals: false + unnecessary_type_name_in_constructor: false + # A workshop app doesn't need an API reference. + public_member_api_docs: false diff --git a/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf b/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..dc2e5d0 Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf b/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/extras/custom-server/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt b/extras/custom-server/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/extras/custom-server/shared/app_ui/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/extras/custom-server/shared/app_ui/assets/fonts/OFL.txt b/extras/custom-server/shared/app_ui/assets/fonts/OFL.txt new file mode 100644 index 0000000..76df3b5 --- /dev/null +++ b/extras/custom-server/shared/app_ui/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Medium.ttf b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Medium.ttf new file mode 100644 index 0000000..a590f5c Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Medium.ttf differ diff --git a/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Regular.ttf b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-Regular.ttf differ diff --git a/extras/custom-server/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/extras/custom-server/shared/app_ui/assets/images/copilotkit-logo.png b/extras/custom-server/shared/app_ui/assets/images/copilotkit-logo.png new file mode 100644 index 0000000..8b878b6 Binary files /dev/null and b/extras/custom-server/shared/app_ui/assets/images/copilotkit-logo.png differ diff --git a/extras/custom-server/shared/app_ui/assets/images/unicorn-dark.svg b/extras/custom-server/shared/app_ui/assets/images/unicorn-dark.svg new file mode 100644 index 0000000..89a9896 --- /dev/null +++ b/extras/custom-server/shared/app_ui/assets/images/unicorn-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extras/custom-server/shared/app_ui/assets/images/unicorn.svg b/extras/custom-server/shared/app_ui/assets/images/unicorn.svg new file mode 100644 index 0000000..06fecf6 --- /dev/null +++ b/extras/custom-server/shared/app_ui/assets/images/unicorn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/extras/custom-server/shared/app_ui/assets/images/vgv-lockup.svg b/extras/custom-server/shared/app_ui/assets/images/vgv-lockup.svg new file mode 100644 index 0000000..ab2bbe3 --- /dev/null +++ b/extras/custom-server/shared/app_ui/assets/images/vgv-lockup.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/extras/custom-server/shared/app_ui/lib/app_ui.dart b/extras/custom-server/shared/app_ui/lib/app_ui.dart new file mode 100644 index 0000000..774d173 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/app_ui.dart @@ -0,0 +1,9 @@ +/// Design system of the Very Good Coffee Manager: tokens, themes and +/// presentational widgets. +library; + +export 'package:flutter/material.dart'; + +export 'src/theme/theme.dart'; +export 'src/tokens/tokens.dart'; +export 'src/widgets/widgets.dart'; diff --git a/extras/custom-server/shared/app_ui/lib/src/theme/app_palette.dart b/extras/custom-server/shared/app_ui/lib/src/theme/app_palette.dart new file mode 100644 index 0000000..e254384 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/theme/app_palette.dart @@ -0,0 +1,258 @@ +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Semantic color roles that adapt to light and dark mode. +/// +/// Read it with `context.palette`. +@immutable +class AppPalette extends ThemeExtension { + /// Creates a palette. + const AppPalette({ + required this.ground, + required this.cardGround, + required this.surface, + required this.navy, + required this.navyAlt, + required this.onNavy, + required this.onNavyMuted, + required this.onNavyFill, + required this.sky, + required this.ink, + required this.heading, + required this.inkSecondary, + required this.inkTertiary, + required this.fill, + required this.hairline, + required this.accent, + required this.accentWash, + required this.positive, + required this.negative, + required this.warning, + required this.incident, + required this.warningWash, + required this.negativeWash, + required this.positiveWash, + required this.slowWash, + required this.scrim, + }); + + /// Light palette taken from the React demo. + const AppPalette.light() + : this( + ground: AppColors.ground, + cardGround: AppColors.cardGround, + surface: AppColors.surface, + navy: AppColors.navy, + navyAlt: AppColors.navy2, + onNavy: const Color(0xFFFFFFFF), + onNavyMuted: const Color(0x8FFFFFFF), + onNavyFill: const Color(0x24FFFFFF), + sky: AppColors.sky, + ink: AppColors.ink, + heading: AppColors.navy, + inkSecondary: const Color(0xC7191817), + inkTertiary: const Color(0x8F191817), + fill: const Color(0x0F191817), + hairline: const Color(0x1A191817), + accent: AppColors.accent, + accentWash: const Color(0x1F2A48DE), + positive: AppColors.positive, + negative: AppColors.negative, + warning: AppColors.warning, + incident: AppColors.incident, + warningWash: AppColors.warningWash, + negativeWash: AppColors.negativeWash, + positiveWash: AppColors.positiveWash, + slowWash: AppColors.slowWash, + scrim: AppElevation.scrim, + ); + + /// Dark palette derived from the brand navy. + const AppPalette.dark() + : this( + ground: AppColors.darkGround, + cardGround: AppColors.darkCardGround, + surface: AppColors.darkSurface, + navy: const Color(0xFF050B22), + navyAlt: AppColors.navy2, + onNavy: const Color(0xFFFFFFFF), + onNavyMuted: const Color(0x8FFFFFFF), + onNavyFill: const Color(0x24FFFFFF), + sky: AppColors.sky, + ink: AppColors.darkInk, + heading: AppColors.darkInk, + inkSecondary: const Color(0xC7F3F4F8), + inkTertiary: const Color(0x8FF3F4F8), + fill: const Color(0x14FFFFFF), + hairline: const Color(0x24FFFFFF), + accent: AppColors.darkAccent, + accentWash: const Color(0x2E7C93FF), + positive: AppColors.positiveOnNavy, + negative: AppColors.negativeOnNavy, + warning: AppColors.warning, + incident: const Color(0xFFFF9A5C), + warningWash: const Color(0x33F5A524), + negativeWash: const Color(0x33E0353B), + positiveWash: const Color(0x331FA567), + slowWash: const Color(0x29E0353B), + scrim: const Color(0x99000000), + ); + + /// Page background. + final Color ground; + + /// Inset background for rows inside cards. + final Color cardGround; + + /// Card surface. + final Color surface; + + /// Navy panel background. + final Color navy; + + /// Secondary navy. + final Color navyAlt; + + /// Primary content on navy. + final Color onNavy; + + /// Muted content on navy. + final Color onNavyMuted; + + /// Translucent fill on navy. + final Color onNavyFill; + + /// Periwinkle highlight on navy. + final Color sky; + + /// Primary text. + final Color ink; + + /// Heading color (navy in light mode). + final Color heading; + + /// Secondary text. + final Color inkSecondary; + + /// Tertiary text. + final Color inkTertiary; + + /// Subtle fill (ghost buttons, skeletons, tracks). + final Color fill; + + /// Hairline borders. + final Color hairline; + + /// Brand accent. + final Color accent; + + /// Translucent accent background. + final Color accentWash; + + /// Positive state. + final Color positive; + + /// Negative state. + final Color negative; + + /// Warning state. + final Color warning; + + /// Incident tag color. + final Color incident; + + /// Warning background. + final Color warningWash; + + /// Negative background. + final Color negativeWash; + + /// Positive background. + final Color positiveWash; + + /// Slow courier background. + final Color slowWash; + + /// Plain dialog scrim. + final Color scrim; + + @override + AppPalette copyWith({ + Color? ground, + Color? cardGround, + Color? surface, + Color? navy, + Color? accent, + Color? ink, + }) { + return AppPalette( + ground: ground ?? this.ground, + cardGround: cardGround ?? this.cardGround, + surface: surface ?? this.surface, + navy: navy ?? this.navy, + navyAlt: navyAlt, + onNavy: onNavy, + onNavyMuted: onNavyMuted, + onNavyFill: onNavyFill, + sky: sky, + ink: ink ?? this.ink, + heading: heading, + inkSecondary: inkSecondary, + inkTertiary: inkTertiary, + fill: fill, + hairline: hairline, + accent: accent ?? this.accent, + accentWash: accentWash, + positive: positive, + negative: negative, + warning: warning, + incident: incident, + warningWash: warningWash, + negativeWash: negativeWash, + positiveWash: positiveWash, + slowWash: slowWash, + scrim: scrim, + ); + } + + @override + AppPalette lerp(ThemeExtension? other, double t) { + if (other is! AppPalette) return this; + Color l(Color a, Color b) => Color.lerp(a, b, t)!; + return AppPalette( + ground: l(ground, other.ground), + cardGround: l(cardGround, other.cardGround), + surface: l(surface, other.surface), + navy: l(navy, other.navy), + navyAlt: l(navyAlt, other.navyAlt), + onNavy: l(onNavy, other.onNavy), + onNavyMuted: l(onNavyMuted, other.onNavyMuted), + onNavyFill: l(onNavyFill, other.onNavyFill), + sky: l(sky, other.sky), + ink: l(ink, other.ink), + heading: l(heading, other.heading), + inkSecondary: l(inkSecondary, other.inkSecondary), + inkTertiary: l(inkTertiary, other.inkTertiary), + fill: l(fill, other.fill), + hairline: l(hairline, other.hairline), + accent: l(accent, other.accent), + accentWash: l(accentWash, other.accentWash), + positive: l(positive, other.positive), + negative: l(negative, other.negative), + warning: l(warning, other.warning), + incident: l(incident, other.incident), + warningWash: l(warningWash, other.warningWash), + negativeWash: l(negativeWash, other.negativeWash), + positiveWash: l(positiveWash, other.positiveWash), + slowWash: l(slowWash, other.slowWash), + scrim: l(scrim, other.scrim), + ); + } +} + +/// Shortcut to [AppPalette] from a [BuildContext]. +extension AppPaletteContext on BuildContext { + /// The active [AppPalette]. + AppPalette get palette => + Theme.of(this).extension() ?? const AppPalette.light(); +} diff --git a/extras/custom-server/shared/app_ui/lib/src/theme/app_theme.dart b/extras/custom-server/shared/app_ui/lib/src/theme/app_theme.dart new file mode 100644 index 0000000..ca675fb --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/theme/app_theme.dart @@ -0,0 +1,113 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Material 3 themes built from the design tokens. +abstract final class AppTheme { + /// Light theme. + static ThemeData get light => + _build(Brightness.light, const AppPalette.light()); + + /// Dark theme. + static ThemeData get dark => _build(Brightness.dark, const AppPalette.dark()); + + static ThemeData _build(Brightness brightness, AppPalette p) { + final scheme = + ColorScheme.fromSeed( + seedColor: AppColors.accent, + brightness: brightness, + ).copyWith( + primary: p.accent, + onPrimary: brightness == Brightness.light + ? AppColors.surface + : AppColors.darkGround, + primaryContainer: p.accentWash, + onPrimaryContainer: p.accent, + secondary: p.navy, + onSecondary: p.onNavy, + surface: p.surface, + onSurface: p.ink, + onSurfaceVariant: p.inkSecondary, + outline: p.hairline, + outlineVariant: p.hairline, + error: p.negative, + surfaceContainerHighest: p.cardGround, + ); + final textTheme = _textTheme(p); + const inputBorder = OutlineInputBorder( + borderRadius: AppRadii.pillBorder, + borderSide: BorderSide.none, + ); + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: p.ground, + canvasColor: p.ground, + fontFamily: AppTypography.fontFamily, + textTheme: textTheme, + dividerColor: p.hairline, + extensions: [p], + splashFactory: NoSplash.splashFactory, + cardTheme: CardThemeData( + color: p.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: const RoundedRectangleBorder(borderRadius: AppRadii.cardBorder), + ), + dialogTheme: DialogThemeData( + backgroundColor: p.surface, + shape: const RoundedRectangleBorder(borderRadius: AppRadii.cardBorder), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: p.fill, + hintStyle: AppTypography.body.copyWith(color: p.inkTertiary), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.x4l - AppSpacing.xs / 2, + ), + border: inputBorder, + enabledBorder: inputBorder, + focusedBorder: inputBorder.copyWith( + borderSide: BorderSide(color: p.ink, width: 2), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: p.surface, + contentTextStyle: AppTypography.bodyMedium.copyWith(color: p.navy), + shape: const StadiumBorder(), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: p.accent, + foregroundColor: scheme.onPrimary, + minimumSize: const Size(0, AppSizes.control), + shape: const StadiumBorder(), + textStyle: AppTypography.button, + ), + ), + ); + } + + static TextTheme _textTheme(AppPalette p) { + TextStyle s(TextStyle base, Color c) => base.copyWith(color: c); + return TextTheme( + displayLarge: s(AppTypography.display, p.ink), + displayMedium: s(AppTypography.headline, p.ink), + displaySmall: s(AppTypography.big, p.ink), + headlineLarge: s(AppTypography.headline, p.heading), + headlineMedium: s(AppTypography.brand, p.ink), + headlineSmall: s(AppTypography.title, p.ink), + titleLarge: s(AppTypography.title, p.ink), + titleMedium: s(AppTypography.subtitle, p.ink), + titleSmall: s(AppTypography.bodyMedium, p.ink), + bodyLarge: s(AppTypography.bodyLarge, p.inkSecondary), + bodyMedium: s(AppTypography.body, p.ink), + bodySmall: s(AppTypography.small, p.inkTertiary), + labelLarge: s(AppTypography.button, p.ink), + labelMedium: s(AppTypography.smallMedium, p.ink), + labelSmall: s(AppTypography.chip, p.ink), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/theme/theme.dart b/extras/custom-server/shared/app_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000..0b957bf --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/theme/theme.dart @@ -0,0 +1,2 @@ +export 'app_palette.dart'; +export 'app_theme.dart'; diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_colors.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_colors.dart new file mode 100644 index 0000000..d183ed3 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_colors.dart @@ -0,0 +1,94 @@ +import 'dart:ui'; + +/// Raw brand colors of the Very Good Coffee Manager. +/// +/// These are theme-independent constants. Widgets should prefer the semantic +/// roles in `AppPalette`, which adapt to light and dark mode. +abstract final class AppColors { + /// Deep navy used by the score bar, agent panel and dialog heroes. + static const Color navy = Color(0xFF0C1A45); + + /// Lighter navy used for gradients and raised navy surfaces. + static const Color navy2 = Color(0xFF16296B); + + /// Soft periwinkle used for fills on navy. + static const Color sky = Color(0xFF8FA8FF); + + /// Primary brand blue. + static const Color accent = Color(0xFF2A48DE); + + /// Light page ground. + static const Color ground = Color(0xFFE9EDF8); + + /// Slightly lighter ground used for rows inside white cards. + static const Color cardGround = Color(0xFFF1F4FB); + + /// White card surface. + static const Color surface = Color(0xFFFFFFFF); + + /// Primary text ink (warm near-black). + static const Color ink = Color(0xFF191817); + + /// Positive / success green. + static const Color positive = Color(0xFF1FA567); + + /// Negative / error red. + static const Color negative = Color(0xFFE0353B); + + /// Warning amber (urgency stripe). + static const Color warning = Color(0xFFF5A524); + + /// Burnt orange used for incident and urgent tags. + static const Color incident = Color(0xFFC4551A); + + /// Background wash for a warning order row. + static const Color warningWash = Color(0xFFFFF3D6); + + /// Background wash for an urgent order row. + static const Color negativeWash = Color(0xFFFFE1E2); + + /// Pulse target of the urgent order row animation. + static const Color negativeWashPulse = Color(0xFFFFD0D2); + + /// Background wash for a done order row. + static const Color positiveWash = Color(0xFFD9F5E6); + + /// Background wash for a slow courier row. + static const Color slowWash = Color(0xFFFFE9EA); + + /// Positive text color on navy. + static const Color positiveOnNavy = Color(0xFF4FDC9A); + + /// Negative text color on navy. + static const Color negativeOnNavy = Color(0xFFFF7B80); + + /// Warning text color on navy. + static const Color warningOnNavy = Color(0xFFFFE08A); + + /// Stage color: order requested. + static const Color stageRequested = Color(0xFF8FA8FF); + + /// Stage color: barista is preparing. + static const Color stageBarista = Color(0xFF4F6EF0); + + /// Stage color: out for delivery. + static const Color stageDelivery = Color(0xFF2A48DE); + + /// Stage color: delivered. + static const Color stageDelivered = Color(0xFF1FA567); + + /// Dark mode page ground. + static const Color darkGround = Color(0xFF070E26); + + /// Dark mode card surface. + static const Color darkSurface = Color(0xFF111D4A); + + /// Dark mode inset ground for rows inside cards. + static const Color darkCardGround = Color(0xFF1A2A63); + + /// Dark mode accent, lifted for contrast on navy. + static const Color darkAccent = Color(0xFF7C93FF); + + /// Dark mode ink. + static const Color darkInk = Color(0xFFF3F4F8); +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_elevation.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_elevation.dart new file mode 100644 index 0000000..addbd17 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_elevation.dart @@ -0,0 +1,37 @@ +import 'package:flutter/painting.dart'; + +/// Shadow tokens. +abstract final class AppElevation { + /// Soft shadow under cards, panels and primary buttons. + static const List card = [ + BoxShadow(color: Color(0x0D0C1A45), blurRadius: 2, offset: Offset(0, 1)), + BoxShadow(color: Color(0x140C1A45), blurRadius: 24, offset: Offset(0, 8)), + ]; + + /// Heavy shadow under the welcome dialog. + static const List dialog = [ + BoxShadow(color: Color(0x660C1A45), blurRadius: 80, offset: Offset(0, 24)), + ]; + + /// Shadow under the selected segment of a segmented control. + static const List segment = [ + BoxShadow(color: Color(0x1F191817), blurRadius: 2, offset: Offset(0, 1)), + ]; + + /// Shadow under the primary icon button. + static const List iconButton = [ + BoxShadow(color: Color(0x330C1A45), blurRadius: 14, offset: Offset(0, 4)), + ]; + + /// Scrim color behind plain dialogs. + static const Color scrim = Color(0x66191817); + + /// Scrim color behind branded dialogs. + static const Color brandScrim = Color(0x8C0C1A45); + + /// Scrim color behind result dialogs. + static const Color resultScrim = Color(0x990C1A45); + + /// Blur sigma behind branded dialogs. + static const double scrimBlur = 4; +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_motion.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_motion.dart new file mode 100644 index 0000000..2290dbf --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_motion.dart @@ -0,0 +1,28 @@ +import 'package:flutter/animation.dart'; + +/// Durations and curves. +abstract final class AppMotion { + /// Press feedback. + static const Duration press = Duration(milliseconds: 120); + + /// Hover / outline transitions. + static const Duration quick = Duration(milliseconds: 200); + + /// Entrance animations. + static const Duration enter = Duration(milliseconds: 400); + + /// Progress bar fills. + static const Duration progress = Duration(milliseconds: 400); + + /// Skeleton pulse period. + static const Duration pulse = Duration(milliseconds: 1200); + + /// Typing indicator period. + static const Duration typing = Duration(milliseconds: 1100); + + /// Urgent pulse period. + static const Duration urgent = Duration(milliseconds: 1600); + + /// The design system's ease curve, `cubic-bezier(0.2, 0.8, 0.2, 1)`. + static const Curve ease = Cubic(0.2, 0.8, 0.2, 1); +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_radii.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_radii.dart new file mode 100644 index 0000000..fc33b8c --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_radii.dart @@ -0,0 +1,46 @@ +import 'package:flutter/painting.dart'; + +/// Corner radii. +abstract final class AppRadii { + /// Radius of cards and panels. + static const double card = 28; + + /// Radius of dialogs with a hero. + static const double dialog = 32; + + /// Radius of a nested decision card. + static const double proposal = 24; + + /// Radius of order rows and nested blocks. + static const double row = 18; + + /// Radius of options and person rows. + static const double option = 16; + + /// Radius of small chips and text fields. + static const double chip = 14; + + /// Radius of tiny elements (skeleton lines). + static const double tiny = 6; + + /// Radius large enough to make any box a pill. + static const double pill = 999; + + /// Pill border radius. + static const BorderRadius pillBorder = BorderRadius.all( + Radius.circular(pill), + ); + + /// Card border radius. + static const BorderRadius cardBorder = BorderRadius.all( + Radius.circular(card), + ); + + /// Row border radius. + static const BorderRadius rowBorder = BorderRadius.all(Radius.circular(row)); + + /// Chip border radius. + static const BorderRadius chipBorder = BorderRadius.all( + Radius.circular(chip), + ); +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_sizes.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_sizes.dart new file mode 100644 index 0000000..ea35be4 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_sizes.dart @@ -0,0 +1,71 @@ +/// Fixed component dimensions that are not part of the spacing scale. +abstract final class AppSizes { + /// Minimum height of a button or text field. + static const double control = 44; + + /// Minimum height of a big button. + static const double controlBig = 52; + + /// Compact control height used in mini panels. + static const double controlCompact = 36; + + /// Side of a round icon button. + static const double iconButton = 44; + + /// Side of a primary round icon button. + static const double iconButtonPrimary = 48; + + /// Small icon. + static const double iconSmall = 16; + + /// Regular icon. + static const double icon = 20; + + /// Large icon. + static const double iconLarge = 24; + + /// Hero icon of the result dialog. + static const double iconHero = 56; + + /// Status dot diameter. + static const double dot = 8; + + /// Height of progress bars and meters. + static const double bar = 6; + + /// Width of the target marker on a progress bar. + static const double barMarker = 2; + + /// Height of the target marker on a progress bar. + static const double barMarkerHeight = 12; + + /// Avatar diameter. + static const double avatar = 34; + + /// Step number badge diameter. + static const double stepBadge = 18; + + /// Typing dot diameter. + static const double typingDot = 6; + + /// Width of the urgency stripe on an order row. + static const double stripe = 4; + + /// Maximum width of a dialog. + static const double dialogWidth = 440; + + /// Maximum width of a welcome dialog. + static const double welcomeWidth = 560; + + /// Maximum width of a result dialog. + static const double resultWidth = 500; + + /// Unicorn mark in the score bar. + static const double logoSmall = 44; + + /// Unicorn mark in the welcome hero. + static const double logoLarge = 120; + + /// Fraction of the available width taken by a chat bubble. + static const double bubbleWidthFactor = 0.86; +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_spacing.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_spacing.dart new file mode 100644 index 0000000..ae280a6 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_spacing.dart @@ -0,0 +1,38 @@ +/// Spacing scale shared by every widget. +abstract final class AppSpacing { + /// 2 logical pixels. + static const double xxs = 2; + + /// 4 logical pixels. + static const double xs = 4; + + /// 6 logical pixels. + static const double sm = 6; + + /// 8 logical pixels. + static const double md = 8; + + /// 10 logical pixels. + static const double lg = 10; + + /// 12 logical pixels. + static const double xl = 12; + + /// 14 logical pixels. + static const double xxl = 14; + + /// 16 logical pixels. + static const double x3l = 16; + + /// 20 logical pixels. + static const double x4l = 20; + + /// 24 logical pixels. + static const double x5l = 24; + + /// 28 logical pixels. + static const double x6l = 28; + + /// 32 logical pixels. + static const double x7l = 32; +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/app_typography.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/app_typography.dart new file mode 100644 index 0000000..70ece15 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/app_typography.dart @@ -0,0 +1,157 @@ +import 'package:flutter/painting.dart'; + +/// Text styles of the design system, set in Poppins. +abstract final class AppTypography { + /// Font family, bundled with the package. + static const String fontFamily = 'packages/app_ui/Poppins'; + + /// Monospace family, bundled with the package. + static const String monoFamily = 'packages/app_ui/JetBrainsMono'; + + /// Fallbacks used by [mono]. + static const List monoFallback = [ + 'Menlo', + 'Roboto Mono', + 'monospace', + ]; + + static const double _tracking = -0.005; + + /// 28px / 500, for big numbers. + static const TextStyle big = TextStyle( + fontFamily: fontFamily, + fontSize: 28, + fontWeight: FontWeight.w500, + letterSpacing: -0.56, + height: 1.15, + ); + + /// 32px / 500, for result headlines. + static const TextStyle display = TextStyle( + fontFamily: fontFamily, + fontSize: 32, + fontWeight: FontWeight.w500, + letterSpacing: -0.64, + height: 1.15, + ); + + /// 28px / 600, for dialog titles. + static const TextStyle headline = TextStyle( + fontFamily: fontFamily, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.56, + height: 1.15, + ); + + /// 22px / 600, brand name. + static const TextStyle brand = TextStyle( + fontFamily: fontFamily, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.44, + ); + + /// 18px / 600, section titles. + static const TextStyle title = TextStyle( + fontFamily: fontFamily, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.18, + ); + + /// 16px / 600, card titles. + static const TextStyle subtitle = TextStyle( + fontFamily: fontFamily, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: -0.16, + ); + + /// 15px / 400, long copy. + static const TextStyle bodyLarge = TextStyle( + fontFamily: fontFamily, + fontSize: 15, + height: 1.55, + letterSpacing: _tracking * 15, + ); + + /// 14px / 400, the default body. + static const TextStyle body = TextStyle( + fontFamily: fontFamily, + fontSize: 14, + height: 1.4, + letterSpacing: _tracking * 14, + ); + + /// 14px / 500, emphasized body. + static const TextStyle bodyMedium = TextStyle( + fontFamily: fontFamily, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.4, + letterSpacing: _tracking * 14, + ); + + /// 12px / 400, secondary info. + static const TextStyle small = TextStyle( + fontFamily: fontFamily, + fontSize: 12, + height: 1.4, + ); + + /// 12px / 500, labels and counts. + static const TextStyle smallMedium = TextStyle( + fontFamily: fontFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.4, + ); + + /// 11px / 400, micro captions. + static const TextStyle micro = TextStyle( + fontFamily: fontFamily, + fontSize: 11, + height: 1.4, + ); + + /// 11px / 500, chips. + static const TextStyle chip = TextStyle( + fontFamily: fontFamily, + fontSize: 11, + fontWeight: FontWeight.w500, + height: 1.3, + ); + + /// 10px / 600 uppercase, tiny tags such as "Recommended". + static const TextStyle tag = TextStyle( + fontFamily: fontFamily, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + ); + + /// Button label, 14px / 500. + static const TextStyle button = TextStyle( + fontFamily: fontFamily, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ); + + /// Big button label, 16px / 500. + static const TextStyle buttonBig = TextStyle( + fontFamily: fontFamily, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.2, + ); + + /// Monospace with tabular figures, for money, clocks and scores. + static const TextStyle mono = TextStyle( + fontFamily: monoFamily, + fontFamilyFallback: monoFallback, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ); +} diff --git a/extras/custom-server/shared/app_ui/lib/src/tokens/tokens.dart b/extras/custom-server/shared/app_ui/lib/src/tokens/tokens.dart new file mode 100644 index 0000000..65a829f --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/tokens/tokens.dart @@ -0,0 +1,7 @@ +export 'app_colors.dart'; +export 'app_elevation.dart'; +export 'app_motion.dart'; +export 'app_radii.dart'; +export 'app_sizes.dart'; +export 'app_spacing.dart'; +export 'app_typography.dart'; diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_brand.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_brand.dart new file mode 100644 index 0000000..8d23541 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_brand.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Paths of the bundled SVG assets (package `app_ui`). +abstract final class AppAssets { + /// Package name used to resolve assets. + static const String package = 'app_ui'; + + /// Unicorn mark drawn for navy backgrounds (white body). + static const String unicorn = 'assets/images/unicorn.svg'; + + /// Unicorn mark drawn for light backgrounds (black). + static const String unicornDark = 'assets/images/unicorn-dark.svg'; + + /// Very Good Ventures lockup (black wordmark). + static const String vgvLockup = 'assets/images/vgv-lockup.svg'; + + /// The CopilotKit logo, dark on a transparent-free white background: show it + /// on a light surface. + static const String copilotKitLogo = 'assets/images/copilotkit-logo.png'; +} + +/// The VGV unicorn. +class AppUnicorn extends StatelessWidget { + /// Creates the unicorn at [size]. + const AppUnicorn({ + required this.size, + this.onNavy = true, + this.semanticLabel, + super.key, + }); + + /// Width and height. + final double size; + + /// Pick the white variant for navy backgrounds (default) or the black one + /// for light backgrounds. + final bool onNavy; + + /// Accessibility label. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + onNavy ? AppAssets.unicorn : AppAssets.unicornDark, + package: AppAssets.package, + width: size, + height: size, + semanticsLabel: semanticLabel, + ); + } +} + +/// The Very Good Ventures lockup, optionally tinted. +class AppVgvLockup extends StatelessWidget { + /// Creates the lockup at [height]. + const AppVgvLockup({required this.height, this.color, super.key}); + + /// Height; width follows the 2209:765 aspect ratio. + final double height; + + /// Tint (e.g. white on navy). Null keeps the black artwork. + final Color? color; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + AppAssets.vgvLockup, + package: AppAssets.package, + height: height, + semanticsLabel: 'Very Good Ventures', + colorFilter: color == null + ? null + : ColorFilter.mode(color!, BlendMode.srcIn), + ); + } +} + +/// The CopilotKit logo: a dark wordmark on white, so it belongs on a light +/// surface, like a pill on the navy banner. +class AppCopilotKitLogo extends StatelessWidget { + /// Creates the logo [height] logical pixels tall. + const AppCopilotKitLogo({required this.height, super.key}); + + /// Height of the logo. + final double height; + + @override + Widget build(BuildContext context) { + return Image.asset( + AppAssets.copilotKitLogo, + package: AppAssets.package, + height: height, + semanticLabel: 'CopilotKit', + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_button.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_button.dart new file mode 100644 index 0000000..04cd6fe --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_button.dart @@ -0,0 +1,253 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_pressable.dart'; +import 'package:flutter/material.dart'; + +/// Visual variants of [AppButton]. +enum AppButtonVariant { + /// Solid brand blue. + primary, + + /// Subtle fill. + ghost, +} + +/// Size presets of [AppButton]. +enum AppButtonSize { + /// 44px tall. + regular, + + /// 52px tall with a larger label. + big, + + /// 36px tall, for mini panels. + compact, +} + +/// Pill button of the design system. +class AppButton extends StatelessWidget { + /// Creates a button. + const AppButton({ + required this.label, + required this.onPressed, + this.variant = AppButtonVariant.primary, + this.size = AppButtonSize.regular, + this.icon, + this.onNavy = false, + this.expanded = false, + super.key, + }); + + /// Solid brand button. + const AppButton.primary({ + required String label, + required VoidCallback? onPressed, + AppIconSet? icon, + AppButtonSize size = AppButtonSize.regular, + bool onNavy = false, + bool expanded = false, + Key? key, + }) : this( + label: label, + onPressed: onPressed, + icon: icon, + size: size, + onNavy: onNavy, + expanded: expanded, + key: key, + ); + + /// Subtle button. + const AppButton.ghost({ + required String label, + required VoidCallback? onPressed, + AppIconSet? icon, + AppButtonSize size = AppButtonSize.regular, + bool onNavy = false, + bool expanded = false, + Key? key, + }) : this( + label: label, + onPressed: onPressed, + icon: icon, + size: size, + onNavy: onNavy, + expanded: expanded, + variant: AppButtonVariant.ghost, + key: key, + ); + + /// Text shown on the button. + final String label; + + /// Tap callback; null disables the button. + final VoidCallback? onPressed; + + /// Visual variant. + final AppButtonVariant variant; + + /// Size preset. + final AppButtonSize size; + + /// Optional leading icon. + final AppIconSet? icon; + + /// Restyle for placement on a navy surface. + final bool onNavy; + + /// Whether the button fills the available width. + final bool expanded; + + static const double _disabledOpacity = 0.4; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final primary = variant == AppButtonVariant.primary; + final Color background; + final Color foreground; + if (onNavy) { + background = primary ? p.onNavy : p.onNavyFill; + foreground = primary ? p.navy : p.onNavy; + } else { + background = primary ? p.accent : p.fill; + foreground = primary ? p.surface : p.ink; + } + final (height, style) = switch (size) { + AppButtonSize.regular => (AppSizes.control, AppTypography.button), + AppButtonSize.big => (AppSizes.controlBig, AppTypography.buttonBig), + AppButtonSize.compact => (AppSizes.controlCompact, AppTypography.button), + }; + final shadow = primary && !onNavy ? AppElevation.card : null; + return AppOpacityWhen( + dim: onPressed == null, + opacity: _disabledOpacity, + child: AppPressable( + onTap: onPressed, + semanticLabel: label, + child: Container( + constraints: BoxConstraints(minHeight: height), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.x4l), + decoration: BoxDecoration( + color: background, + borderRadius: AppRadii.pillBorder, + boxShadow: shadow, + ), + child: Row( + mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + AppIcon(icon!, style: AppIconStyle.fill, color: foreground), + const SizedBox(width: AppSpacing.md), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style.copyWith(color: foreground), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Fades its child when [dim] is true. +class AppOpacityWhen extends StatelessWidget { + /// Creates the wrapper. + const AppOpacityWhen({ + required this.dim, + required this.opacity, + required this.child, + super.key, + }); + + /// Whether to apply [opacity]. + final bool dim; + + /// Opacity applied when dimmed. + final double opacity; + + /// Content. + final Widget child; + + @override + Widget build(BuildContext context) { + return Opacity(opacity: dim ? opacity : 1, child: child); + } +} + +/// Round icon-only button, used for play, pause and restart. +class AppIconButton extends StatelessWidget { + /// Creates an icon button. + const AppIconButton({ + required this.icon, + required this.onPressed, + required this.semanticLabel, + this.primary = false, + this.onNavy = true, + this.style = AppIconStyle.regular, + super.key, + }); + + /// Glyph. + final AppIconSet icon; + + /// Tap callback; null disables it. + final VoidCallback? onPressed; + + /// Accessibility label. + final String semanticLabel; + + /// Highlighted variant (white disc, brand icon). + final bool primary; + + /// Whether it sits on navy (default) or on a light surface. + final bool onNavy; + + /// Glyph weight. + final AppIconStyle style; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final side = primary ? AppSizes.iconButtonPrimary : AppSizes.iconButton; + final background = primary + ? (onNavy ? p.onNavy : p.accent) + : (onNavy ? p.onNavyFill : p.fill); + final foreground = primary + ? (onNavy ? p.accent : p.surface) + : (onNavy ? p.onNavy : p.ink); + return AppOpacityWhen( + dim: onPressed == null, + opacity: 0.4, + child: AppPressable( + onTap: onPressed, + semanticLabel: semanticLabel, + pressedScale: 0.92, + child: Container( + width: side, + height: side, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + boxShadow: primary ? AppElevation.iconButton : null, + ), + alignment: Alignment.center, + child: AppIcon( + icon, + style: style, + size: AppSizes.icon, + color: foreground, + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_card.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_card.dart new file mode 100644 index 0000000..b37cf0c --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_card.dart @@ -0,0 +1,163 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:flutter/material.dart'; + +/// White rounded surface with the design system shadow. +class AppCard extends StatelessWidget { + /// Creates a card. + const AppCard({ + required this.child, + this.padding = EdgeInsets.zero, + this.color, + this.radius = AppRadii.card, + this.shadow = true, + this.clip = true, + super.key, + }); + + /// Card content. + final Widget child; + + /// Inner padding. + final EdgeInsetsGeometry padding; + + /// Background; defaults to the palette surface. + final Color? color; + + /// Corner radius. + final double radius; + + /// Whether to paint the card shadow. + final bool shadow; + + /// Whether to clip children to the rounded rectangle. + final bool clip; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + color: color ?? p.surface, + borderRadius: BorderRadius.circular(radius), + boxShadow: shadow ? AppElevation.card : null, + ), + child: ClipRRect( + clipBehavior: clip ? Clip.antiAlias : Clip.none, + borderRadius: BorderRadius.circular(radius), + child: Padding(padding: padding, child: child), + ), + ); + } +} + +/// Section title row: optional icon, title and trailing widget. +class AppSectionHeader extends StatelessWidget { + /// Creates a section header. + const AppSectionHeader({ + required this.title, + this.icon, + this.trailing, + this.onNavy = false, + this.padding = const EdgeInsets.fromLTRB( + AppSpacing.x4l, + AppSpacing.x3l, + AppSpacing.x4l, + AppSpacing.md, + ), + super.key, + }); + + /// Title text. + final String title; + + /// Optional leading icon, drawn duotone in the accent color. + final AppIconSet? icon; + + /// Optional trailing widget (count badge, chip, hint). + final Widget? trailing; + + /// Restyle for navy surfaces. + final bool onNavy; + + /// Outer padding. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final color = onNavy ? p.onNavy : p.heading; + return Padding( + padding: padding, + child: Row( + children: [ + if (icon != null) ...[ + AppIcon( + icon!, + style: AppIconStyle.duotone, + color: onNavy ? p.onNavy : p.accent, + ), + const SizedBox(width: AppSpacing.lg), + ], + Expanded( + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMedium.copyWith(color: color), + ), + ), + ?trailing, + ], + ), + ); + } +} + +/// Titled card. The navy variant is used by the agent panel. +class AppPanel extends StatelessWidget { + /// Creates a panel. + const AppPanel({ + required this.title, + required this.child, + this.icon, + this.trailing, + this.navy = false, + super.key, + }); + + /// Panel title. + final String title; + + /// Panel body. + final Widget child; + + /// Optional header icon. + final AppIconSet? icon; + + /// Optional header trailing widget. + final Widget? trailing; + + /// Whether the panel is drawn on the navy background. + final bool navy; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return AppCard( + color: navy ? p.navy : p.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + title: title, + icon: icon, + trailing: trailing, + onNavy: navy, + ), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_chat.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_chat.dart new file mode 100644 index 0000000..2389495 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_chat.dart @@ -0,0 +1,357 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_pressable.dart'; +import 'package:flutter/material.dart'; + +/// Who wrote a chat message. +enum AppChatRole { + /// The customer: left aligned, neutral. + customer, + + /// The courier: left aligned, accent wash. + courier, + + /// The manager (the user): right aligned, navy or accent. + manager, +} + +/// Chat bubble. Customer and courier sit left, the manager sits right. +class AppChatBubble extends StatelessWidget { + /// Creates a chat bubble. + const AppChatBubble({ + required this.text, + required this.role, + this.timestamp, + this.sender, + this.onNavy = false, + this.accent = false, + super.key, + }); + + /// Message text. + final String text; + + /// Author role. + final AppChatRole role; + + /// Optional faded timestamp under the text. + final String? timestamp; + + /// Optional sender name above the text. + final String? sender; + + /// Restyle for navy surfaces (the agent panel). + final bool onNavy; + + /// Use the accent color for manager bubbles instead of navy. + final bool accent; + + static const double _smallCorner = 6; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final mine = role == AppChatRole.manager; + final Color background; + final Color foreground; + if (mine) { + background = (accent || onNavy) ? p.accent : p.navy; + foreground = onNavy || accent ? p.surface : p.onNavy; + } else if (role == AppChatRole.courier) { + background = onNavy ? p.onNavy.withValues(alpha: 0.2) : p.accentWash; + foreground = onNavy ? p.onNavy : p.accent; + } else { + background = onNavy ? p.onNavyFill : p.cardGround; + foreground = onNavy ? p.onNavy : p.ink; + } + const big = Radius.circular(20); + const small = Radius.circular(_smallCorner); + return Align( + alignment: mine ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: AppSizes.bubbleWidthFactor, + alignment: mine ? Alignment.centerRight : Alignment.centerLeft, + child: Align( + alignment: mine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl, + vertical: AppSpacing.lg, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.only( + topLeft: big, + topRight: big, + bottomLeft: mine ? big : small, + bottomRight: mine ? small : big, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (sender != null) + Text( + sender!, + style: AppTypography.chip.copyWith( + color: foreground.withValues(alpha: 0.7), + ), + ), + Text( + text, + style: AppTypography.body.copyWith( + color: foreground, + height: 1.45, + ), + ), + if (timestamp != null) + Opacity( + opacity: 0.6, + child: Text( + timestamp!, + style: AppTypography.micro.copyWith(color: foreground), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Three bouncing dots shown while someone is typing. +class AppTypingBubble extends StatefulWidget { + /// Creates a typing indicator. + const AppTypingBubble({this.onNavy = false, super.key}); + + /// Restyle for navy surfaces. + final bool onNavy; + + @override + State createState() => _AppTypingBubbleState(); +} + +class _AppTypingBubbleState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: AppMotion.typing, + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final color = widget.onNavy ? p.onNavy : p.ink; + return Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.x3l, + vertical: AppSpacing.xxl - 1, + ), + decoration: BoxDecoration( + color: widget.onNavy ? p.onNavyFill : p.cardGround, + borderRadius: BorderRadius.circular(20), + ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) ...[ + if (i > 0) const SizedBox(width: AppSpacing.xs + 1), + _dot(color, i), + ], + ], + ); + }, + ), + ), + ); + } + + Widget _dot(Color color, int index) { + // Each dot is offset by 0.15 of the period, peaking at 30% of its cycle. + final t = (_controller.value - index * 0.15) % 1.0; + final peak = t < 0.3 ? t / 0.3 : (t < 0.6 ? (0.6 - t) / 0.3 : 0.0); + return Transform.translate( + offset: Offset(0, -3 * peak), + child: Container( + width: AppSizes.typingDot, + height: AppSizes.typingDot, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.3 + 0.7 * peak), + shape: BoxShape.circle, + ), + ), + ); + } +} + +/// Pill text field with a send button. +class AppComposer extends StatefulWidget { + /// Creates a composer. + const AppComposer({ + required this.onSend, + this.hint = 'Type a message', + this.enabled = true, + this.onNavy = false, + this.compact = false, + super.key, + }); + + /// Called with the trimmed text when sent. Empty text is ignored. + final ValueChanged onSend; + + /// Placeholder text. + final String hint; + + /// Whether input is enabled. + final bool enabled; + + /// Restyle for navy surfaces. + final bool onNavy; + + /// Smaller control height. + final bool compact; + + @override + State createState() => _AppComposerState(); +} + +class _AppComposerState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit([String? _]) { + final text = _controller.text.trim(); + if (text.isEmpty || !widget.enabled) return; + widget.onSend(text); + _controller.clear(); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final height = widget.compact ? AppSizes.controlCompact : AppSizes.control; + final fg = widget.onNavy ? p.onNavy : p.ink; + final fillColor = widget.onNavy ? p.onNavyFill : p.fill; + final focus = widget.onNavy ? p.sky : p.ink; + OutlineInputBorder border(Color c, double w) => OutlineInputBorder( + borderRadius: AppRadii.pillBorder, + borderSide: BorderSide(color: c, width: w), + ); + return Row( + children: [ + Expanded( + child: SizedBox( + height: height, + child: TextField( + controller: _controller, + enabled: widget.enabled, + onSubmitted: _submit, + style: AppTypography.body.copyWith(color: fg), + cursorColor: fg, + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: AppTypography.body.copyWith( + color: widget.onNavy ? p.onNavyMuted : p.inkTertiary, + ), + filled: true, + fillColor: fillColor, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.x4l - 2, + ), + border: border(Colors.transparent, 0), + enabledBorder: border(Colors.transparent, 0), + disabledBorder: border(Colors.transparent, 0), + focusedBorder: border(focus, 2), + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + AppPressable( + onTap: widget.enabled ? _submit : null, + semanticLabel: 'Send', + child: Container( + width: height, + height: height, + decoration: BoxDecoration( + color: widget.onNavy ? p.onNavyFill : p.accent, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: AppIcon( + AppIcons.send, + style: AppIconStyle.fill, + color: widget.onNavy ? p.onNavy : p.surface, + ), + ), + ), + ], + ); + } +} + +/// Suggestion pill shown above the composer on navy. +class AppSuggestionChip extends StatelessWidget { + /// Creates a suggestion chip. + const AppSuggestionChip({ + required this.label, + required this.onTap, + this.onNavy = true, + super.key, + }); + + /// Suggestion text. + final String label; + + /// Tap callback. + final VoidCallback? onTap; + + /// Restyle for navy surfaces. + final bool onNavy; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return AppPressable( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: onNavy ? p.onNavy.withValues(alpha: 0.12) : p.fill, + borderRadius: AppRadii.pillBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.sm, + ), + child: Text( + label, + style: AppTypography.micro.copyWith( + color: onNavy ? p.onNavy : p.ink, + ), + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_chip.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_chip.dart new file mode 100644 index 0000000..ce9811c --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_chip.dart @@ -0,0 +1,293 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_tone.dart'; +import 'package:flutter/material.dart'; + +/// Small rounded label, the base of every status pill. +class AppChip extends StatelessWidget { + /// Creates a chip. + const AppChip({ + required this.label, + this.tone = AppTone.neutral, + this.icon, + this.foreground, + this.background, + super.key, + }); + + /// Text of the chip. + final String label; + + /// Semantic tone that picks the colors. + final AppTone tone; + + /// Optional leading icon. + final AppIconSet? icon; + + /// Overrides the tone's foreground. + final Color? foreground; + + /// Overrides the tone's background. + final Color? background; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final fg = foreground ?? tone.foreground(p); + return DecoratedBox( + decoration: BoxDecoration( + color: background ?? tone.background(p), + borderRadius: AppRadii.chipBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xxs + 1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + AppIcon(icon!, style: AppIconStyle.fill, size: 13, color: fg), + const SizedBox(width: AppSpacing.xs), + ], + Text(label, style: AppTypography.chip.copyWith(color: fg)), + ], + ), + ), + ); + } +} + +/// How the copilot judges a decision. +enum AppVerdict { + /// Approved as proposed. + approve, + + /// Approved with edits. + edit, + + /// Rejected. + reject, +} + +/// Chip for an [AppVerdict]. +class AppVerdictChip extends StatelessWidget { + /// Creates a verdict chip. + const AppVerdictChip({required this.verdict, this.label, super.key}); + + /// The verdict to show. + final AppVerdict verdict; + + /// Overrides the default label ("Approved", "Edited", "Rejected"). + final String? label; + + @override + Widget build(BuildContext context) { + final (tone, text) = switch (verdict) { + AppVerdict.approve => (AppTone.positive, 'Approved'), + AppVerdict.edit => (AppTone.accent, 'Edited'), + AppVerdict.reject => (AppTone.negative, 'Rejected'), + }; + return AppChip(label: label ?? text, tone: tone); + } +} + +/// Severity of an incident. +enum AppSeverity { + /// Needs action now. + high, + + /// Needs attention. + medium, + + /// Informational. + low, +} + +/// Chip for an [AppSeverity]. +class AppSeverityChip extends StatelessWidget { + /// Creates a severity chip. + const AppSeverityChip({required this.severity, this.label, super.key}); + + /// The severity to show. + final AppSeverity severity; + + /// Overrides the default label. + final String? label; + + @override + Widget build(BuildContext context) { + final (tone, text) = switch (severity) { + AppSeverity.high => (AppTone.negative, 'High'), + AppSeverity.medium => (AppTone.accent, 'Medium'), + AppSeverity.low => (AppTone.accent, 'Low'), + }; + return AppChip(label: label ?? text, tone: tone); + } +} + +/// Rounded count bubble used in column headers. +class AppCountBadge extends StatelessWidget { + /// Creates a count badge. + const AppCountBadge({required this.count, super.key}); + + /// Number shown. + final int count; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + color: p.accentWash, + borderRadius: AppRadii.pillBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xxs, + ), + child: Text( + '$count', + style: AppTypography.smallMedium.copyWith(color: p.accent), + ), + ), + ); + } +} + +/// Stage of an order on the board. +enum AppStage { + /// Customer requested it. + requested, + + /// Barista is preparing it. + barista, + + /// Courier is delivering it. + delivery, + + /// Delivered. + delivered, +} + +/// Colored dot identifying an [AppStage]. +class AppStageDot extends StatelessWidget { + /// Creates a stage dot. + const AppStageDot({required this.stage, super.key}); + + /// Stage to color the dot for. + final AppStage stage; + + /// Color of [stage]. + static Color colorOf(AppStage stage) => switch (stage) { + AppStage.requested => AppColors.stageRequested, + AppStage.barista => AppColors.stageBarista, + AppStage.delivery => AppColors.stageDelivery, + AppStage.delivered => AppColors.stageDelivered, + }; + + @override + Widget build(BuildContext context) { + return Container( + width: AppSizes.dot, + height: AppSizes.dot, + decoration: BoxDecoration(color: colorOf(stage), shape: BoxShape.circle), + ); + } +} + +/// Progress state of an [AppStepPill]. +enum AppStepState { + /// Already completed. + past, + + /// Current step. + now, + + /// Not reached yet. + upcoming, +} + +/// Numbered pill of the day tracker, shown on navy. +class AppStepPill extends StatelessWidget { + /// Creates a step pill. + const AppStepPill({ + required this.number, + required this.label, + required this.state, + super.key, + }); + + /// Step number. + final int number; + + /// Step label. + final String label; + + /// Progress state. + final AppStepState state; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final (background, foreground, badge, badgeText) = switch (state) { + AppStepState.now => (p.onNavy, p.navy, p.accent, p.onNavy), + AppStepState.past => ( + p.onNavy.withValues(alpha: 0.1), + AppColors.positiveOnNavy, + Colors.transparent, + AppColors.positiveOnNavy, + ), + AppStepState.upcoming => ( + p.onNavy.withValues(alpha: 0.1), + p.onNavyMuted, + p.onNavy.withValues(alpha: 0.16), + p.onNavyMuted, + ), + }; + return DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: AppRadii.pillBorder, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xs, + AppSpacing.xxs + 1, + AppSpacing.md, + AppSpacing.xxs + 1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: AppSizes.stepBadge, + height: AppSizes.stepBadge, + decoration: BoxDecoration(color: badge, shape: BoxShape.circle), + alignment: Alignment.center, + child: state == AppStepState.past + ? AppIcon( + AppIcons.check, + style: AppIconStyle.fill, + size: 12, + color: badgeText, + ) + : Text( + '$number', + style: AppTypography.tag.copyWith( + color: badgeText, + letterSpacing: 0, + ), + ), + ), + const SizedBox(width: AppSpacing.sm - 1), + Text(label, style: AppTypography.chip.copyWith(color: foreground)), + ], + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_decision.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_decision.dart new file mode 100644 index 0000000..c873700 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_decision.dart @@ -0,0 +1,223 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_pressable.dart'; +import 'package:app_ui/src/widgets/app_skeleton.dart'; +import 'package:flutter/material.dart'; + +/// Card the copilot uses to ask the manager for a decision. +/// +/// Compose it with [AppOptionTile]s and buttons as [children]. +class AppDecisionCard extends StatelessWidget { + /// Creates a decision card. + const AppDecisionCard({ + required this.kindLabel, + required this.title, + required this.children, + this.kindIcon, + this.trailing, + this.diagnosis, + this.urgent = false, + this.complete = false, + this.loading = false, + super.key, + }); + + /// Small label above the title (e.g. "Incident"). + final String kindLabel; + + /// Optional icon beside [kindLabel]. + final AppIconSet? kindIcon; + + /// Card title. Null while it is still arriving: see [loading]. + final String? title; + + /// Optional chip shown on the right of the header. + final Widget? trailing; + + /// Optional explanatory paragraph. Null while it is still arriving: see + /// [loading]. + final String? diagnosis; + + /// Warms the tag color and border (urgent category). + final bool urgent; + + /// Flattens the card once the decision was made. + final bool complete; + + /// Whether the card is still being written: a missing [title] or + /// [diagnosis] shows as a skeleton line instead of being left out. + final bool loading; + + /// Body widgets. + final List children; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final tagColor = urgent ? p.incident : p.accent; + return Container( + padding: const EdgeInsets.all(AppSpacing.x3l), + decoration: BoxDecoration( + color: complete ? p.cardGround : p.surface, + borderRadius: BorderRadius.circular(AppRadii.proposal), + border: Border.all( + color: complete + ? Colors.transparent + : (urgent ? p.incident.withValues(alpha: 0.35) : p.hairline), + ), + boxShadow: complete ? null : AppElevation.card, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (kindIcon != null) ...[ + AppIcon(kindIcon!, style: AppIconStyle.fill, color: tagColor), + const SizedBox(width: AppSpacing.sm), + ], + Expanded( + child: Text( + kindLabel, + style: AppTypography.smallMedium.copyWith(color: tagColor), + ), + ), + ?trailing, + ], + ), + if (title != null) ...[ + const SizedBox(height: AppSpacing.lg), + Text( + title!, + style: AppTypography.subtitle.copyWith(color: p.heading), + ), + ] else if (loading) ...[ + const SizedBox(height: AppSpacing.lg), + const AppSkeleton(height: 20), + ], + if (diagnosis != null) ...[ + const SizedBox(height: AppSpacing.lg), + Text( + diagnosis!, + style: AppTypography.body.copyWith( + fontSize: 13, + color: p.inkSecondary, + ), + ), + ] else if (loading) ...[ + const SizedBox(height: AppSpacing.lg), + const AppSkeleton(), + const SizedBox(height: AppSpacing.sm), + const FractionallySizedBox(widthFactor: 0.7, child: AppSkeleton()), + ], + for (final child in children) ...[ + const SizedBox(height: AppSpacing.lg), + child, + ], + ], + ), + ); + } +} + +/// Selectable option inside an [AppDecisionCard]. +class AppOptionTile extends StatelessWidget { + /// Creates an option tile. + const AppOptionTile({ + required this.label, + this.detail, + this.cost, + this.recommended = false, + this.recommendedLabel = 'Recommended', + this.selected = false, + this.onTap, + super.key, + }); + + /// Option title. + final String label; + + /// Optional description. + final String? detail; + + /// Optional right-aligned cost text. + final String? cost; + + /// Show the "Recommended" tag. + final bool recommended; + + /// Text of the recommended tag. + final String recommendedLabel; + + /// Whether the option is chosen. + final bool selected; + + /// Tap callback; null makes the tile read-only. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Opacity( + opacity: onTap == null && !selected ? 0.7 : 1, + child: AppPressable( + onTap: onTap, + pressedScale: 0.99, + child: AnimatedContainer( + duration: AppMotion.quick, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl, + vertical: AppSpacing.lg, + ), + decoration: BoxDecoration( + color: selected ? p.accentWash : p.cardGround, + borderRadius: BorderRadius.circular(AppRadii.option), + border: Border.all( + color: selected ? p.accent : Colors.transparent, + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recommended) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xxs), + child: Text( + recommendedLabel.toUpperCase(), + style: AppTypography.tag.copyWith(color: p.accent), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + label, + style: AppTypography.bodyMedium.copyWith(color: p.ink), + ), + ), + if (cost != null) + Text( + cost!, + style: AppTypography.small.copyWith( + color: p.inkSecondary, + ), + ), + ], + ), + if (detail != null) + Text( + detail!, + style: AppTypography.small.copyWith(color: p.inkTertiary), + ), + ], + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_dialog.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_dialog.dart new file mode 100644 index 0000000..abe7ff7 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_dialog.dart @@ -0,0 +1,381 @@ +import 'dart:ui'; + +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_button.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_tone.dart'; +import 'package:flutter/material.dart'; + +/// Shows [builder] as a modal overlay with the design system scrim. +/// +/// Set [branded] for the navy blurred scrim used by welcome and result +/// dialogs. +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool branded = true, + bool dismissible = false, +}) { + final p = context.palette; + return showGeneralDialog( + context: context, + barrierDismissible: dismissible, + barrierLabel: 'Dismiss', + barrierColor: branded ? AppElevation.brandScrim : p.scrim, + transitionDuration: AppMotion.enter, + pageBuilder: (context, _, _) => SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.x3l), + child: builder(context), + ), + ), + ), + transitionBuilder: (context, animation, _, child) { + final curved = CurvedAnimation(parent: animation, curve: AppMotion.ease); + Widget result = FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.94, end: 1).animate(curved), + child: child, + ), + ); + if (branded) { + result = BackdropFilter( + filter: ImageFilter.blur( + sigmaX: AppElevation.scrimBlur * animation.value, + sigmaY: AppElevation.scrimBlur * animation.value, + ), + child: result, + ); + } + return result; + }, + ); +} + +/// Dialog frame: rounded surface, optional navy [hero] and a padded body. +class AppDialogScaffold extends StatelessWidget { + /// Creates a dialog scaffold. + const AppDialogScaffold({ + required this.children, + this.hero, + this.maxWidth = AppSizes.dialogWidth, + this.radius = AppRadii.card, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.padding = const EdgeInsets.all(AppSpacing.x6l), + super.key, + }); + + /// Optional hero shown above the body on navy. + final Widget? hero; + + /// Body children, separated by 14px. + final List children; + + /// Maximum dialog width. + final double maxWidth; + + /// Corner radius. + final double radius; + + /// Cross axis alignment of the body. + final CrossAxisAlignment crossAxisAlignment; + + /// Body padding. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: DecoratedBox( + decoration: BoxDecoration( + color: p.surface, + borderRadius: BorderRadius.circular(radius), + boxShadow: hero == null ? AppElevation.card : AppElevation.dialog, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Material( + type: MaterialType.transparency, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?hero, + Padding( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlignment, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: AppSpacing.xxl), + children[i], + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Welcome dialog: navy hero with brand, then title, copy and actions. +class AppWelcomeDialog extends StatelessWidget { + /// Creates a welcome dialog. + const AppWelcomeDialog({ + required this.hero, + required this.title, + required this.actionLabel, + required this.onAction, + this.brand, + this.paragraphs = const [], + this.steps = const [], + this.goals = const [], + this.footnote, + super.key, + }); + + /// Hero artwork, usually an `AppUnicorn`. + final Widget hero; + + /// Brand line under the artwork. + final String? brand; + + /// Dialog headline. + final String title; + + /// Body paragraphs. + final List paragraphs; + + /// Numbered-style steps rendered as inset rows. + final List steps; + + /// Goal chips (see `AppGoalChip`). + final List goals; + + /// Muted note above the action. + final String? footnote; + + /// Label of the big action button. + final String actionLabel; + + /// Action callback. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return AppDialogScaffold( + maxWidth: AppSizes.welcomeWidth, + radius: AppRadii.dialog, + padding: const EdgeInsets.fromLTRB( + AppSpacing.x7l, + AppSpacing.x5l, + AppSpacing.x7l, + AppSpacing.x7l, + ), + hero: Container( + color: p.navy, + padding: const EdgeInsets.fromLTRB( + AppSpacing.x5l, + AppSpacing.x6l, + AppSpacing.x5l, + AppSpacing.x4l, + ), + child: Column( + children: [ + hero, + if (brand != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + brand!, + style: AppTypography.brand.copyWith( + color: p.onNavy, + fontSize: 24, + ), + ), + ], + ], + ), + ), + children: [ + Text(title, style: AppTypography.headline.copyWith(color: p.heading)), + for (final text in paragraphs) + Text( + text, + style: AppTypography.bodyLarge.copyWith(color: p.inkSecondary), + ), + if (steps.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < steps.length; i++) ...[ + if (i > 0) const SizedBox(height: AppSpacing.sm), + steps[i], + ], + ], + ), + if (goals.isNotEmpty) + Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.md, + children: goals, + ), + if (footnote != null) + Text( + footnote!, + style: AppTypography.bodyLarge.copyWith(color: p.inkTertiary), + ), + SizedBox( + width: double.infinity, + child: AppButton( + label: actionLabel, + onPressed: onAction, + size: AppButtonSize.big, + expanded: true, + ), + ), + ], + ); + } +} + +/// Inset step row for [AppWelcomeDialog.steps]. +class AppDialogStep extends StatelessWidget { + /// Creates a step row with an optional bold [strong] prefix. + const AppDialogStep({required this.text, this.strong, super.key}); + + /// Regular text. + final String text; + + /// Bold lead-in. + final String? strong; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + color: p.cardGround, + borderRadius: AppRadii.chipBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl, + vertical: AppSpacing.md, + ), + child: Text.rich( + TextSpan( + children: [ + if (strong != null) + TextSpan( + text: '$strong ', + style: TextStyle( + fontWeight: FontWeight.w600, + color: p.heading, + ), + ), + TextSpan(text: text), + ], + ), + style: AppTypography.body.copyWith(color: p.inkSecondary), + ), + ), + ); + } +} + +/// Result dialog: big icon, headline, message, check rows and actions. +class AppResultDialog extends StatelessWidget { + /// Creates a result dialog. + const AppResultDialog({ + required this.title, + required this.actionLabel, + required this.onAction, + this.won = true, + this.message, + this.checks = const [], + this.extra, + super.key, + }); + + /// Whether the outcome was a win (green check) or a loss (red cross). + final bool won; + + /// Headline. + final String title; + + /// Optional paragraph. + final String? message; + + /// Check rows, typically `AppKeyValueRow`s. + final List checks; + + /// Optional extra block under the checks (e.g. a week summary). + final Widget? extra; + + /// Primary action label. + final String actionLabel; + + /// Primary action callback. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final tone = won ? AppTone.positive : AppTone.negative; + return AppDialogScaffold( + maxWidth: AppSizes.resultWidth, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppIcon( + won ? AppIcons.trophy : AppIcons.xCircle, + style: AppIconStyle.fill, + size: AppSizes.iconHero, + color: tone.foreground(p), + ), + Text( + title, + textAlign: TextAlign.center, + style: AppTypography.headline.copyWith(color: p.heading), + ), + if (message != null) + Text( + message!, + textAlign: TextAlign.center, + style: AppTypography.body.copyWith( + height: 1.5, + color: p.inkSecondary, + ), + ), + if (checks.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < checks.length; i++) ...[ + if (i > 0) const SizedBox(height: AppSpacing.md), + checks[i], + ], + ], + ), + ?extra, + AppButton( + label: actionLabel, + onPressed: onAction, + icon: AppIcons.restart, + ), + ], + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_icons.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_icons.dart new file mode 100644 index 0000000..684a087 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_icons.dart @@ -0,0 +1,250 @@ +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Visual weights available for an [AppIcon]. +/// +/// Phosphor Icons (`phosphor_flutter` 2.1.0) does not compile on Flutter 3.47 +/// because `IconData` became a final class, so the set is backed by Material +/// icons. `fill` and `duotone` currently share the solid rounded glyph. +enum AppIconStyle { + /// Outline. + regular, + + /// Solid. + fill, + + /// Same solid glyph as [fill]; kept so a duotone set can be swapped in. + duotone, +} + +/// One icon in three weights. +@immutable +class AppIconSet { + /// Creates an icon set. + const AppIconSet(this.regular, this.fill, this.duotone); + + /// Outline glyph. + final IconData regular; + + /// Solid glyph. + final IconData fill; + + /// Duotone glyph. + final IconData duotone; + + /// Glyph for [style]. + IconData resolve(AppIconStyle style) => switch (style) { + AppIconStyle.regular => regular, + AppIconStyle.fill => fill, + AppIconStyle.duotone => duotone, + }; +} + +/// The icon vocabulary of the Coffee Manager. +abstract final class AppIcons { + /// Coffee cup. + static const coffee = AppIconSet( + Icons.coffee_outlined, + Icons.coffee_rounded, + Icons.coffee_rounded, + ); + + /// Parcel. + static const package = AppIconSet( + Icons.inventory_2_outlined, + Icons.inventory_2_rounded, + Icons.inventory_2_rounded, + ); + + /// Scooter. + static const moped = AppIconSet( + Icons.moped_outlined, + Icons.moped_rounded, + Icons.moped_rounded, + ); + + /// Map pin. + static const mapPin = AppIconSet( + Icons.location_on_outlined, + Icons.location_on_rounded, + Icons.location_on_rounded, + ); + + /// Check in a circle. + static const checkCircle = AppIconSet( + Icons.check_circle_outline_rounded, + Icons.check_circle_rounded, + Icons.check_circle_rounded, + ); + + /// Cross in a circle. + static const xCircle = AppIconSet( + Icons.cancel_outlined, + Icons.cancel_rounded, + Icons.cancel_rounded, + ); + + /// Warning triangle. + static const warning = AppIconSet( + Icons.warning_amber_rounded, + Icons.warning_rounded, + Icons.warning_rounded, + ); + + /// Star. + static const star = AppIconSet( + Icons.star_outline_rounded, + Icons.star_rounded, + Icons.star_rounded, + ); + + /// Receipt. + static const receipt = AppIconSet( + Icons.receipt_long_outlined, + Icons.receipt_long_rounded, + Icons.receipt_long_rounded, + ); + + /// Dollar coin. + static const currencyDollar = AppIconSet( + Icons.attach_money_rounded, + Icons.paid_rounded, + Icons.paid_rounded, + ); + + /// Group of people. + static const usersThree = AppIconSet( + Icons.groups_outlined, + Icons.groups_rounded, + Icons.groups_rounded, + ); + + /// Chat bubble. + static const chat = AppIconSet( + Icons.chat_bubble_outline_rounded, + Icons.chat_bubble_rounded, + Icons.chat_bubble_rounded, + ); + + /// Play. + static const play = AppIconSet( + Icons.play_arrow_rounded, + Icons.play_arrow_rounded, + Icons.play_arrow_rounded, + ); + + /// Pause. + static const pause = AppIconSet( + Icons.pause_rounded, + Icons.pause_rounded, + Icons.pause_rounded, + ); + + /// Sign out. + static const signOut = AppIconSet( + Icons.logout_rounded, + Icons.logout_rounded, + Icons.logout_rounded, + ); + + /// Restart arrow. + static const restart = AppIconSet( + Icons.restart_alt_rounded, + Icons.restart_alt_rounded, + Icons.restart_alt_rounded, + ); + + /// Lightning bolt. + static const lightning = AppIconSet( + Icons.bolt_outlined, + Icons.bolt_rounded, + Icons.bolt_rounded, + ); + + /// Wrench. + static const wrench = AppIconSet( + Icons.build_outlined, + Icons.build_rounded, + Icons.build_rounded, + ); + + /// Paper plane (send). + static const send = AppIconSet( + Icons.send_outlined, + Icons.send_rounded, + Icons.send_rounded, + ); + + /// Trophy. + static const trophy = AppIconSet( + Icons.emoji_events_outlined, + Icons.emoji_events_rounded, + Icons.emoji_events_rounded, + ); + + /// Sparkle. + static const sparkle = AppIconSet( + Icons.auto_awesome_outlined, + Icons.auto_awesome_rounded, + Icons.auto_awesome_rounded, + ); + + /// Clock. + static const clock = AppIconSet( + Icons.schedule_outlined, + Icons.schedule_rounded, + Icons.schedule_rounded, + ); + + /// Arrow pointing right. + static const arrowRight = AppIconSet( + Icons.arrow_forward_rounded, + Icons.arrow_forward_rounded, + Icons.arrow_forward_rounded, + ); + + /// Check mark. + static const check = AppIconSet( + Icons.check_rounded, + Icons.check_rounded, + Icons.check_rounded, + ); +} + +/// Renders an [AppIconSet] at a given weight. +class AppIcon extends StatelessWidget { + /// Creates an icon. + const AppIcon( + this.icon, { + this.style = AppIconStyle.regular, + this.size = AppSizes.iconSmall, + this.color, + this.semanticLabel, + super.key, + }); + + /// Which glyph. + final AppIconSet icon; + + /// Which weight. + final AppIconStyle style; + + /// Icon size. + final double size; + + /// Color; defaults to the ambient icon theme. + final Color? color; + + /// Accessibility label. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return Icon( + icon.resolve(style), + size: size, + color: color, + semanticLabel: semanticLabel, + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_order_row.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_order_row.dart new file mode 100644 index 0000000..8278431 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_order_row.dart @@ -0,0 +1,209 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_icons.dart'; +import 'package:app_ui/src/widgets/app_pressable.dart'; +import 'package:flutter/material.dart'; + +/// How urgent an order is, which drives the row's color and stripe. +enum AppUrgency { + /// Nothing special. + normal, + + /// Getting late. + warn, + + /// Late: pulses red. + urgent, + + /// Delivered successfully. + done, +} + +/// Board row for one order. Pass plain strings; map your model outside. +class AppOrderRow extends StatefulWidget { + /// Creates an order row. + const AppOrderRow({ + required this.title, + required this.subtitle, + this.trailing, + this.urgency = AppUrgency.normal, + this.selected = false, + this.unread = false, + this.onTap, + super.key, + }); + + /// Primary line, usually the customer name. + final String title; + + /// Secondary line, usually the items. + final String subtitle; + + /// Right-aligned text on the title line (e.g. a countdown). + final String? trailing; + + /// Urgency level. + final AppUrgency urgency; + + /// Whether the row shows the selection outline. + final bool selected; + + /// Whether to show the unread-message icon. + final bool unread; + + /// Tap callback. + final VoidCallback? onTap; + + @override + State createState() => _AppOrderRowState(); +} + +class _AppOrderRowState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulse = AnimationController( + vsync: this, + duration: AppMotion.urgent, + ); + + @override + void initState() { + super.initState(); + _syncPulse(); + } + + @override + void didUpdateWidget(AppOrderRow oldWidget) { + super.didUpdateWidget(oldWidget); + _syncPulse(); + } + + void _syncPulse() { + if (widget.urgency == AppUrgency.urgent) { + if (!_pulse.isAnimating) _pulse.repeat(reverse: true); + } else { + _pulse.stop(); + } + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final (background, stripe) = switch (widget.urgency) { + AppUrgency.normal => (p.cardGround, null), + AppUrgency.warn => (p.warningWash, p.warning), + AppUrgency.urgent => (p.negativeWash, p.negative), + AppUrgency.done => (p.positiveWash, p.positive), + }; + final done = widget.urgency == AppUrgency.done; + return AppPressable( + onTap: widget.onTap, + pressedScale: 0.99, + child: AnimatedBuilder( + animation: _pulse, + builder: (context, child) { + final color = widget.urgency == AppUrgency.urgent + ? Color.lerp( + background, + AppColors.negativeWashPulse, + _pulse.value, + )! + : background; + return AnimatedContainer( + duration: AppMotion.quick, + decoration: BoxDecoration( + color: color, + borderRadius: AppRadii.rowBorder, + border: Border.all( + color: widget.selected ? p.accent : Colors.transparent, + width: 2, + ), + ), + child: child, + ); + }, + child: ClipRRect( + borderRadius: AppRadii.rowBorder, + child: Stack( + children: [ + if (stripe != null) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + key: const Key('appOrderRow_stripe'), + width: AppSizes.stripe, + color: stripe, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl, + vertical: AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMedium.copyWith( + color: p.ink, + ), + ), + ), + if (widget.unread) ...[ + AppIcon( + AppIcons.chat, + style: AppIconStyle.fill, + size: 18, + color: p.accent, + semanticLabel: 'Unread message', + ), + const SizedBox(width: AppSpacing.sm), + ], + if (done) + AppIcon( + AppIcons.checkCircle, + style: AppIconStyle.fill, + size: AppSizes.iconLarge, + color: p.positive, + ) + else if (widget.trailing != null) + Text( + widget.trailing!, + style: AppTypography.mono.copyWith( + color: p.inkTertiary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xxs), + Text( + widget.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.body.copyWith( + color: done ? p.positive : p.inkSecondary, + fontWeight: done ? FontWeight.w500 : null, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_person.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_person.dart new file mode 100644 index 0000000..800452c --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_person.dart @@ -0,0 +1,131 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_progress.dart'; +import 'package:flutter/material.dart'; + +/// Round avatar showing initials. +class AppAvatar extends StatelessWidget { + /// Creates an avatar from a [name]; the first letter is shown. + const AppAvatar({required this.name, this.alert = false, super.key}); + + /// Name to take the initial from. + final String name; + + /// Paint in the negative color. + final bool alert; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase(); + return Container( + width: AppSizes.avatar, + height: AppSizes.avatar, + decoration: BoxDecoration( + color: alert ? p.negative : p.accent, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + initial, + style: AppTypography.bodyMedium.copyWith( + color: p.surface, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// Staff row: avatar, name, caption, meter and a trailing load figure. +class AppPersonRow extends StatelessWidget { + /// Creates a person row. + const AppPersonRow({ + required this.name, + required this.caption, + required this.meter, + this.trailing, + this.alert = false, + this.meterTarget, + super.key, + }); + + /// Person name. + final String name; + + /// Right-aligned caption on the name line. + final String caption; + + /// Meter fraction (0..1). + final double meter; + + /// Optional target marker on the meter. + final double? meterTarget; + + /// Trailing text (e.g. current load). + final String? trailing; + + /// Highlights a slow or struggling person. + final bool alert; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + color: alert ? p.slowWash : p.cardGround, + borderRadius: BorderRadius.circular(AppRadii.option), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.xl, + AppSpacing.md, + ), + child: Row( + children: [ + AppAvatar(name: name, alert: alert), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMedium.copyWith( + color: p.ink, + ), + ), + ), + Text( + caption, + style: AppTypography.small.copyWith( + color: p.inkTertiary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + AppProgressBar(value: meter, target: meterTarget, low: alert), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.lg), + Text( + trailing!, + style: AppTypography.mono.copyWith(color: p.inkSecondary), + ), + ], + ], + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_pressable.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_pressable.dart new file mode 100644 index 0000000..9f25bf8 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_pressable.dart @@ -0,0 +1,64 @@ +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Scale-on-press and pointer cursor wrapper used by tappable widgets. +class AppPressable extends StatefulWidget { + /// Creates a pressable area. A null [onTap] renders it inert. + const AppPressable({ + required this.child, + this.onTap, + this.semanticLabel, + this.pressedScale = 0.96, + super.key, + }); + + /// Content. + final Widget child; + + /// Called when tapped. + final VoidCallback? onTap; + + /// Optional accessibility label. + final String? semanticLabel; + + /// Scale applied while pressed. + final double pressedScale; + + @override + State createState() => _AppPressableState(); +} + +class _AppPressableState extends State { + bool _down = false; + + void _set({required bool down}) { + if (widget.onTap != null) setState(() => _down = down); + } + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: widget.onTap != null, + label: widget.semanticLabel, + child: MouseRegion( + cursor: widget.onTap == null + ? MouseCursor.defer + : SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + onTapDown: (_) => _set(down: true), + onTapUp: (_) => _set(down: false), + onTapCancel: () => _set(down: false), + child: AnimatedScale( + scale: _down ? widget.pressedScale : 1, + duration: AppMotion.press, + curve: AppMotion.ease, + child: widget.child, + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_progress.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_progress.dart new file mode 100644 index 0000000..7a3fdf3 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_progress.dart @@ -0,0 +1,81 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Thin progress bar with an optional target marker. +class AppProgressBar extends StatelessWidget { + /// Creates a progress bar. [value] and [target] are fractions in 0..1. + const AppProgressBar({ + required this.value, + this.target, + this.onNavy = false, + this.low = false, + super.key, + }); + + /// Filled fraction. + final double value; + + /// Optional target marker position. + final double? target; + + /// Restyle for navy surfaces. + final bool onNavy; + + /// Paint the fill in the negative color (a courier below target). + final bool low; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final track = onNavy ? p.onNavy.withValues(alpha: 0.16) : p.fill; + final fill = low ? p.negative : (onNavy ? p.sky : p.accent); + final marker = onNavy ? p.onNavy : p.heading.withValues(alpha: 0.5); + return SizedBox( + height: AppSizes.barMarkerHeight, + child: LayoutBuilder( + builder: (context, box) { + final width = box.maxWidth; + final clamped = value.clamp(0.0, 1.0); + return Stack( + alignment: Alignment.centerLeft, + children: [ + Container( + height: AppSizes.bar, + decoration: BoxDecoration( + color: track, + borderRadius: AppRadii.pillBorder, + ), + ), + AnimatedContainer( + duration: AppMotion.progress, + curve: AppMotion.ease, + height: AppSizes.bar, + width: width * clamped, + decoration: BoxDecoration( + color: fill, + borderRadius: AppRadii.pillBorder, + ), + ), + if (target != null) + Positioned( + left: + (width * target!.clamp(0.0, 1.0)) - + AppSizes.barMarker / 2, + child: Container( + key: const Key('appProgressBar_target'), + width: AppSizes.barMarker, + height: AppSizes.barMarkerHeight, + decoration: BoxDecoration( + color: marker, + borderRadius: BorderRadius.circular(AppSizes.barMarker), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_segmented.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_segmented.dart new file mode 100644 index 0000000..b37cba9 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_segmented.dart @@ -0,0 +1,113 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_button.dart'; +import 'package:app_ui/src/widgets/app_pressable.dart'; +import 'package:flutter/material.dart'; + +/// Pill-shaped segmented control (speed, difficulty, chat target...). +class AppSegmented extends StatelessWidget { + /// Creates a segmented control. + const AppSegmented({ + required this.segments, + required this.value, + required this.onChanged, + this.onNavy = false, + this.compact = false, + super.key, + }); + + /// Selectable values mapped to their labels, in display order. + final Map segments; + + /// Currently selected value. + final T value; + + /// Called with the tapped value; null disables the control. + final ValueChanged? onChanged; + + /// Restyle for navy surfaces. + final bool onNavy; + + /// Tighter padding for mini panels. + final bool compact; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return AppOpacityWhen( + dim: onChanged == null, + opacity: 0.4, + child: Container( + padding: const EdgeInsets.all(AppSpacing.xxs + 1), + decoration: BoxDecoration( + color: onNavy ? p.onNavyFill : p.fill, + borderRadius: AppRadii.pillBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final entry in segments.entries) + _Segment( + label: entry.value, + selected: entry.key == value, + onNavy: onNavy, + compact: compact, + onTap: onChanged == null ? null : () => onChanged!(entry.key), + ), + ], + ), + ), + ); + } +} + +class _Segment extends StatelessWidget { + const _Segment({ + required this.label, + required this.selected, + required this.onNavy, + required this.compact, + required this.onTap, + }); + + final String label; + final bool selected; + final bool onNavy; + final bool compact; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final Color background; + final Color foreground; + if (selected) { + background = onNavy ? p.onNavy : p.surface; + foreground = onNavy ? p.navy : p.ink; + } else { + background = Colors.transparent; + foreground = onNavy ? p.onNavy.withValues(alpha: 0.78) : p.inkSecondary; + } + return AppPressable( + onTap: onTap, + pressedScale: 0.97, + child: AnimatedContainer( + duration: AppMotion.quick, + padding: EdgeInsets.symmetric( + horizontal: compact ? AppSpacing.lg : AppSpacing.xxl, + vertical: compact ? AppSpacing.sm - 1 : AppSpacing.md, + ), + decoration: BoxDecoration( + color: background, + borderRadius: AppRadii.pillBorder, + boxShadow: selected && !onNavy ? AppElevation.segment : null, + ), + child: Text( + label, + style: (compact ? AppTypography.chip : AppTypography.smallMedium) + .copyWith(color: foreground), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_skeleton.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_skeleton.dart new file mode 100644 index 0000000..fc04d6e --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_skeleton.dart @@ -0,0 +1,76 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Pulsing placeholder line shown while the agent is thinking. +class AppSkeleton extends StatefulWidget { + /// Creates a skeleton line of fixed [width], or full width when null. + const AppSkeleton({this.width, this.height = 12, super.key}); + + /// Width in logical pixels, or null to fill the parent. + final double? width; + + /// Line height. + final double height; + + @override + State createState() => _AppSkeletonState(); +} + +class _AppSkeletonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: AppMotion.pulse, + )..repeat(reverse: true); + + static const double _minOpacity = 0.4; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + return FadeTransition( + opacity: Tween(begin: 1, end: _minOpacity).animate(_controller), + child: Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: p.fill, + borderRadius: BorderRadius.circular(AppRadii.tiny), + ), + ), + ); + } +} + +/// Stack of skeleton lines (a "40px / 60% / 90%" loading block). +class AppSkeletonBlock extends StatelessWidget { + /// Creates a block of skeleton lines. + const AppSkeletonBlock({this.fractions = const [0.4, 0.9, 0.6], super.key}); + + /// Width of each line as a fraction of the available width. + final List fractions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final f in fractions) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: FractionallySizedBox( + widthFactor: f, + child: const AppSkeleton(), + ), + ), + ], + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_stat.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_stat.dart new file mode 100644 index 0000000..a5d8b8a --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_stat.dart @@ -0,0 +1,204 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:app_ui/src/widgets/app_progress.dart'; +import 'package:app_ui/src/widgets/app_tone.dart'; +import 'package:flutter/material.dart'; + +/// Score tile: caption, big value, optional suffix and progress bar. +class AppStatTile extends StatelessWidget { + /// Creates a stat tile. + const AppStatTile({ + required this.label, + required this.value, + this.suffix, + this.tone = AppTone.neutral, + this.progress, + this.target, + this.onNavy = true, + this.mono = true, + super.key, + }); + + /// Caption above the value. + final String label; + + /// Main value text. + final String value; + + /// Muted text after the value (e.g. "/ 30"). + final String? suffix; + + /// Tone of the value. Neutral uses the surface's primary color. + final AppTone tone; + + /// Optional progress fraction (0..1) drawn below. + final double? progress; + + /// Optional target marker fraction (0..1). + final double? target; + + /// Whether the tile sits on navy (default) or on a light surface. + final bool onNavy; + + /// Whether the value uses tabular monospace figures. + final bool mono; + + Color _valueColor(AppPalette p) { + if (onNavy) { + return switch (tone) { + AppTone.neutral => p.onNavy, + AppTone.accent => p.sky, + AppTone.positive => AppColors.positiveOnNavy, + AppTone.warning => AppColors.warningOnNavy, + AppTone.negative => AppColors.negativeOnNavy, + }; + } + return tone == AppTone.neutral ? p.heading : tone.foreground(p); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final muted = onNavy ? p.onNavyMuted : p.inkTertiary; + final valueStyle = + (mono + ? AppTypography.mono.copyWith( + fontSize: AppTypography.big.fontSize, + fontWeight: FontWeight.w500, + letterSpacing: AppTypography.big.letterSpacing, + ) + : AppTypography.big) + .copyWith(color: _valueColor(p)); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: AppTypography.small.copyWith(color: muted)), + const SizedBox(height: AppSpacing.xs), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: valueStyle), + if (suffix != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text(suffix!, style: AppTypography.mono.copyWith(color: muted)), + ], + ], + ), + if (progress != null) ...[ + const SizedBox(height: AppSpacing.xs), + AppProgressBar(value: progress!, target: target, onNavy: onNavy), + ], + ], + ); + } +} + +/// Goal chip: a bold figure with a label, on an accent wash. +class AppGoalChip extends StatelessWidget { + /// Creates a goal chip, e.g. `AppGoalChip(strong: '30', label: 'orders')`. + const AppGoalChip({required this.strong, required this.label, super.key}); + + /// Bold part. + final String strong; + + /// Regular part. + final String label; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return DecoratedBox( + decoration: BoxDecoration( + color: p.accentWash, + borderRadius: AppRadii.pillBorder, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl, + vertical: AppSpacing.md, + ), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: strong, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + TextSpan(text: ' $label'), + ], + ), + style: AppTypography.body.copyWith(color: p.accent, fontSize: 13), + ), + ), + ); + } +} + +/// Label on the left, value on the right, on an inset rounded row. +class AppKeyValueRow extends StatelessWidget { + /// Creates a key-value row. + const AppKeyValueRow({ + required this.label, + required this.value, + this.valueTone = AppTone.neutral, + this.inset = true, + this.mono = false, + super.key, + }); + + /// Left text. + final String label; + + /// Right text. + final String value; + + /// Tone of the value. + final AppTone valueTone; + + /// Whether the row has the inset card-ground background. + final bool inset; + + /// Whether the value uses tabular monospace figures. + final bool mono; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final valueColor = valueTone == AppTone.neutral + ? p.ink + : valueTone.foreground(p); + final base = mono + ? AppTypography.mono.copyWith(fontSize: 14) + : AppTypography.bodyMedium; + return DecoratedBox( + decoration: BoxDecoration( + color: inset ? p.cardGround : null, + borderRadius: AppRadii.rowBorder, + ), + child: Padding( + padding: inset + ? const EdgeInsets.symmetric( + horizontal: AppSpacing.x3l, + vertical: AppSpacing.xl, + ) + : const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + label, + style: AppTypography.body.copyWith(color: p.inkSecondary), + ), + ), + const SizedBox(width: AppSpacing.md), + Text(value, style: base.copyWith(color: valueColor)), + ], + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_toast.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_toast.dart new file mode 100644 index 0000000..9c38d00 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_toast.dart @@ -0,0 +1,49 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:app_ui/src/tokens/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Floating pill banner, e.g. "Day 2 begins". +class AppToast extends StatelessWidget { + /// Creates a toast. + const AppToast({required this.message, super.key}); + + /// Message shown. + final String message; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: AppMotion.enter, + curve: AppMotion.ease, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * AppSpacing.sm), + child: child, + ), + ), + child: Semantics( + liveRegion: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: p.surface, + borderRadius: AppRadii.pillBorder, + boxShadow: AppElevation.card, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.x4l, + vertical: AppSpacing.md, + ), + child: Text( + message, + style: AppTypography.bodyMedium.copyWith(color: p.heading), + ), + ), + ), + ), + ); + } +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/app_tone.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/app_tone.dart new file mode 100644 index 0000000..31fc007 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/app_tone.dart @@ -0,0 +1,41 @@ +import 'package:app_ui/src/theme/app_palette.dart'; +import 'package:flutter/material.dart'; + +/// Semantic emphasis shared by chips, tiles and banners. +enum AppTone { + /// Quiet gray. + neutral, + + /// Brand blue. + accent, + + /// Success green. + positive, + + /// Warning amber. + warning, + + /// Error red. + negative, +} + +/// Resolves [AppTone] into concrete colors. +extension AppToneColors on AppTone { + /// Foreground (text and icon) color of the tone. + Color foreground(AppPalette p) => switch (this) { + AppTone.neutral => p.inkSecondary, + AppTone.accent => p.accent, + AppTone.positive => p.positive, + AppTone.warning => p.incident, + AppTone.negative => p.negative, + }; + + /// Background wash of the tone. + Color background(AppPalette p) => switch (this) { + AppTone.neutral => p.fill, + AppTone.accent => p.accentWash, + AppTone.positive => p.positive.withValues(alpha: 0.14), + AppTone.warning => p.warningWash, + AppTone.negative => p.negative.withValues(alpha: 0.12), + }; +} diff --git a/extras/custom-server/shared/app_ui/lib/src/widgets/widgets.dart b/extras/custom-server/shared/app_ui/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..d861300 --- /dev/null +++ b/extras/custom-server/shared/app_ui/lib/src/widgets/widgets.dart @@ -0,0 +1,17 @@ +export 'app_brand.dart'; +export 'app_button.dart'; +export 'app_card.dart'; +export 'app_chat.dart'; +export 'app_chip.dart'; +export 'app_decision.dart'; +export 'app_dialog.dart'; +export 'app_icons.dart'; +export 'app_order_row.dart'; +export 'app_person.dart'; +export 'app_pressable.dart'; +export 'app_progress.dart'; +export 'app_segmented.dart'; +export 'app_skeleton.dart'; +export 'app_stat.dart'; +export 'app_toast.dart'; +export 'app_tone.dart'; diff --git a/extras/custom-server/shared/app_ui/pubspec.yaml b/extras/custom-server/shared/app_ui/pubspec.yaml new file mode 100644 index 0000000..1efdd04 --- /dev/null +++ b/extras/custom-server/shared/app_ui/pubspec.yaml @@ -0,0 +1,36 @@ +name: app_ui +description: Design system for the Calaverita Builder (tokens, themes and widgets from the CopilotKit demo) +version: 0.1.0+1 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.13.0 + flutter: ^3.47.0 + +dependencies: + flutter: + sdk: flutter + flutter_svg: ^2.2.4 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^11.0.0 + +flutter: + assets: + - assets/images/ + fonts: + - family: Poppins + fonts: + - asset: assets/fonts/Poppins-Regular.ttf + - asset: assets/fonts/Poppins-Medium.ttf + weight: 500 + - asset: assets/fonts/Poppins-SemiBold.ttf + weight: 600 + - family: JetBrainsMono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf + - asset: assets/fonts/JetBrainsMono-Medium.ttf + weight: 500 diff --git a/extras/custom-server/shared/app_ui/test/helpers/helpers.dart b/extras/custom-server/shared/app_ui/test/helpers/helpers.dart new file mode 100644 index 0000000..b15fe65 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/helpers/helpers.dart @@ -0,0 +1 @@ +export 'pump_app.dart'; diff --git a/extras/custom-server/shared/app_ui/test/helpers/pump_app.dart b/extras/custom-server/shared/app_ui/test/helpers/pump_app.dart new file mode 100644 index 0000000..c4c4f45 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/helpers/pump_app.dart @@ -0,0 +1,25 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension PumpApp on WidgetTester { + /// Pumps [widget] inside an [AppTheme] material app. + Future pumpApp( + Widget widget, { + bool dark = false, + bool settle = true, + }) async { + await pumpWidget( + MaterialApp( + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: dark ? ThemeMode.dark : ThemeMode.light, + home: Scaffold(body: Center(child: widget)), + ), + ); + if (settle) { + await pump(const Duration(milliseconds: 500)); + } else { + await pump(); + } + } +} diff --git a/extras/custom-server/shared/app_ui/test/src/theme/app_theme_test.dart b/extras/custom-server/shared/app_ui/test/src/theme/app_theme_test.dart new file mode 100644 index 0000000..ef84ffb --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/theme/app_theme_test.dart @@ -0,0 +1,80 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppTheme', () { + test('light is a Material 3 light theme with the palette', () { + final theme = AppTheme.light; + expect(theme.useMaterial3, isTrue); + expect(theme.brightness, Brightness.light); + expect(theme.colorScheme.primary, AppColors.accent); + expect(theme.scaffoldBackgroundColor, AppColors.ground); + expect(theme.extension(), isNotNull); + expect(theme.textTheme.bodyMedium?.fontFamily, AppTypography.fontFamily); + }); + + test('dark is a Material 3 dark theme with the dark palette', () { + final theme = AppTheme.dark; + expect(theme.brightness, Brightness.dark); + expect(theme.scaffoldBackgroundColor, AppColors.darkGround); + expect(theme.colorScheme.primary, AppColors.darkAccent); + }); + }); + + group('AppPalette', () { + test('lerp blends two palettes and ignores foreign extensions', () { + const a = AppPalette.light(); + const b = AppPalette.dark(); + expect(a.lerp(b, 0).ground, a.ground); + expect(a.lerp(b, 1).ground, b.ground); + expect(a.lerp(null, 0.5), same(a)); + }); + + test('copyWith overrides selected roles', () { + const a = AppPalette.light(); + final b = a.copyWith( + ground: AppColors.navy, + cardGround: AppColors.navy, + surface: AppColors.navy, + navy: AppColors.ground, + accent: AppColors.negative, + ink: AppColors.negative, + ); + expect(b.ground, AppColors.navy); + expect(b.accent, AppColors.negative); + expect(b.copyWith().navy, b.navy); + }); + + testWidgets('context.palette falls back to light without a theme ' + 'extension', (tester) async { + late AppPalette palette; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + palette = context.palette; + return const SizedBox(); + }, + ), + ), + ); + expect(palette.ground, AppColors.ground); + }); + + testWidgets('context.palette reads the dark theme', (tester) async { + late AppPalette palette; + await tester.pumpApp( + Builder( + builder: (context) { + palette = context.palette; + return const SizedBox(); + }, + ), + dark: true, + ); + expect(palette.ground, AppColors.darkGround); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/tokens/tokens_test.dart b/extras/custom-server/shared/app_ui/test/src/tokens/tokens_test.dart new file mode 100644 index 0000000..aa8d2a4 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/tokens/tokens_test.dart @@ -0,0 +1,55 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('tokens', () { + test('spacing scale is ascending', () { + final scale = [ + AppSpacing.xxs, + AppSpacing.xs, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.xxl, + AppSpacing.x3l, + AppSpacing.x4l, + AppSpacing.x5l, + AppSpacing.x6l, + AppSpacing.x7l, + ]; + expect([...scale]..sort(), scale); + }); + + test('radii borders match their radius', () { + expect(AppRadii.cardBorder.topLeft.x, AppRadii.card); + expect(AppRadii.rowBorder.topLeft.x, AppRadii.row); + expect(AppRadii.chipBorder.topLeft.x, AppRadii.chip); + expect(AppRadii.pillBorder.topLeft.x, AppRadii.pill); + }); + + test('ease curve starts at 0 and ends at 1', () { + expect(AppMotion.ease.transform(0), 0); + expect(AppMotion.ease.transform(1), closeTo(1, 1e-6)); + }); + + test('typography uses Poppins and mono uses tabular figures', () { + expect(AppTypography.body.fontFamily, AppTypography.fontFamily); + expect(AppTypography.mono.fontFeatures, isNotEmpty); + expect(AppTypography.mono.fontFamilyFallback, AppTypography.monoFallback); + }); + + test('elevation tokens are non-empty and sizes are positive', () { + expect(AppElevation.card, isNotEmpty); + expect(AppElevation.dialog, isNotEmpty); + expect(AppElevation.segment, isNotEmpty); + expect(AppElevation.iconButton, isNotEmpty); + expect(AppSizes.control, greaterThan(0)); + }); + + test('stage colors are distinct', () { + final colors = AppStage.values.map(AppStageDot.colorOf).toSet(); + expect(colors, hasLength(AppStage.values.length)); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_button_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_button_test.dart new file mode 100644 index 0000000..54fa769 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_button_test.dart @@ -0,0 +1,130 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppButton', () { + testWidgets('renders label and fires onPressed', (tester) async { + var taps = 0; + await tester.pumpApp(AppButton(label: 'Start', onPressed: () => taps++)); + expect(find.text('Start'), findsOneWidget); + await tester.tap(find.byType(AppButton)); + await tester.pump(const Duration(milliseconds: 200)); + expect(taps, 1); + }); + + testWidgets('is dimmed and inert when disabled', (tester) async { + await tester.pumpApp(const AppButton.ghost(label: 'No', onPressed: null)); + final opacity = tester.widget(find.byType(Opacity).first); + expect(opacity.opacity, lessThan(1)); + }); + + testWidgets('shows icon and supports every size / variant', (tester) async { + for (final size in AppButtonSize.values) { + for (final onNavy in [false, true]) { + await tester.pumpApp( + Column( + children: [ + AppButton.primary( + label: 'P', + onPressed: () {}, + icon: AppIcons.play, + size: size, + onNavy: onNavy, + expanded: true, + ), + AppButton.ghost( + label: 'G', + onPressed: () {}, + size: size, + onNavy: onNavy, + ), + ], + ), + ); + expect(find.byType(AppIcon), findsOneWidget); + } + } + }); + + testWidgets('press scales down and releases', (tester) async { + await tester.pumpApp(AppButton(label: 'Go', onPressed: () {})); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(AppButton)), + ); + await tester.pump(const Duration(milliseconds: 200)); + expect( + tester.widget(find.byType(AnimatedScale)).scale, + lessThan(1), + ); + await gesture.cancel(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(find.byType(AnimatedScale)).scale, 1); + }); + }); + + group('AppIconButton', () { + testWidgets('fires onPressed in all variants', (tester) async { + var taps = 0; + for (final primary in [false, true]) { + for (final onNavy in [false, true]) { + await tester.pumpApp( + AppIconButton( + icon: AppIcons.pause, + semanticLabel: 'Pause', + onPressed: () => taps++, + primary: primary, + onNavy: onNavy, + ), + ); + await tester.tap(find.byType(AppIconButton)); + } + } + expect(taps, 4); + }); + + testWidgets('is dimmed when disabled', (tester) async { + await tester.pumpApp( + const AppIconButton( + icon: AppIcons.pause, + semanticLabel: 'Pause', + onPressed: null, + ), + ); + expect( + tester.widget(find.byType(Opacity).first).opacity, + lessThan(1), + ); + }); + }); + + group('AppSegmented', () { + testWidgets('reports taps and highlights selection', (tester) async { + String? picked; + await tester.pumpApp( + AppSegmented( + segments: const {'a': 'Slow', 'b': 'Fast'}, + value: 'a', + onChanged: (v) => picked = v, + ), + ); + await tester.tap(find.text('Fast')); + expect(picked, 'b'); + }); + + testWidgets('supports navy, compact and disabled', (tester) async { + await tester.pumpApp( + const AppSegmented( + segments: {1: 'One', 2: 'Two'}, + value: 2, + onChanged: null, + onNavy: true, + compact: true, + ), + ); + expect(find.text('One'), findsOneWidget); + await tester.tap(find.text('One')); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_card_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_card_test.dart new file mode 100644 index 0000000..678ddc8 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_card_test.dart @@ -0,0 +1,65 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppCard', () { + testWidgets('renders child with and without shadow/clip', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppCard(child: Text('a')), + AppCard(shadow: false, clip: false, child: Text('b')), + ], + ), + ); + expect(find.text('a'), findsOneWidget); + expect(find.text('b'), findsOneWidget); + }); + }); + + group('AppSectionHeader', () { + testWidgets('shows title, icon and trailing', (tester) async { + await tester.pumpApp( + const AppSectionHeader( + title: 'Requested', + icon: AppIcons.coffee, + trailing: AppCountBadge(count: 2), + ), + ); + expect(find.text('Requested'), findsOneWidget); + expect(find.byType(AppIcon), findsOneWidget); + expect(find.byType(AppCountBadge), findsOneWidget); + }); + + testWidgets('works on navy without icon', (tester) async { + await tester.pumpApp( + const AppSectionHeader(title: 'Copilot', onNavy: true), + ); + expect(find.byType(AppIcon), findsNothing); + }); + }); + + group('AppPanel', () { + testWidgets('renders a titled body in both styles', (tester) async { + for (final navy in [false, true]) { + await tester.pumpApp( + SizedBox( + height: 200, + width: 300, + child: AppPanel( + title: 'Panel', + icon: AppIcons.usersThree, + trailing: const AppChip(label: 'x'), + navy: navy, + child: const Text('body'), + ), + ), + ); + expect(find.text('Panel'), findsOneWidget); + expect(find.text('body'), findsOneWidget); + } + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_chat_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_chat_test.dart new file mode 100644 index 0000000..81eb3df --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_chat_test.dart @@ -0,0 +1,126 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppChatBubble', () { + testWidgets('renders text, sender and timestamp', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 400, + child: AppChatBubble( + text: 'Where is my latte?', + role: AppChatRole.customer, + sender: 'Ana', + timestamp: '09:12', + ), + ), + ); + expect(find.text('Where is my latte?'), findsOneWidget); + expect(find.text('Ana'), findsOneWidget); + expect(find.text('09:12'), findsOneWidget); + }); + + testWidgets('renders every role on light and navy', (tester) async { + for (final onNavy in [false, true]) { + for (final accent in [false, true]) { + await tester.pumpApp( + SizedBox( + width: 400, + child: Column( + children: [ + for (final role in AppChatRole.values) + AppChatBubble( + text: role.name, + role: role, + onNavy: onNavy, + accent: accent, + ), + ], + ), + ), + ); + expect(find.byType(AppChatBubble), findsNWidgets(3)); + } + } + }); + }); + + group('AppTypingBubble', () { + testWidgets('animates three dots', (tester) async { + for (final onNavy in [false, true]) { + await tester.pumpApp(AppTypingBubble(onNavy: onNavy), settle: false); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(find.byType(AppTypingBubble), findsOneWidget); + } + }); + }); + + group('AppComposer', () { + testWidgets('sends trimmed text on tap and clears the field', ( + tester, + ) async { + final sent = []; + await tester.pumpApp( + SizedBox(width: 400, child: AppComposer(onSend: sent.add)), + ); + await tester.enterText(find.byType(TextField), ' hello '); + await tester.tap(find.bySemanticsLabel('Send')); + await tester.pump(); + expect(sent, ['hello']); + expect( + tester.widget(find.byType(TextField)).controller!.text, + isEmpty, + ); + }); + + testWidgets('sends on keyboard submit and ignores empty text', ( + tester, + ) async { + final sent = []; + await tester.pumpApp( + SizedBox( + width: 400, + child: AppComposer(onSend: sent.add, onNavy: true, compact: true), + ), + ); + await tester.tap(find.bySemanticsLabel('Send')); + expect(sent, isEmpty); + await tester.enterText(find.byType(TextField), 'go'); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(sent, ['go']); + }); + + testWidgets('does nothing when disabled', (tester) async { + final sent = []; + await tester.pumpApp( + SizedBox( + width: 400, + child: AppComposer(onSend: sent.add, enabled: false), + ), + ); + await tester.tap(find.bySemanticsLabel('Send')); + expect(sent, isEmpty); + }); + }); + + group('AppSuggestionChip', () { + testWidgets('fires onTap on navy and light', (tester) async { + var taps = 0; + for (final onNavy in [true, false]) { + await tester.pumpApp( + AppSuggestionChip( + label: 'Who is slow?', + onTap: () => taps++, + onNavy: onNavy, + ), + ); + await tester.tap(find.text('Who is slow?')); + } + expect(taps, 2); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_chip_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_chip_test.dart new file mode 100644 index 0000000..6625db9 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_chip_test.dart @@ -0,0 +1,94 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppChip', () { + testWidgets('renders every tone with and without icon', (tester) async { + await tester.pumpApp( + Column( + children: [ + for (final tone in AppTone.values) + AppChip(label: tone.name, tone: tone), + const AppChip(label: 'icon', icon: AppIcons.coffee), + const AppChip( + label: 'custom', + foreground: AppColors.navy, + background: AppColors.ground, + ), + ], + ), + ); + expect(find.byType(AppChip), findsNWidgets(AppTone.values.length + 2)); + expect(find.byType(AppIcon), findsOneWidget); + }); + }); + + group('AppVerdictChip', () { + testWidgets('shows default labels and accepts overrides', (tester) async { + await tester.pumpApp( + Column( + children: [ + for (final v in AppVerdict.values) AppVerdictChip(verdict: v), + const AppVerdictChip(verdict: AppVerdict.approve, label: 'Yes'), + ], + ), + ); + expect(find.text('Approved'), findsOneWidget); + expect(find.text('Edited'), findsOneWidget); + expect(find.text('Rejected'), findsOneWidget); + expect(find.text('Yes'), findsOneWidget); + }); + }); + + group('AppSeverityChip', () { + testWidgets('shows default labels and accepts overrides', (tester) async { + await tester.pumpApp( + Column( + children: [ + for (final s in AppSeverity.values) AppSeverityChip(severity: s), + const AppSeverityChip(severity: AppSeverity.low, label: 'Meh'), + ], + ), + ); + expect(find.text('High'), findsOneWidget); + expect(find.text('Medium'), findsOneWidget); + expect(find.text('Low'), findsOneWidget); + expect(find.text('Meh'), findsOneWidget); + }); + }); + + testWidgets('AppCountBadge shows the count', (tester) async { + await tester.pumpApp(const AppCountBadge(count: 7)); + expect(find.text('7'), findsOneWidget); + }); + + testWidgets('AppStageDot paints the stage color', (tester) async { + await tester.pumpApp( + Column( + children: [for (final s in AppStage.values) AppStageDot(stage: s)], + ), + ); + expect(find.byType(AppStageDot), findsNWidgets(4)); + }); + + group('AppStepPill', () { + testWidgets('renders the three states', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppStepPill(number: 1, label: 'Past', state: AppStepState.past), + AppStepPill(number: 2, label: 'Now', state: AppStepState.now), + AppStepPill(number: 3, label: 'Next', state: AppStepState.upcoming), + ], + ), + ); + expect(find.text('Past'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('1'), findsNothing); + expect(find.byType(AppIcon), findsOneWidget); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_decision_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_decision_test.dart new file mode 100644 index 0000000..70d9530 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_decision_test.dart @@ -0,0 +1,123 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppDecisionCard', () { + testWidgets('renders header, diagnosis and children', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 360, + child: AppDecisionCard( + kindLabel: 'Incident', + kindIcon: AppIcons.warning, + title: 'Machine down', + diagnosis: 'Grinder jammed.', + trailing: AppSeverityChip(severity: AppSeverity.high), + urgent: true, + children: [Text('child')], + ), + ), + ); + expect(find.text('Incident'), findsOneWidget); + expect(find.text('Machine down'), findsOneWidget); + expect(find.text('Grinder jammed.'), findsOneWidget); + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('renders minimal and complete states', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 360, + child: AppDecisionCard( + kindLabel: 'Systemic', + title: 'T', + complete: true, + children: [], + ), + ), + ); + expect(find.text('Systemic'), findsOneWidget); + }); + }); + + group('AppDecisionCard loading', () { + testWidgets('draws skeletons for a missing title and diagnosis', ( + tester, + ) async { + await tester.pumpApp( + const SizedBox( + width: 360, + child: AppDecisionCard( + kindLabel: 'Proposal', + title: null, + loading: true, + children: [], + ), + ), + ); + expect(find.byType(AppSkeleton), findsNWidgets(3)); + }); + + testWidgets('keeps the arrived text and skeletons only the rest', ( + tester, + ) async { + await tester.pumpApp( + const SizedBox( + width: 360, + child: AppDecisionCard( + kindLabel: 'Proposal', + title: 'Machine down', + loading: true, + children: [], + ), + ), + ); + expect(find.text('Machine down'), findsOneWidget); + expect(find.byType(AppSkeleton), findsNWidgets(2)); + }); + + testWidgets('leaves missing text out when not loading', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 360, + child: AppDecisionCard( + kindLabel: 'Proposal', + title: null, + children: [], + ), + ), + ); + expect(find.byType(AppSkeleton), findsNothing); + }); + }); + + group('AppOptionTile', () { + testWidgets('shows details and handles taps', (tester) async { + var taps = 0; + await tester.pumpApp( + SizedBox( + width: 360, + child: AppOptionTile( + label: 'Add courier', + detail: 'Faster', + cost: r'$12', + recommended: true, + selected: true, + onTap: () => taps++, + ), + ), + ); + expect(find.text('RECOMMENDED'), findsOneWidget); + expect(find.text(r'$12'), findsOneWidget); + await tester.tap(find.byType(AppOptionTile)); + expect(taps, 1); + }); + + testWidgets('is dimmed when read-only and unselected', (tester) async { + await tester.pumpApp(const AppOptionTile(label: 'Skip')); + expect(tester.widget(find.byType(Opacity).first).opacity, 0.7); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_dialog_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_dialog_test.dart new file mode 100644 index 0000000..4ff0d4f --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_dialog_test.dart @@ -0,0 +1,145 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppDialogScaffold', () { + testWidgets('renders hero and children', (tester) async { + await tester.pumpApp( + const AppDialogScaffold( + hero: Text('hero'), + children: [Text('one'), Text('two')], + ), + ); + expect(find.text('hero'), findsOneWidget); + expect(find.text('one'), findsOneWidget); + expect(find.text('two'), findsOneWidget); + }); + + testWidgets('renders without hero', (tester) async { + await tester.pumpApp(const AppDialogScaffold(children: [Text('one')])); + expect(find.text('one'), findsOneWidget); + }); + }); + + group('AppWelcomeDialog', () { + testWidgets('renders all sections and fires the action', (tester) async { + var taps = 0; + tester.view.physicalSize = const Size(900, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpApp( + AppWelcomeDialog( + hero: const AppUnicorn(size: 80), + brand: 'Very Good Coffee', + title: 'Welcome', + paragraphs: const ['Run the shop.'], + steps: const [AppDialogStep(strong: '1.', text: 'Watch')], + goals: const [AppGoalChip(strong: '30', label: 'orders')], + footnote: 'Good luck', + actionLabel: 'Start', + onAction: () => taps++, + ), + ); + expect(find.text('Welcome'), findsOneWidget); + expect(find.text('Very Good Coffee'), findsOneWidget); + expect(find.text('Run the shop.'), findsOneWidget); + expect(find.text('Good luck'), findsOneWidget); + await tester.tap(find.text('Start')); + expect(taps, 1); + }); + + testWidgets('renders with only required parts', (tester) async { + await tester.pumpApp( + AppWelcomeDialog( + hero: const SizedBox(), + title: 'Hi', + actionLabel: 'Go', + onAction: () {}, + ), + ); + expect(find.text('Hi'), findsOneWidget); + }); + }); + + group('AppDialogStep', () { + testWidgets('renders with and without a bold lead-in', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppDialogStep(strong: 'Bold', text: 'rest'), + AppDialogStep(text: 'plain'), + ], + ), + ); + expect(find.textContaining('rest', findRichText: true), findsOneWidget); + expect(find.textContaining('plain', findRichText: true), findsOneWidget); + }); + }); + + group('AppResultDialog', () { + testWidgets('won state shows a trophy and fires the action', ( + tester, + ) async { + var taps = 0; + await tester.pumpApp( + AppResultDialog( + title: 'You won', + message: 'Nice', + checks: const [AppKeyValueRow(label: 'Orders', value: '30')], + extra: const Text('week'), + actionLabel: 'Again', + onAction: () => taps++, + ), + ); + expect(find.text('You won'), findsOneWidget); + expect(find.text('Orders'), findsOneWidget); + expect(find.text('week'), findsOneWidget); + await tester.tap(find.text('Again')); + expect(taps, 1); + }); + + testWidgets('lost state renders without optional parts', (tester) async { + await tester.pumpApp( + AppResultDialog( + title: 'Closed', + won: false, + actionLabel: 'Retry', + onAction: () {}, + ), + ); + expect(find.text('Closed'), findsOneWidget); + }); + }); + + group('showAppDialog', () { + for (final branded in [true, false]) { + testWidgets('opens a modal (branded: $branded) and dismisses it', ( + tester, + ) async { + await tester.pumpApp( + Builder( + builder: (context) => AppButton( + label: 'Open', + onPressed: () => showAppDialog( + context: context, + branded: branded, + dismissible: true, + builder: (_) => const Text('modal'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('modal'), findsOneWidget); + await tester.tapAt(const Offset(2, 2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('modal'), findsNothing); + }); + } + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_misc_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_misc_test.dart new file mode 100644 index 0000000..c7a8708 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_misc_test.dart @@ -0,0 +1,96 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppToast', () { + testWidgets('fades in the message', (tester) async { + await tester.pumpApp(const AppToast(message: 'Day 2'), settle: false); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('Day 2'), findsOneWidget); + }); + }); + + group('AppIcon', () { + testWidgets('resolves every style', (tester) async { + await tester.pumpApp( + Column( + children: [ + for (final style in AppIconStyle.values) + AppIcon(AppIcons.coffee, style: style, semanticLabel: style.name), + ], + ), + ); + expect(find.byType(AppIcon), findsNWidgets(3)); + }); + + test('every icon set has three distinct glyphs', () { + const sets = [ + AppIcons.coffee, + AppIcons.package, + AppIcons.moped, + AppIcons.mapPin, + AppIcons.checkCircle, + AppIcons.xCircle, + AppIcons.warning, + AppIcons.star, + AppIcons.receipt, + AppIcons.currencyDollar, + AppIcons.usersThree, + AppIcons.chat, + AppIcons.play, + AppIcons.pause, + AppIcons.signOut, + AppIcons.restart, + AppIcons.lightning, + AppIcons.wrench, + AppIcons.send, + AppIcons.trophy, + AppIcons.sparkle, + AppIcons.clock, + AppIcons.arrowRight, + AppIcons.check, + ]; + for (final set in sets) { + expect(set.resolve(AppIconStyle.regular), set.regular); + expect(set.resolve(AppIconStyle.fill), set.fill); + expect(set.resolve(AppIconStyle.duotone), set.duotone); + } + }); + }); + + group('brand', () { + testWidgets('AppUnicorn picks the asset for the background', ( + tester, + ) async { + await tester.pumpApp( + const Column( + children: [ + AppUnicorn(size: 40), + AppUnicorn(size: 40, onNavy: false, semanticLabel: 'VGV'), + ], + ), + ); + expect(find.byType(AppUnicorn), findsNWidgets(2)); + }); + + testWidgets('AppVgvLockup renders plain and tinted', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppVgvLockup(height: 40), + AppVgvLockup(height: 40, color: AppColors.accent), + ], + ), + ); + expect(find.byType(AppVgvLockup), findsNWidgets(2)); + }); + + test('asset paths point into the package', () { + expect(AppAssets.unicorn, endsWith('unicorn.svg')); + expect(AppAssets.unicornDark, endsWith('unicorn-dark.svg')); + expect(AppAssets.vgvLockup, endsWith('vgv-lockup.svg')); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_order_row_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_order_row_test.dart new file mode 100644 index 0000000..c6da23d --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_order_row_test.dart @@ -0,0 +1,124 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppOrderRow', () { + testWidgets('shows texts and fires onTap', (tester) async { + var taps = 0; + await tester.pumpApp( + SizedBox( + width: 300, + child: AppOrderRow( + title: 'Ana', + subtitle: '2x Latte', + trailing: '0:42', + onTap: () => taps++, + ), + ), + ); + expect(find.text('Ana'), findsOneWidget); + expect(find.text('2x Latte'), findsOneWidget); + expect(find.text('0:42'), findsOneWidget); + expect(find.byKey(const Key('appOrderRow_stripe')), findsNothing); + await tester.tap(find.byType(AppOrderRow)); + expect(taps, 1); + }); + + testWidgets('draws a stripe for warn, urgent and done', (tester) async { + for (final urgency in [ + AppUrgency.warn, + AppUrgency.urgent, + AppUrgency.done, + ]) { + await tester.pumpApp( + SizedBox( + width: 300, + child: AppOrderRow( + title: 'Ana', + subtitle: 'Latte', + urgency: urgency, + selected: true, + unread: true, + ), + ), + settle: false, + ); + await tester.pump(const Duration(milliseconds: 800)); + expect(find.byKey(const Key('appOrderRow_stripe')), findsOneWidget); + } + }); + + testWidgets('done shows a check and hides trailing text', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 300, + child: AppOrderRow( + title: 'Ana', + subtitle: 'Latte', + trailing: '0:10', + urgency: AppUrgency.done, + ), + ), + ); + expect(find.text('0:10'), findsNothing); + expect(find.byType(AppIcon), findsOneWidget); + }); + + testWidgets('stops pulsing when no longer urgent', (tester) async { + Widget row(AppUrgency u) => SizedBox( + width: 300, + child: AppOrderRow(title: 'A', subtitle: 'B', urgency: u), + ); + await tester.pumpApp(row(AppUrgency.urgent), settle: false); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpApp(row(AppUrgency.urgent), settle: false); + await tester.pumpApp(row(AppUrgency.normal)); + expect(find.text('A'), findsOneWidget); + }); + }); + + group('AppAvatar / AppPersonRow', () { + testWidgets('avatar shows the uppercase initial or ?', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppAvatar(name: 'bea'), + AppAvatar(name: '', alert: true), + ], + ), + ); + expect(find.text('B'), findsOneWidget); + expect(find.text('?'), findsOneWidget); + }); + + testWidgets('person row shows caption, meter and trailing', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 300, + child: Column( + children: [ + AppPersonRow( + name: 'Bea', + caption: '3 min', + meter: 0.6, + meterTarget: 0.7, + trailing: '4', + ), + AppPersonRow( + name: 'Cy', + caption: 'slow', + meter: 0.1, + alert: true, + ), + ], + ), + ), + ); + expect(find.text('3 min'), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.byType(AppProgressBar), findsNWidgets(2)); + }); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart new file mode 100644 index 0000000..0388648 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_runtime_constructors_test.dart @@ -0,0 +1,41 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +/// Widgets are usually built with `const`, which never runs the constructor. +/// Building them from runtime values keeps constructor lines covered and +/// proves non-const usage works. +void main() { + testWidgets('widgets build from runtime values', (tester) async { + final n = DateTime.now().millisecond; + final label = 'v$n'; + await tester.pumpApp( + SizedBox( + width: 400, + child: Column( + children: [ + AppUnicorn(size: 10 + n * 0), + AppVgvLockup(height: 10 + n * 0), + AppCountBadge(count: n), + AppStageDot(stage: AppStage.values[n % 4]), + AppDecisionCard(kindLabel: label, title: label, children: const []), + AppDialogStep(text: label), + AppAvatar(name: label), + AppPersonRow(name: label, caption: label, meter: 0.5), + AppSkeleton(width: 10 + n * 0.0), + AppStatTile(label: label, value: label), + AppKeyValueRow(label: label, value: label), + AppToast(message: label), + AppStepPill(number: n, label: label, state: AppStepState.now), + AppSkeletonBlock(fractions: [0.5 + n * 0]), + AppGoalChip(strong: label, label: label), + ], + ), + ), + settle: false, + ); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(AppToast), findsOneWidget); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_skeleton_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_skeleton_test.dart new file mode 100644 index 0000000..2c3be45 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_skeleton_test.dart @@ -0,0 +1,20 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + testWidgets('AppSkeleton pulses with a fixed width', (tester) async { + await tester.pumpApp(const AppSkeleton(width: 40), settle: false); + await tester.pump(const Duration(milliseconds: 600)); + expect(tester.getSize(find.byType(AppSkeleton)).width, 40); + }); + + testWidgets('AppSkeletonBlock renders one line per fraction', (tester) async { + await tester.pumpApp( + const SizedBox(width: 200, child: AppSkeletonBlock()), + settle: false, + ); + expect(find.byType(AppSkeleton), findsNWidgets(3)); + }); +} diff --git a/extras/custom-server/shared/app_ui/test/src/widgets/app_stat_test.dart b/extras/custom-server/shared/app_ui/test/src/widgets/app_stat_test.dart new file mode 100644 index 0000000..865da03 --- /dev/null +++ b/extras/custom-server/shared/app_ui/test/src/widgets/app_stat_test.dart @@ -0,0 +1,102 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('AppStatTile', () { + testWidgets('shows label, value and suffix', (tester) async { + await tester.pumpApp( + const AppStatTile(label: 'Orders', value: '12', suffix: '/ 30'), + ); + expect(find.text('Orders'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('/ 30'), findsOneWidget); + expect(find.byType(AppProgressBar), findsNothing); + }); + + testWidgets('renders every tone on navy and on light', (tester) async { + for (final onNavy in [true, false]) { + await tester.pumpApp( + Column( + children: [ + for (final tone in AppTone.values) + AppStatTile( + label: tone.name, + value: '1', + tone: tone, + onNavy: onNavy, + mono: false, + ), + ], + ), + ); + expect(find.byType(AppStatTile), findsNWidgets(AppTone.values.length)); + } + }); + + testWidgets('draws progress with target', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 200, + child: AppStatTile( + label: 'Score', + value: '4.5', + progress: 0.5, + target: 0.8, + ), + ), + ); + expect(find.byType(AppProgressBar), findsOneWidget); + }); + }); + + group('AppProgressBar', () { + testWidgets('fills proportionally and clamps', (tester) async { + await tester.pumpApp( + const SizedBox(width: 200, child: AppProgressBar(value: 2, target: -1)), + ); + expect(find.byKey(const Key('appProgressBar_target')), findsOneWidget); + }); + + testWidgets('supports low and navy styles without target', (tester) async { + await tester.pumpApp( + const SizedBox( + width: 200, + child: Column( + children: [ + AppProgressBar(value: 0.2, low: true), + AppProgressBar(value: 0.2, onNavy: true, target: 0.5), + ], + ), + ), + ); + expect(find.byKey(const Key('appProgressBar_target')), findsOneWidget); + }); + }); + + testWidgets('AppGoalChip renders bold and regular parts', (tester) async { + await tester.pumpApp(const AppGoalChip(strong: '30', label: 'orders')); + expect(find.textContaining('orders', findRichText: true), findsOneWidget); + }); + + group('AppKeyValueRow', () { + testWidgets('renders inset and plain rows with tones', (tester) async { + await tester.pumpApp( + const Column( + children: [ + AppKeyValueRow(label: 'Revenue', value: r'$40', mono: true), + AppKeyValueRow( + label: 'Late', + value: '3', + valueTone: AppTone.negative, + inset: false, + ), + ], + ), + ); + expect(find.text('Revenue'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + }); +} diff --git a/runtime/server.ts b/runtime/server.ts index 6f0faad..8b95273 100644 --- a/runtime/server.ts +++ b/runtime/server.ts @@ -35,7 +35,11 @@ Language: answer in the language the user writes in, English by default, and nev Keep your replies to one or two short, warm sentences. After you fill the form, say in one sentence what you filled in or changed. -If you have a showCalaverita tool and the user asks for the calaverita, call it once the form is complete: write four rhyming lines (two couplets) that fit the name, the job and the tone, and pass them to the tool. +If you have a showCalaverita tool, call it only when the user asks for a calaverita in this conversation ("write", "show", "make me a calaverita"), never because the form is complete. Filling in the form or dressing the skull is not a request for one. When they ask and the form is complete, call it: write four rhyming lines (two couplets) that fit the name, the job and the tone, and pass them to the tool. + +If you have an askUser tool, use it when the answer is one of a few options and the choice is the user's: one question at a time, with two to four options, each with a short id and a label, and nothing else in that turn. The answer comes back as the id of the option. Ask a free-text question (a name, a job) in your reply instead. Do not ask when a sensible choice exists, and never ask again about something the user already answered. + +Tone check: the tone in the form starts as "playful", so treat it as not chosen unless the user named one in this conversation. If you have askUser and the user asks for a calaverita without naming a tone, ask first, once the name and the job are filled in: the question is "How should I roast ?", the options have the ids playful, sarcastic and dramatic and the labels Playful, Sarcastic and Dramatic. When the answer comes back, set the tone in the form with a state update, then write the calaverita. If you do not have a showCalaverita tool, you cannot write the calaverita yourself. Whenever the form is complete, end your reply by telling the user to press the "Write my calaverita" button to see it. Do not write the verse in the chat.`; diff --git a/tool/setup.mjs b/tool/setup.mjs index 65ceb6f..c6be697 100644 --- a/tool/setup.mjs +++ b/tool/setup.mjs @@ -11,7 +11,7 @@ // // Options (everything is asked when left out): // --step <00|01|02|03> which step to run -// --backend which backend to use (step 03 always runs its own server) +// --backend which backend to use // --url the runtime URL, for a custom backend // --agent the agent id, when the backend has more than one // --code the workshop password of the shared backend (or set WORKSHOP_CODE) @@ -36,7 +36,7 @@ const STEPS = { "00": { dir: "00-foundations", title: "Foundations, no agent" }, "01": { dir: "01-shared-state", title: "Shared state, the agent fills the form" }, "02": { dir: "02-generative-ui", title: "Generative UI, the agent writes the calaverita" }, - "03": { dir: "03-custom-server", title: "Custom server, optional", server: { dir: "server", port: 4100, agent: "calaverita" } }, + "03": { dir: "03-human-in-the-loop", title: "Human in the loop, the agent asks you" }, }; const LOCAL_RUNTIME = { dir: "runtime", port: 8200, agent: "default" }; const config = JSON.parse(readFileSync(`${root}tool/workshop.config.json`, "utf8")); @@ -245,11 +245,7 @@ async function useExternal(url, defaultAgent) { backend = { url: base, agent }; } -if (stepKey !== "00" && step.server) { - heading("2. Backend"); - say(`Step 03 has a server of its own, in ${step.dir}/${step.server.dir}.`); - await useOwnServer({ ...step.server, dir: `${step.dir}/${step.server.dir}` }); -} else if (stepKey !== "00") { +if (stepKey !== "00") { heading("2. Backend"); const choices = [ ...(config.sharedBackendUrl ? [{ key: "shared", label: "The shared backend, nothing to set up" }] : []),