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..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,8 +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 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 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 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/lib/screens/agent_screen.dart b/lib/screens/agent_screen.dart index ae63361..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 _AgentListeningPlaceholder(canListen: session.agent.canListen); - } - 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(), ), - ], + ), ), - ), + ], ), ); } @@ -246,10 +261,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 +277,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( 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), + ), + ], + ), + ), + ), + ); + }, + ); +} 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/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: 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',