diff --git a/.changes/session-api b/.changes/session-api new file mode 100644 index 0000000..89e824e --- /dev/null +++ b/.changes/session-api @@ -0,0 +1 @@ +minor type="added" "Session components" diff --git a/CHANGELOG.md b/CHANGELOG.md index df91cbf..c74fa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.2.3 (2025-10-01) +## 1.2.3 (2025-12-07) * Update WebRTC ver & code maintenance. (#41) diff --git a/README.md b/README.md index 37820c5..1ef739d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,65 @@ class MyApp extends StatelessWidget { You can find a complete example in the [example](./example) folder. +### Session UI (Agents) + +Use the agent `Session` from `livekit_client` with `SessionContext` to make it +available to widgets like `ChatScrollView`: + +```dart +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_components/livekit_components.dart'; + +class AgentChatView extends StatefulWidget { + const AgentChatView({super.key}); + + @override + State createState() => _AgentChatViewState(); +} + +class _AgentChatViewState extends State { + late final Session _session; + + @override + void initState() { + super.initState(); + _session = Session.withAgent( + 'my-agent', + tokenSource: EndpointTokenSource( + url: Uri.parse('https://your-token-endpoint'), + ), + options: const SessionOptions(preConnectAudio: true), + ); + unawaited(_session.start()); // start connecting the agent session + } + + @override + void dispose() { + _session.dispose(); // ends the session and cleans up listeners + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SessionContext( + session: _session, + child: ChatScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + messageBuilder: (context, message) => ListTile( + title: Text(message.content.text), + subtitle: Text(message.timestamp.toLocal().toIso8601String()), + ), + ), + ); + } +} +``` + +- `ChatScrollView` auto-scrolls to the newest message (bottom). Pass a + `ScrollController` if you need manual control. +- You can also pass `session:` directly to `ChatScrollView` instead of relying + on `SessionContext`. +
diff --git a/lib/livekit_components.dart b/lib/livekit_components.dart index 61f4f3e..f8dee0c 100644 --- a/lib/livekit_components.dart +++ b/lib/livekit_components.dart @@ -15,6 +15,7 @@ export 'src/context/chat_context.dart'; export 'src/context/media_device_context.dart'; export 'src/context/participant_context.dart'; +export 'src/context/session_context.dart'; export 'src/context/room_context.dart'; export 'src/context/track_reference_context.dart'; export 'src/debug/logger.dart'; @@ -51,6 +52,7 @@ export 'src/ui/layout/carousel_layout.dart'; export 'src/ui/layout/grid_layout.dart'; export 'src/ui/layout/layouts.dart'; export 'src/ui/prejoin/prejoin.dart'; +export 'src/ui/session/chat_scroll_view.dart'; export 'src/ui/widgets/camera_preview.dart'; export 'src/ui/widgets/participant/connection_quality_indicator.dart'; export 'src/ui/widgets/participant/is_speaking_indicator.dart'; diff --git a/lib/src/context/session_context.dart b/lib/src/context/session_context.dart new file mode 100644 index 0000000..bb4b8a2 --- /dev/null +++ b/lib/src/context/session_context.dart @@ -0,0 +1,50 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/widgets.dart'; + +import 'package:livekit_client/livekit_client.dart'; + +/// Provides a [Session] to descendant widgets. +/// +/// Use this to make a single `Session` visible to session-aware widgets (for +/// example, `ChatScrollView`) without passing it through every constructor. +/// Because it inherits from [InheritedNotifier], it will rebuild dependents +/// when the session notifies listeners, but you can safely use [maybeOf] if +/// you are in an optional context. +class SessionContext extends InheritedNotifier { + const SessionContext({ + super.key, + required Session session, + required super.child, + }) : super(notifier: session); + + /// Returns the nearest [Session] in the widget tree or `null` if none exists. + static Session? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier; + } + + /// Returns the nearest [Session] in the widget tree. + /// Throws a [FlutterError] if no session is found. + static Session of(BuildContext context) { + final session = maybeOf(context); + if (session == null) { + throw FlutterError( + 'SessionContext.of() called with no Session in the context. ' + 'Add a SessionContext above this widget or pass a Session directly.', + ); + } + return session; + } +} diff --git a/lib/src/ui/session/chat_scroll_view.dart b/lib/src/ui/session/chat_scroll_view.dart new file mode 100644 index 0000000..5245c1a --- /dev/null +++ b/lib/src/ui/session/chat_scroll_view.dart @@ -0,0 +1,145 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:livekit_client/livekit_client.dart'; + +import '../../context/session_context.dart'; + +/// A scrollable list that renders [Session.messages] with newest messages at +/// the bottom and auto-scrolls when new messages arrive. +/// +/// Provide a [Session] via [session] or a surrounding [SessionContext]. Use +/// [messageBuilder] to render each [ReceivedMessage]; the builder runs in +/// reverse order so index `0` corresponds to the latest message. +class ChatScrollView extends StatefulWidget { + const ChatScrollView({ + super.key, + required this.messageBuilder, + this.session, + this.autoScroll = true, + this.scrollController, + this.padding, + this.physics, + }); + + /// Optional session instance. If omitted, [SessionContext.of] is used. + final Session? session; + + /// Builder for each message. + final Widget Function(BuildContext context, ReceivedMessage message) messageBuilder; + + /// Whether the list should automatically scroll to the latest message when + /// the message count changes. + final bool autoScroll; + + /// Optional scroll controller. If not provided, an internal controller is + /// created and disposed automatically. + final ScrollController? scrollController; + + /// Optional padding applied to the list. + final EdgeInsetsGeometry? padding; + + /// Optional scroll physics. + final ScrollPhysics? physics; + + @override + State createState() => _ChatScrollViewState(); +} + +class _ChatScrollViewState extends State { + ScrollController? _internalController; + int _lastMessageCount = 0; + + ScrollController get _controller => widget.scrollController ?? _internalController!; + + @override + void initState() { + super.initState(); + _internalController = widget.scrollController ?? ScrollController(); + } + + @override + void didUpdateWidget(ChatScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scrollController != widget.scrollController) { + _internalController?.dispose(); + _internalController = widget.scrollController ?? ScrollController(); + } + } + + @override + void dispose() { + if (widget.scrollController == null) { + _internalController?.dispose(); + } + super.dispose(); + } + + Session _resolveSession(BuildContext context) { + return widget.session ?? SessionContext.of(context); + } + + void _autoScrollIfNeeded(List messages) { + if (!widget.autoScroll) { + _lastMessageCount = messages.length; + return; + } + if (messages.length == _lastMessageCount) { + return; + } + _lastMessageCount = messages.length; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + if (!_controller.hasClients) { + return; + } + unawaited(_controller.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + )); + }); + } + + @override + Widget build(BuildContext context) { + final session = _resolveSession(context); + + return AnimatedBuilder( + animation: session, + builder: (context, _) { + final messages = [...session.messages]..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + _autoScrollIfNeeded(messages); + + return ListView.builder( + reverse: true, + controller: _controller, + padding: widget.padding, + physics: widget.physics, + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[messages.length - 1 - index]; + return widget.messageBuilder(context, message); + }, + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3873a7b..d35d302 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter flutter_webrtc: 1.2.1 - livekit_client: ^2.4.9 + livekit_client: ^2.6.0 chat_bubbles: ^1.6.0 collection: ^1.19.0 flutter_background: ^1.3.0+1
LiveKit Ecosystem