Skip to content

Commit 966ee8f

Browse files
authored
Session components (#43)
- [x] Refactor to SessionContext to align with other types
1 parent 93d24d6 commit 966ee8f

File tree

7 files changed

+259
-2
lines changed

7 files changed

+259
-2
lines changed

.changes/session-api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "Session components"

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## 1.2.3 (2025-10-01)
3+
## 1.2.3 (2025-12-07)
44

55
* Update WebRTC ver & code maintenance. (#41)
66

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,65 @@ class MyApp extends StatelessWidget {
107107

108108
You can find a complete example in the [example](./example) folder.
109109

110+
### Session UI (Agents)
111+
112+
Use the agent `Session` from `livekit_client` with `SessionContext` to make it
113+
available to widgets like `ChatScrollView`:
114+
115+
```dart
116+
import 'package:livekit_client/livekit_client.dart';
117+
import 'package:livekit_components/livekit_components.dart';
118+
119+
class AgentChatView extends StatefulWidget {
120+
const AgentChatView({super.key});
121+
122+
@override
123+
State<AgentChatView> createState() => _AgentChatViewState();
124+
}
125+
126+
class _AgentChatViewState extends State<AgentChatView> {
127+
late final Session _session;
128+
129+
@override
130+
void initState() {
131+
super.initState();
132+
_session = Session.withAgent(
133+
'my-agent',
134+
tokenSource: EndpointTokenSource(
135+
url: Uri.parse('https://your-token-endpoint'),
136+
),
137+
options: const SessionOptions(preConnectAudio: true),
138+
);
139+
unawaited(_session.start()); // start connecting the agent session
140+
}
141+
142+
@override
143+
void dispose() {
144+
_session.dispose(); // ends the session and cleans up listeners
145+
super.dispose();
146+
}
147+
148+
@override
149+
Widget build(BuildContext context) {
150+
return SessionContext(
151+
session: _session,
152+
child: ChatScrollView(
153+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
154+
messageBuilder: (context, message) => ListTile(
155+
title: Text(message.content.text),
156+
subtitle: Text(message.timestamp.toLocal().toIso8601String()),
157+
),
158+
),
159+
);
160+
}
161+
}
162+
```
163+
164+
- `ChatScrollView` auto-scrolls to the newest message (bottom). Pass a
165+
`ScrollController` if you need manual control.
166+
- You can also pass `session:` directly to `ChatScrollView` instead of relying
167+
on `SessionContext`.
168+
110169
<!--BEGIN_REPO_NAV-->
111170
<br/><table>
112171
<thead><tr><th colspan="2">LiveKit Ecosystem</th></tr></thead>

lib/livekit_components.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
export 'src/context/chat_context.dart';
1616
export 'src/context/media_device_context.dart';
1717
export 'src/context/participant_context.dart';
18+
export 'src/context/session_context.dart';
1819
export 'src/context/room_context.dart';
1920
export 'src/context/track_reference_context.dart';
2021
export 'src/debug/logger.dart';
@@ -51,6 +52,7 @@ export 'src/ui/layout/carousel_layout.dart';
5152
export 'src/ui/layout/grid_layout.dart';
5253
export 'src/ui/layout/layouts.dart';
5354
export 'src/ui/prejoin/prejoin.dart';
55+
export 'src/ui/session/chat_scroll_view.dart';
5456
export 'src/ui/widgets/camera_preview.dart';
5557
export 'src/ui/widgets/participant/connection_quality_indicator.dart';
5658
export 'src/ui/widgets/participant/is_speaking_indicator.dart';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:flutter/widgets.dart';
16+
17+
import 'package:livekit_client/livekit_client.dart';
18+
19+
/// Provides a [Session] to descendant widgets.
20+
///
21+
/// Use this to make a single `Session` visible to session-aware widgets (for
22+
/// example, `ChatScrollView`) without passing it through every constructor.
23+
/// Because it inherits from [InheritedNotifier], it will rebuild dependents
24+
/// when the session notifies listeners, but you can safely use [maybeOf] if
25+
/// you are in an optional context.
26+
class SessionContext extends InheritedNotifier<Session> {
27+
const SessionContext({
28+
super.key,
29+
required Session session,
30+
required super.child,
31+
}) : super(notifier: session);
32+
33+
/// Returns the nearest [Session] in the widget tree or `null` if none exists.
34+
static Session? maybeOf(BuildContext context) {
35+
return context.dependOnInheritedWidgetOfExactType<SessionContext>()?.notifier;
36+
}
37+
38+
/// Returns the nearest [Session] in the widget tree.
39+
/// Throws a [FlutterError] if no session is found.
40+
static Session of(BuildContext context) {
41+
final session = maybeOf(context);
42+
if (session == null) {
43+
throw FlutterError(
44+
'SessionContext.of() called with no Session in the context. '
45+
'Add a SessionContext above this widget or pass a Session directly.',
46+
);
47+
}
48+
return session;
49+
}
50+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'dart:async';
16+
17+
import 'package:flutter/material.dart';
18+
19+
import 'package:livekit_client/livekit_client.dart';
20+
21+
import '../../context/session_context.dart';
22+
23+
/// A scrollable list that renders [Session.messages] with newest messages at
24+
/// the bottom and auto-scrolls when new messages arrive.
25+
///
26+
/// Provide a [Session] via [session] or a surrounding [SessionContext]. Use
27+
/// [messageBuilder] to render each [ReceivedMessage]; the builder runs in
28+
/// reverse order so index `0` corresponds to the latest message.
29+
class ChatScrollView extends StatefulWidget {
30+
const ChatScrollView({
31+
super.key,
32+
required this.messageBuilder,
33+
this.session,
34+
this.autoScroll = true,
35+
this.scrollController,
36+
this.padding,
37+
this.physics,
38+
});
39+
40+
/// Optional session instance. If omitted, [SessionContext.of] is used.
41+
final Session? session;
42+
43+
/// Builder for each message.
44+
final Widget Function(BuildContext context, ReceivedMessage message) messageBuilder;
45+
46+
/// Whether the list should automatically scroll to the latest message when
47+
/// the message count changes.
48+
final bool autoScroll;
49+
50+
/// Optional scroll controller. If not provided, an internal controller is
51+
/// created and disposed automatically.
52+
final ScrollController? scrollController;
53+
54+
/// Optional padding applied to the list.
55+
final EdgeInsetsGeometry? padding;
56+
57+
/// Optional scroll physics.
58+
final ScrollPhysics? physics;
59+
60+
@override
61+
State<ChatScrollView> createState() => _ChatScrollViewState();
62+
}
63+
64+
class _ChatScrollViewState extends State<ChatScrollView> {
65+
ScrollController? _internalController;
66+
int _lastMessageCount = 0;
67+
68+
ScrollController get _controller => widget.scrollController ?? _internalController!;
69+
70+
@override
71+
void initState() {
72+
super.initState();
73+
_internalController = widget.scrollController ?? ScrollController();
74+
}
75+
76+
@override
77+
void didUpdateWidget(ChatScrollView oldWidget) {
78+
super.didUpdateWidget(oldWidget);
79+
if (oldWidget.scrollController != widget.scrollController) {
80+
_internalController?.dispose();
81+
_internalController = widget.scrollController ?? ScrollController();
82+
}
83+
}
84+
85+
@override
86+
void dispose() {
87+
if (widget.scrollController == null) {
88+
_internalController?.dispose();
89+
}
90+
super.dispose();
91+
}
92+
93+
Session _resolveSession(BuildContext context) {
94+
return widget.session ?? SessionContext.of(context);
95+
}
96+
97+
void _autoScrollIfNeeded(List<ReceivedMessage> messages) {
98+
if (!widget.autoScroll) {
99+
_lastMessageCount = messages.length;
100+
return;
101+
}
102+
if (messages.length == _lastMessageCount) {
103+
return;
104+
}
105+
_lastMessageCount = messages.length;
106+
WidgetsBinding.instance.addPostFrameCallback((_) {
107+
if (!mounted) {
108+
return;
109+
}
110+
if (!_controller.hasClients) {
111+
return;
112+
}
113+
unawaited(_controller.animateTo(
114+
0,
115+
duration: const Duration(milliseconds: 200),
116+
curve: Curves.easeOut,
117+
));
118+
});
119+
}
120+
121+
@override
122+
Widget build(BuildContext context) {
123+
final session = _resolveSession(context);
124+
125+
return AnimatedBuilder(
126+
animation: session,
127+
builder: (context, _) {
128+
final messages = [...session.messages]..sort((a, b) => a.timestamp.compareTo(b.timestamp));
129+
_autoScrollIfNeeded(messages);
130+
131+
return ListView.builder(
132+
reverse: true,
133+
controller: _controller,
134+
padding: widget.padding,
135+
physics: widget.physics,
136+
itemCount: messages.length,
137+
itemBuilder: (context, index) {
138+
final message = messages[messages.length - 1 - index];
139+
return widget.messageBuilder(context, message);
140+
},
141+
);
142+
},
143+
);
144+
}
145+
}

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ dependencies:
1111
flutter:
1212
sdk: flutter
1313
flutter_webrtc: 1.2.1
14-
livekit_client: ^2.4.9
14+
livekit_client: ^2.6.0
1515
chat_bubbles: ^1.6.0
1616
collection: ^1.19.0
1717
flutter_background: ^1.3.0+1

0 commit comments

Comments
 (0)