From 730ec7b0b566ec2d37ae533f34b9e7cfa9fa803a Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:34:27 +0200 Subject: [PATCH 1/6] First round, .env is now optional --- lib/controllers/app_ctrl.dart | 11 ++++++++--- lib/main.dart | 3 ++- pubspec.yaml | 5 +++-- test/widget_test.dart | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/controllers/app_ctrl.dart b/lib/controllers/app_ctrl.dart index 73e0f23..47dcd5d 100644 --- a/lib/controllers/app_ctrl.dart +++ b/lib/controllers/app_ctrl.dart @@ -8,6 +8,8 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +final String homepageAgentTokenEndpoint = 'https://livekit.com/api/homepage-agent/token'; + enum AppScreenState { welcome, agent } enum AgentScreenState { visualizer, transcription } @@ -47,12 +49,15 @@ class AppCtrl extends ChangeNotifier { } final sandboxId = dotenv.env['LIVEKIT_SANDBOX_ID']?.replaceAll('"', ''); - if (sandboxId == null || sandboxId.isEmpty) { - throw StateError('LIVEKIT_SANDBOX_ID is not set and no hardcoded token is configured.'); + sdk.EndpointTokenSource tokenSource; + if (sandboxId == null || sandboxId.isEmpty || sandboxId == '') { + tokenSource = sdk.EndpointTokenSource(url: Uri.parse(homepageAgentTokenEndpoint)); + } else { + tokenSource = sdk.SandboxTokenSource(sandboxId: sandboxId); } return sdk.Session.fromConfigurableTokenSource( - sdk.SandboxTokenSource(sandboxId: sandboxId).cached(), + tokenSource, options: sdk.SessionOptions(room: room), ); } diff --git a/lib/main.dart b/lib/main.dart index ecf0b6b..a540626 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,8 @@ import 'app.dart'; // Load environment variables before starting the app // This is used to configure the LiveKit sandbox ID for development +// The file is optional; without it the app connects to a default agent (see app_ctrl.dart) void main() async { - await dotenv.load(fileName: '.env'); + await dotenv.load(fileName: 'assets/.env', isOptional: true); runApp(const VoiceAssistantApp()); } diff --git a/pubspec.yaml b/pubspec.yaml index ca82f5b..b96b56a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,9 +70,10 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: + # The assets/ directory also picks up the optional assets/.env file when + # present (declared file assets must exist, directory contents may vary). assets: - - .env - - assets/terminal.png + - assets/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/test/widget_test.dart b/test/widget_test.dart index 8667719..7858b3d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:voice_assistant/app.dart'; void main() { testWidgets('App builds successfully', (WidgetTester tester) async { await dotenv.load( - fileName: '.env', + fileName: 'assets/.env', isOptional: true, mergeWith: const { 'LIVEKIT_SANDBOX_ID': 'test', From 40677c6ec02f2779c09424017ad38dc63ea2b335 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:49:18 +0200 Subject: [PATCH 2/6] Works as intended, now inform about missing agent --- .env.example | 3 +++ .github/workflows/test.yaml | 3 --- README.md | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 808b9fb..0ffe18a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ +# Copy this file to assets/.env and set your token server sandbox ID, +# found in your LiveKit Cloud project's Settings page. +# Without it, the app connects to a default agent. LIVEKIT_SANDBOX_ID= \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f08c108..25a1027 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,9 +18,6 @@ jobs: with: channel: "stable" - - name: Create empty .env file - run: touch .env - - name: Install dependencies run: flutter pub get diff --git a/README.md b/README.md index b60562a..95e1440 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ flutter run Note: You may need to configure signing certificates in Xcode if building to a real iOS device. > [!NOTE] -> To setup without the LiveKit CLI, clone the repository and then either create a `.env` with a `LIVEKIT_SANDBOX_ID` (from your project's **Options** on the [Settings](https://cloud.livekit.io/projects/p_/settings/project) page), or modify `lib/controllers/app_ctrl.dart` to replace the `SandboxTokenSource` with your own token source implementation (development-only hardcoded credentials are also supported there). +> To setup without the LiveKit CLI, clone the repository and then either create an `assets/.env` file with a `LIVEKIT_SANDBOX_ID` (from your project's **Options** on the [Settings](https://cloud.livekit.io/projects/p_/settings/project) page), or modify `lib/controllers/app_ctrl.dart` to replace the `SandboxTokenSource` with your own token source implementation (development-only hardcoded credentials are also supported there). +> +> The env file is optional: without any configuration, the app connects to a default agent — the same one featured on the [LiveKit homepage](https://livekit.io) — so you can try it out right away. ## Feature overview From 47b305f901b0307f5e3201f7564b06ddee8dd3ac Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:00:15 +0200 Subject: [PATCH 3/6] Show 'Waiting for agent' until an agent joins the room The transcription placeholder always claimed the agent was listening, even in rooms without an agent dispatch. Key the text off session.agent.isConnected so it reads 'Waiting for agent' until an agent participant is present and ready. Co-Authored-By: Claude Fable 5 --- lib/screens/agent_screen.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/screens/agent_screen.dart b/lib/screens/agent_screen.dart index ae63361..b68313b 100644 --- a/lib/screens/agent_screen.dart +++ b/lib/screens/agent_screen.dart @@ -162,7 +162,7 @@ class AgentScreen extends StatelessWidget { child: Consumer( builder: (context, session, _) { if (session.messages.isEmpty) { - return _AgentListeningPlaceholder(canListen: session.agent.canListen); + return _AgentStatusPlaceholder(isAgentConnected: session.agent.isConnected); } return components.ChatScrollView( session: session, @@ -246,10 +246,10 @@ class _MessageBubble extends StatelessWidget { } } -class _AgentListeningPlaceholder extends StatelessWidget { - const _AgentListeningPlaceholder({required this.canListen}); +class _AgentStatusPlaceholder extends StatelessWidget { + const _AgentStatusPlaceholder({required this.isAgentConnected}); - final bool canListen; + final bool isAgentConnected; @override Widget build(BuildContext context) { @@ -262,13 +262,13 @@ class _AgentListeningPlaceholder extends StatelessWidget { Icon(Icons.graphic_eq, size: 32, color: colorScheme.primary.withValues(alpha: 0.7)), const SizedBox(height: 12), Text( - 'Agent is listening', + isAgentConnected ? 'Agent is listening' : 'Waiting for agent', style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), - if (!canListen) + if (isAgentConnected) Padding( padding: const EdgeInsets.only(top: 6), child: Text( From 03b3cba53a7d42a0692ab298ba401601f628d5d4 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:08:31 +0200 Subject: [PATCH 4/6] Fix premature 'Agent is listening' indicator on welcome screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome screen indicator was keyed off agent.canListen, which is already true while pre-connect audio is buffering — before any agent participant exists. Replace it with a shared AgentStatusIndicator that shows 'Waiting for agent' (spinner) until an agent has joined and is ready, and only then 'Agent is listening'. The indicator is also overlaid on the agent screen in visualizer mode, so the waiting state persists across the screen transition until the agent actually joins; it hides on agent failure, where the session error banner takes over. Co-Authored-By: Claude Fable 5 --- lib/screens/agent_screen.dart | 157 +++++++++++++----------- lib/screens/welcome_screen.dart | 31 +---- lib/widgets/agent_status_indicator.dart | 61 +++++++++ 3 files changed, 150 insertions(+), 99 deletions(-) create mode 100644 lib/widgets/agent_status_indicator.dart diff --git a/lib/screens/agent_screen.dart b/lib/screens/agent_screen.dart index b68313b..dae6e71 100644 --- a/lib/screens/agent_screen.dart +++ b/lib/screens/agent_screen.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../controllers/app_ctrl.dart'; import '../support/agent_selector.dart'; import '../widgets/agent_layout_switcher.dart'; +import '../widgets/agent_status_indicator.dart'; import '../widgets/camera_toggle_button.dart'; import '../widgets/message_bar.dart'; @@ -115,84 +116,98 @@ class AgentScreen extends StatelessWidget { isCameraVisible: appCtrl.isUserCameEnabled, isScreenshareVisible: appCtrl.isScreenshareEnabled, ), - builder: (ctx, agentLayoutState, child) => AgentLayoutSwitcher( - layoutState: agentLayoutState, - // agentViewBuilder: (ctx) => AgentTrackView(), - buildAgentView: (ctx) => const AgentTrackView(), - buildCameraView: (ctx) => Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - ), - child: components.MediaDeviceContextBuilder( - builder: (context, roomCtx, mediaDeviceCtx) => components.ParticipantSelector( - filter: (identifier) => identifier.isVideo && identifier.isLocal, - builder: (context, identifier) => Stack( - children: [ - components.VideoTrackWidget( - fit: sdk.VideoViewFit.cover, - noTrackBuilder: (ctx) => Container(color: Theme.of(ctx).cardColor), - ), - Positioned( - right: 10, - bottom: 10, - child: CameraToggleButton( - onTap: () => mediaDeviceCtx.toggleCameraPosition(), - ), - ), - ], - ), + builder: (ctx, agentLayoutState, child) => Stack( + children: [ + _buildLayoutSwitcher(ctx, agentLayoutState), + // In transcription mode the chat placeholder shows the agent + // status instead. + if (!agentLayoutState.isTranscriptionVisible) + const Positioned( + left: 0, + right: 0, + bottom: 110, + child: Center(child: AgentStatusIndicator(hideWhenConnected: true)), ), + ], + ), + ), + ); + + Widget _buildLayoutSwitcher(BuildContext ctx, AgentLayoutState agentLayoutState) => AgentLayoutSwitcher( + layoutState: agentLayoutState, + // agentViewBuilder: (ctx) => AgentTrackView(), + buildAgentView: (ctx) => const AgentTrackView(), + buildCameraView: (ctx) => Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + ), + child: components.MediaDeviceContextBuilder( + builder: (context, roomCtx, mediaDeviceCtx) => components.ParticipantSelector( + filter: (identifier) => identifier.isVideo && identifier.isLocal, + builder: (context, identifier) => Stack( + children: [ + components.VideoTrackWidget( + fit: sdk.VideoViewFit.cover, + noTrackBuilder: (ctx) => Container(color: Theme.of(ctx).cardColor), + ), + Positioned( + right: 10, + bottom: 10, + child: CameraToggleButton( + onTap: () => mediaDeviceCtx.toggleCameraPosition(), + ), + ), + ], ), ), - buildScreenShareView: (ctx) => Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.3), + ), + ), + buildScreenShareView: (ctx) => Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.3), + ), + child: const Text('Screenshare View'), + ), + transcriptionsBuilder: (ctx) => Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: GestureDetector( + onTap: () => ctx.read().messageFocusNode.unfocus(), + child: Consumer( + builder: (context, session, _) { + if (session.messages.isEmpty) { + return _AgentStatusPlaceholder(isAgentConnected: session.agent.isConnected); + } + return components.ChatScrollView( + session: session, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + physics: const BouncingScrollPhysics(), + messageBuilder: (context, message) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _MessageBubble(message: message), + ), + ); + }, + ), ), - child: const Text('Screenshare View'), ), - transcriptionsBuilder: (ctx) => Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: GestureDetector( - onTap: () => ctx.read().messageFocusNode.unfocus(), - child: Consumer( - builder: (context, session, _) { - if (session.messages.isEmpty) { - return _AgentStatusPlaceholder(isAgentConnected: session.agent.isConnected); - } - return components.ChatScrollView( - session: session, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - physics: const BouncingScrollPhysics(), - messageBuilder: (context, message) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _MessageBubble(message: message), - ), - ); - }, - ), - ), - ), - Padding( - padding: - EdgeInsets.only(left: 16, right: 16, bottom: max(0, MediaQuery.of(ctx).viewInsets.bottom - 80)), - child: Selector( - selector: (ctx, appCtx) => appCtx.isSendButtonEnabled, - builder: (ctx, isSendEnabled, child) => MessageBar( - focusNode: ctx.read().messageFocusNode, - isSendEnabled: isSendEnabled, - controller: ctx.read().messageCtrl, - onSendTap: () => ctx.read().sendMessage(), - ), - ), + Padding( + padding: EdgeInsets.only(left: 16, right: 16, bottom: max(0, MediaQuery.of(ctx).viewInsets.bottom - 80)), + child: Selector( + selector: (ctx, appCtx) => appCtx.isSendButtonEnabled, + builder: (ctx, isSendEnabled, child) => MessageBar( + focusNode: ctx.read().messageFocusNode, + isSendEnabled: isSendEnabled, + controller: ctx.read().messageCtrl, + onSendTap: () => ctx.read().sendMessage(), ), - ], + ), ), - ), + ], ), ); } diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 2a7c638..a8bfd8f 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -4,6 +4,7 @@ import 'package:livekit_client/livekit_client.dart' as sdk; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart' show launchUrl; import '../controllers/app_ctrl.dart' as ctrl; +import '../widgets/agent_status_indicator.dart'; import '../widgets/button.dart' as buttons; class WelcomeScreen extends StatelessWidget { @@ -50,34 +51,8 @@ class WelcomeScreen extends StatelessWidget { ], ), ), - // Agent listening indicator - Consumer( - builder: (ctx, session, child) => AnimatedOpacity( - opacity: session.agent.canListen ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.mic, - color: Colors.green, - size: 18, - ), - const SizedBox(width: 8), - const Text( - 'Agent is listening', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), + // Agent status indicator + const AgentStatusIndicator(), Consumer2( builder: (ctx, appCtrl, session, child) { final isProgressing = diff --git a/lib/widgets/agent_status_indicator.dart b/lib/widgets/agent_status_indicator.dart new file mode 100644 index 0000000..4885a3d --- /dev/null +++ b/lib/widgets/agent_status_indicator.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as sdk; +import 'package:provider/provider.dart'; + +/// Shows the agent connection status while a session is active: +/// "Waiting for agent" until an agent participant has joined and is ready, +/// then "Agent is listening". Hidden when there is no active session or the +/// agent has failed (the session error banner covers that case). +class AgentStatusIndicator extends StatelessWidget { + const AgentStatusIndicator({super.key, this.hideWhenConnected = false}); + + /// Hides the indicator once the agent is connected instead of showing + /// "Agent is listening". + final bool hideWhenConnected; + + @override + Widget build(BuildContext context) => Consumer( + builder: (context, session, child) { + final agent = session.agent; + final bool isListening = agent.isConnected; + final bool isWaiting = agent.isPending || agent.isBuffering; + final bool visible = isWaiting || (isListening && !hideWhenConnected); + final Color color = isListening ? Colors.green : Theme.of(context).colorScheme.outline; + + return IgnorePointer( + child: AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: isListening + ? const Icon(Icons.mic, color: Colors.green, size: 18) + : isWaiting + ? Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ), + ) + : null, + ), + const SizedBox(width: 8), + Text( + isListening ? 'Agent is listening' : 'Waiting for agent', + style: TextStyle(color: color, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + }, + ); +} From f5e24140a9404ab96a27e4ac9a5c3bb30fe8ab6c Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:13:41 +0200 Subject: [PATCH 5/6] Some other leftover from moving the .env file --- taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taskfile.yaml b/taskfile.yaml index d4c29f3..d7130b1 100644 --- a/taskfile.yaml +++ b/taskfile.yaml @@ -1,7 +1,7 @@ version: "3" vars: - env_file: ".env" + env_file: "assets/.env" tasks: post_create: From de72fae89c16d509f5c79c7c1c1066e29c179e2f Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:45:50 +0200 Subject: [PATCH 6/6] Aligned README --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 95e1440..9e1f05d 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,10 @@ This template is compatible with iOS, macOS, Android, and web. It is free for yo ## Getting started -First, you'll need a LiveKit agent to speak with. Try our starter agent for [Python](https://github.com/livekit-examples/agent-starter-python), [Node.js](https://github.com/livekit-examples/agent-starter-node), or [create your own from scratch](https://docs.livekit.io/agents/start/voice-ai/). - -Second, you need a token server. The easiest way to set this up is with a [token server](https://docs.livekit.io/frontends/authentication/tokens/sandbox-token-server/) and the [LiveKit CLI](https://docs.livekit.io/home/cli/cli-setup/). - -First, enable the token server from your project's **Options** on the [Settings](https://cloud.livekit.io/projects/p_/settings/project) page in LiveKit Cloud and copy the `sandboxId`. - -Then, run the following command to automatically clone this template and connect it to LiveKit Cloud. +Run the following command to automatically clone this template and connect it to LiveKit Cloud. ```bash -lk app create --template agent-starter-flutter --sandbox +lk app create --template agent-starter-flutter ``` This will create a new Flutter project in the current directory. Install dependencies and run the app: @@ -30,10 +24,24 @@ flutter run Note: You may need to configure signing certificates in Xcode if building to a real iOS device. +The app is configured to connect to the LiveKit homepage agent by default, which you can also try at [livekit.com](https://www.livekit.com). To point the app at your own agent (see [Connect to your agent](#connect-to-your-agent)). + > [!NOTE] -> To setup without the LiveKit CLI, clone the repository and then either create an `assets/.env` file with a `LIVEKIT_SANDBOX_ID` (from your project's **Options** on the [Settings](https://cloud.livekit.io/projects/p_/settings/project) page), or modify `lib/controllers/app_ctrl.dart` to replace the `SandboxTokenSource` with your own token source implementation (development-only hardcoded credentials are also supported there). -> -> The env file is optional: without any configuration, the app connects to a default agent — the same one featured on the [LiveKit homepage](https://livekit.io) — so you can try it out right away. +> To setup without the LiveKit CLI, clone the repository and then either create the `assets/.env` file manually from a copy of `.env.example`. The env file is optional: without any configuration, the app connects to a default agent — the same one featured on the [LiveKit homepage](https://livekit.io) — so you can try it out right away. + +## Connect to your agent + +To switch from the default agent to your own, you first need a LiveKit agent to speak with. For a no-code setup, use the [Agent Builder](https://docs.livekit.io/agents/start/builder/). For more customization, try our starter agent for [Python](https://github.com/livekit-examples/agent-starter-python), [Node.js](https://github.com/livekit-examples/agent-starter-node), or [create your own from scratch](https://docs.livekit.io/agents/start/voice-ai/). + +Second, you need a token server. For development, the easiest option is the [sandbox token server](https://docs.livekit.io/frontends/authentication/tokens/sandbox-token-server/): enable it from your project's **Options** on the [Settings](https://cloud.livekit.io/projects/p_/settings/project) page in LiveKit Cloud and copy the `sandboxId`. + +Then fill the `LIVEKIT_SANDBOX_ID` in your `assets/.env`: + +```swift +LIVEKIT_SANDBOX_ID= +``` + +or modify `lib/controllers/app_ctrl.dart` to replace the `SandboxTokenSource` with your own token source implementation (development-only hardcoded credentials are also supported there). ## Feature overview