mugib_sdk 1.0.23 copy "mugib_sdk: ^1.0.23" to clipboard
mugib_sdk: ^1.0.23 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)),
    ]);
  }
}
1
likes
150
points
1.1k
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Official Flutter SDK for Mugib chat sessions, voice agents, and session payload with one apiKey.

Homepage
Repository (GitHub)
View/report issues

Topics

#ai #chatbot #voice #sdk #mugib-voice

License

MIT (license)

Dependencies

audio_session, flutter, flutter_pcm_sound, http, record, web_socket_channel

More

Packages that depend on mugib_sdk