mugib_sdk 1.0.12 copy "mugib_sdk: ^1.0.12" to clipboard
mugib_sdk: ^1.0.12 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
0
points
1.1k
downloads

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

unknown (license)

Dependencies

audio_session, flutter, flutter_pcm_sound, http, record, web_socket_channel

More

Packages that depend on mugib_sdk