mugib_sdk 1.0.11
mugib_sdk: ^1.0.11 copied to clipboard
Official Flutter SDK for Mugib chat sessions, voice agents, and session payload with one apiKey.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mugib_sdk/mugib_sdk.dart';
void main() => runApp(const MugibSdkExampleApp());
/// Configure with:
/// flutter run --dart-define=MUGIB_API_KEY=... --dart-define=MUGIB_AGENT_ID=34
const _apiKey = String.fromEnvironment('MUGIB_API_KEY', defaultValue: '');
const _baseUrl =
String.fromEnvironment('MUGIB_BASE_URL', defaultValue: 'https://api.mugib.com');
const _agentId = int.fromEnvironment('MUGIB_AGENT_ID', defaultValue: 0);
class MugibSdkExampleApp extends StatelessWidget {
const MugibSdkExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mugib SDK Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF1677FF),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final mugib = MugibClient(apiKey: _apiKey, baseUrl: _baseUrl);
return Scaffold(
appBar: AppBar(title: const Text('Mugib SDK')),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
Text('Demos', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.chat_bubble_outline),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatPage(mugib: mugib)),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
label: const Text('Chat demo'),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.call),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VoiceCallPage(mugib: mugib, agentId: _agentId)),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF12B886),
),
label: const Text('Voice call'),
),
const Spacer(),
if (_apiKey.isEmpty)
const _Hint(
'Set --dart-define=MUGIB_API_KEY=... to run the live demos.'),
if (_agentId <= 0)
const _Hint(
'Set --dart-define=MUGIB_AGENT_ID=... for the voice call demo.'),
],
),
),
);
}
}
class _Hint extends StatelessWidget {
final String text;
const _Hint(this.text);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.15),
borderRadius: BorderRadius.circular(10),
),
child: Row(children: [
const Icon(Icons.info_outline, size: 18, color: Colors.amber),
const SizedBox(width: 8),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
]),
);
}
}
// ───────────────────────────── Chat demo ─────────────────────────────
class ChatPage extends StatefulWidget {
final MugibClient mugib;
const ChatPage({super.key, required this.mugib});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
final _log = <String>[];
bool _busy = false;
void _add(String line) => setState(() => _log.add(line));
Future<void> _run() async {
if (_apiKey.isEmpty) {
_add('Set MUGIB_API_KEY to run the live demo.');
return;
}
setState(() => _busy = true);
try {
final session = await widget.mugib.chat.createSession(pageUrl: '/demo');
_add('Session: ${session.id}');
final reply = await session.send('Hello',
payload: {'source': 'mugib_sdk_example'});
_add('Bot: ${reply.reply}');
final buf = StringBuffer();
await for (final e in session.sendStream('What can you help with?')) {
if (e is MugibStreamText) buf.write(e.text);
}
_add('Stream: $buf');
} on MugibException catch (e) {
_add('Error: ${e.message}');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: _busy ? null : _run,
child: _busy
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Run chat demo'),
),
const Divider(height: 24),
Expanded(
child: ListView.builder(
itemCount: _log.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(_log[i]),
),
),
),
],
),
),
);
}
}
// ──────────────────────────── Voice call ────────────────────────────
class VoiceCallPage extends StatefulWidget {
final MugibClient mugib;
final int agentId;
const VoiceCallPage({super.key, required this.mugib, required this.agentId});
@override
State<VoiceCallPage> createState() => _VoiceCallPageState();
}
class _VoiceCallPageState extends State<VoiceCallPage> {
late final MugibVoiceClient _call;
final _scroll = ScrollController();
final _subs = <StreamSubscription<dynamic>>[];
MugibCallState _state = MugibCallState.idle;
List<MugibTranscript> _lines = const [];
Duration _duration = Duration.zero;
double _level = 0;
bool _muted = false;
String? _errorBanner;
@override
void initState() {
super.initState();
_call = widget.mugib.voice.client(widget.agentId);
_subs.add(_call.state.listen((s) {
setState(() => _state = s);
if (s == MugibCallState.connected ||
s == MugibCallState.listening ||
s == MugibCallState.speaking) {
// A successful connection clears any prior error.
if (_errorBanner != null) setState(() => _errorBanner = null);
}
}));
_subs.add(_call.transcriptLines.listen((lines) {
setState(() => _lines = lines);
_scrollToBottom();
}));
_subs.add(_call.durationTicks.listen((d) => setState(() => _duration = d)));
_subs.add(_call.inputLevel.listen((l) => setState(() => _level = l)));
_subs.add(_call.errorEvents.listen((err) {
setState(() => _errorBanner = _friendlyError(err));
}));
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_call.dispose();
_scroll.dispose();
super.dispose();
}
bool get _inCall =>
_state == MugibCallState.connecting ||
_state == MugibCallState.connected ||
_state == MugibCallState.listening ||
_state == MugibCallState.speaking;
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _toggleCall() async {
if (_inCall) {
await _call.end();
return;
}
if (widget.agentId <= 0) {
setState(() => _errorBanner = 'Set MUGIB_AGENT_ID to start a call.');
return;
}
setState(() => _errorBanner = null);
try {
await _call.start(
options: const MugibVoiceCallOptions(
userContext: {'source': 'mugib_sdk_example'},
),
);
} on MugibVoiceException catch (e) {
// errorEvents already surfaces a friendly banner; nothing else to do.
debugPrint('voice start failed: ${e.code.name}');
}
}
void _toggleMute() {
final m = _call.toggleMute();
setState(() => _muted = m);
}
/// Maps a structured error code to a user-friendly, actionable message.
String _friendlyError(MugibVoiceError err) {
switch (err.code) {
case MugibVoiceErrorCode.microphonePermissionDenied:
return 'Microphone access is needed for the call. Enable it in Settings and try again.';
case MugibVoiceErrorCode.network:
return 'Connection problem. Check your internet and try again.';
case MugibVoiceErrorCode.connectionTimeout:
return 'The call took too long to connect. Please try again.';
case MugibVoiceErrorCode.quotaExceeded:
return 'Voice minutes for this account are used up.';
case MugibVoiceErrorCode.invalidApiKey:
return 'Invalid API key. Check your Mugib project key.';
case MugibVoiceErrorCode.agentNotFound:
return 'This voice agent was not found.';
case MugibVoiceErrorCode.agentInactive:
return 'This voice agent is currently inactive.';
case MugibVoiceErrorCode.notConfigured:
return 'Voice is not configured for this project yet.';
case MugibVoiceErrorCode.unsupportedProvider:
return 'This agent uses a provider not supported by the SDK.';
case MugibVoiceErrorCode.providerError:
return 'The voice service hit an error. Please try again.';
case MugibVoiceErrorCode.alreadyInProgress:
return 'A call is already in progress.';
case MugibVoiceErrorCode.disposed:
case MugibVoiceErrorCode.unknown:
return err.message;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_call.token?.agentName.isNotEmpty == true
? _call.token!.agentName
: 'Voice call'),
),
body: Column(
children: [
if (_errorBanner != null) _ErrorBanner(message: _errorBanner!),
const SizedBox(height: 12),
_StatusPill(state: _state, duration: _duration),
const SizedBox(height: 24),
_MicOrb(level: _level, state: _state, muted: _muted),
const SizedBox(height: 12),
_LevelMeter(level: _muted ? 0 : _level),
const SizedBox(height: 8),
const Divider(height: 24),
Expanded(child: _Transcript(scroll: _scroll, lines: _lines)),
_Controls(
inCall: _inCall,
connecting: _state == MugibCallState.connecting,
muted: _muted,
onToggleCall: _toggleCall,
onToggleMute: _inCall ? _toggleMute : null,
),
],
),
);
}
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: const Color(0xFFFFE3E3),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(children: [
const Icon(Icons.error_outline, color: Color(0xFFE03131), size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(message,
style: const TextStyle(color: Color(0xFFC92A2A), fontSize: 13)),
),
]),
);
}
}
class _StatusPill extends StatelessWidget {
final MugibCallState state;
final Duration duration;
const _StatusPill({required this.state, required this.duration});
({Color color, String label, IconData icon}) get _info {
switch (state) {
case MugibCallState.idle:
return (color: Colors.grey, label: 'Ready', icon: Icons.circle_outlined);
case MugibCallState.connecting:
return (color: Colors.orange, label: 'Connecting…', icon: Icons.sync);
case MugibCallState.connected:
return (color: Colors.teal, label: 'Connected', icon: Icons.link);
case MugibCallState.listening:
return (color: Colors.blue, label: 'Listening', icon: Icons.hearing);
case MugibCallState.speaking:
return (
color: Colors.deepPurple,
label: 'Agent speaking',
icon: Icons.graphic_eq
);
case MugibCallState.ended:
return (color: Colors.grey, label: 'Call ended', icon: Icons.call_end);
case MugibCallState.error:
return (color: Colors.red, label: 'Error', icon: Icons.error_outline);
}
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final info = _info;
final showTimer = state == MugibCallState.connected ||
state == MugibCallState.listening ||
state == MugibCallState.speaking;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: info.color.withOpacity(0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(info.icon, size: 16, color: info.color),
const SizedBox(width: 8),
Text(info.label,
style: TextStyle(
color: info.color, fontWeight: FontWeight.w600, fontSize: 13)),
if (showTimer) ...[
const SizedBox(width: 10),
Text('· ${_fmt(duration)}',
style: TextStyle(color: info.color, fontSize: 13)),
],
]),
);
}
}
/// A pulsing mic orb that scales with the live input [level].
class _MicOrb extends StatelessWidget {
final double level;
final MugibCallState state;
final bool muted;
const _MicOrb({required this.level, required this.state, required this.muted});
@override
Widget build(BuildContext context) {
final active = state == MugibCallState.listening ||
state == MugibCallState.connected;
final scale = 1.0 + (active && !muted ? level * 0.4 : 0.0);
final base = muted
? Colors.grey
: (state == MugibCallState.speaking
? Colors.deepPurple
: const Color(0xFF12B886));
return SizedBox(
height: 140,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 96 * scale,
height: 96 * scale,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: base.withOpacity(0.15),
boxShadow: [
if (active && !muted)
BoxShadow(
color: base.withOpacity(0.35),
blurRadius: 24 * (0.5 + level),
spreadRadius: 4 * level,
),
],
),
child: Center(
child: Icon(muted ? Icons.mic_off : Icons.mic,
size: 40, color: base),
),
),
),
);
}
}
/// Horizontal bar that fills with the live mic [level] (0..1).
class _LevelMeter extends StatelessWidget {
final double level;
const _LevelMeter({required this.level});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(children: [
Container(height: 8, color: Colors.grey.withOpacity(0.2)),
FractionallySizedBox(
widthFactor: level.clamp(0.0, 1.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
height: 8,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF12B886), Color(0xFF1677FF)],
),
borderRadius: BorderRadius.circular(8),
),
),
),
]),
),
const SizedBox(height: 6),
const Text('Mic input',
style: TextStyle(fontSize: 11, color: Colors.grey)),
]),
);
}
}
class _Transcript extends StatelessWidget {
final ScrollController scroll;
final List<MugibTranscript> lines;
const _Transcript({required this.scroll, required this.lines});
@override
Widget build(BuildContext context) {
if (lines.isEmpty) {
return const Center(
child: Text('Transcript will appear here during the call.',
style: TextStyle(color: Colors.grey)),
);
}
return ListView.builder(
controller: scroll,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: lines.length,
itemBuilder: (_, i) {
final line = lines[i];
final isUser = line.isUser;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75),
decoration: BoxDecoration(
color: isUser
? const Color(0xFF1677FF)
: Colors.grey.withOpacity(0.15),
borderRadius: BorderRadius.circular(14),
),
child: Text(
line.text,
style: TextStyle(
color: isUser ? Colors.white : Colors.black87, fontSize: 14),
),
),
);
},
);
}
}
class _Controls extends StatelessWidget {
final bool inCall;
final bool connecting;
final bool muted;
final VoidCallback onToggleCall;
final VoidCallback? onToggleMute;
const _Controls({
required this.inCall,
required this.connecting,
required this.muted,
required this.onToggleCall,
required this.onToggleMute,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 28),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RoundButton(
icon: muted ? Icons.mic_off : Icons.mic,
color: muted ? Colors.orange : Colors.blueGrey,
onPressed: onToggleMute,
label: muted ? 'Unmute' : 'Mute',
),
const SizedBox(width: 36),
_RoundButton(
icon: connecting
? Icons.hourglass_top
: (inCall ? Icons.call_end : Icons.call),
color: inCall ? const Color(0xFFE03131) : const Color(0xFF12B886),
large: true,
onPressed: connecting ? null : onToggleCall,
label: inCall ? 'End' : 'Call',
),
],
),
);
}
}
class _RoundButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback? onPressed;
final String label;
final bool large;
const _RoundButton({
required this.icon,
required this.color,
required this.onPressed,
required this.label,
this.large = false,
});
@override
Widget build(BuildContext context) {
final size = large ? 72.0 : 56.0;
final disabled = onPressed == null;
return Column(mainAxisSize: MainAxisSize.min, children: [
Material(
color: disabled ? Colors.grey.shade300 : color,
shape: const CircleBorder(),
elevation: disabled ? 0 : 2,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onPressed,
child: SizedBox(
width: size,
height: size,
child: Icon(icon,
color: disabled ? Colors.grey.shade500 : Colors.white,
size: large ? 32 : 24),
),
),
),
const SizedBox(height: 6),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
]);
}
}