Skip to main content

hatang_chat 0.3.1 copy "hatang_chat: ^0.3.1" to clipboard
hatang_chat: ^0.3.1 copied to clipboard

Hatang live chat for Flutter apps: a native customer-support chat screen that talks to your Hatang site over REST and WebSocket, with identity, events, presence and push hooks.

example/lib/main.dart

// QA app for the hatang_chat SDK.
//
// `flutter run` talks to Hatang's public demo inbox (lib/demo.dart). Your own site, or a local gateway:
//
// flutter run \
//   --dart-define=HATANG_SITE_ID=site_… \
//   --dart-define=HATANG_APP_KEY=hak_… \
//   --dart-define=HATANG_BASE_URL=http://localhost:3002
//
// Android emulator: `adb reverse tcp:3002 tcp:3002` first, then keep localhost (the socket URL the
// gateway returns uses its CHAT_URL, so 10.0.2.2 reaches the API but not the socket).
//
// Push: add --dart-define=FIREBASE_API_KEY/APP_ID/PROJECT_ID/SENDER_ID (see lib/push.dart).
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:hatang_chat/file_picker.dart';
import 'package:hatang_chat/hatang_chat.dart';

import 'demo.dart';
import 'push.dart';

const _siteIdDefine = String.fromEnvironment('HATANG_SITE_ID');

/// No site given: the public demo, on production.
const usingDemo = _siteIdDefine == '';
const siteId = usingDemo ? demoSiteId : _siteIdDefine;
const appKey = usingDemo
    ? demoAppKey
    : String.fromEnvironment('HATANG_APP_KEY');
const baseUrl = String.fromEnvironment(
  'HATANG_BASE_URL',
  defaultValue: usingDemo ? demoBaseUrl : 'http://localhost:3002',
);

final navigatorKey = GlobalKey<NavigatorState>();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (siteId.isNotEmpty && appKey.isNotEmpty) {
    // autoConnect: false so the UI shows at once; connect() runs in the
    // background and the chips below follow its progress.
    await HatangChat.configure(
      siteId: siteId,
      appKey: appKey,
      baseUrl: Uri.parse(baseUrl),
      navigatorKey: navigatorKey,
      logger: HatangLogger.console,
      autoConnect: false,
      pickFile: hatangFilePicker,
    );
    unawaited(HatangChat.instance.connect());
    unawaited(
      initPush(
        androidFallback: usingDemo ? demoFirebaseAndroid : null,
        iosFallback: usingDemo ? demoFirebaseIos : null,
      ),
    );
  }
  runApp(const ExampleApp());
}

/// Host-side settings the QA app lets you flip.
class ExampleSettings extends ChangeNotifier {
  ThemeMode appTheme = ThemeMode.system;
  HatangChatThemeMode chatTheme = HatangChatThemeMode.site;

  void update(void Function(ExampleSettings s) change) {
    change(this);
    notifyListeners();
  }
}

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  final settings = ExampleSettings();

  @override
  void dispose() {
    settings.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => ListenableBuilder(
    listenable: settings,
    builder: (context, _) => MaterialApp(
      title: 'Hatang Chat QA',
      navigatorKey: navigatorKey,
      themeMode: settings.appTheme,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF0EA5E9),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: const Color(0xFF0EA5E9),
        brightness: Brightness.dark,
        useMaterial3: true,
      ),
      home: HatangChat.isConfigured
          ? HomeTabs(settings: settings)
          : const MissingConfig(),
    ),
  );
}

class MissingConfig extends StatelessWidget {
  const MissingConfig({super.key});

  @override
  Widget build(BuildContext context) => const Scaffold(
    body: Center(
      child: Padding(
        padding: EdgeInsets.all(24),
        child: Text(
          'Pass --dart-define=HATANG_SITE_ID=… and --dart-define=HATANG_APP_KEY=… '
          '(from `just seed-chat-site --json`).',
          textAlign: TextAlign.center,
        ),
      ),
    ),
  );
}

class HomeTabs extends StatefulWidget {
  const HomeTabs({required this.settings, super.key});

  final ExampleSettings settings;

  @override
  State<HomeTabs> createState() => _HomeTabsState();
}

class _HomeTabsState extends State<HomeTabs> {
  int _tab = 0;
  final _log = <String>[];
  StreamSubscription<HatangEvent>? _events;

  @override
  void initState() {
    super.initState();
    _events = HatangChat.instance.events.listen((e) {
      setState(() {
        _log.insert(0, '${TimeOfDay.now().format(context)}  ${_describe(e)}');
        if (_log.length > 30) _log.removeLast();
      });
    });
  }

  @override
  void dispose() {
    unawaited(_events?.cancel());
    super.dispose();
  }

  static String _describe(HatangEvent e) => switch (e) {
    HatangReady(:final visitorId) => 'ready ($visitorId)',
    HatangOpened() => 'opened',
    HatangClosed() => 'closed',
    HatangMessageReceived(:final message) => 'received: ${message.content}',
    HatangMessageSent(:final message) => 'sent: ${message.content}',
    HatangConversationStatus(:final status) => 'status: ${status.name}',
    HatangConversion(:final kind) => 'conversion: ${kind.name}',
    HatangSessionChanged(:final reason) => 'session changed: ${reason.name}',
    HatangProtocolOutdated(:final reason) => 'outdated: $reason',
  };

  @override
  Widget build(BuildContext context) {
    final pages = [
      HomePage(settings: widget.settings, log: _log),
      // Mounted only while selected: a hidden chat must not count as read.
      if (_tab == 1) const EmbeddedChatPage() else const SizedBox.shrink(),
      const IdentifyPage(),
      const PushPage(),
      SettingsPage(settings: widget.settings),
    ];
    return Scaffold(
      appBar: AppBar(title: const Text('Hatang Chat QA')),
      body: IndexedStack(index: _tab, children: pages),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tab,
        onDestinationSelected: (i) => setState(() => _tab = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
          NavigationDestination(
            icon: Icon(Icons.support_agent_outlined),
            label: 'Embedded',
          ),
          NavigationDestination(
            icon: Icon(Icons.person_outline),
            label: 'Identify',
          ),
          NavigationDestination(
            icon: Icon(Icons.notifications_none_outlined),
            label: 'Push',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            label: 'Settings',
          ),
        ],
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({required this.settings, required this.log, super.key});

  final ExampleSettings settings;
  final List<String> log;

  void _openChat(BuildContext context) {
    unawaited(
      Navigator.of(context).push(
        MaterialPageRoute<void>(
          fullscreenDialog: true,
          builder: (_) => HatangChatScreen(themeMode: settings.chatTheme),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final sdk = HatangChat.instance;
    return Stack(
      children: [
        ListView(
          padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                StreamBuilder<HatangConnectionState>(
                  stream: sdk.connectionState,
                  initialData: sdk.currentConnectionState,
                  builder: (context, snap) => Chip(
                    key: const Key('chip-connection'),
                    avatar: Icon(
                      snap.data == HatangConnectionState.connected
                          ? Icons.link
                          : Icons.link_off,
                      size: 18,
                    ),
                    label: Text('socket: ${snap.data?.name ?? '?'}'),
                  ),
                ),
                StreamBuilder<HatangAvailability>(
                  stream: sdk.availability,
                  initialData: sdk.currentAvailability,
                  builder: (context, snap) => Chip(
                    avatar: Icon(
                      Icons.circle,
                      size: 12,
                      color: snap.data?.online ?? false
                          ? Colors.green
                          : Colors.grey,
                    ),
                    label: Text(
                      snap.data?.online ?? false
                          ? 'team online (${snap.data!.agentsOnline})'
                          : 'team away',
                    ),
                  ),
                ),
                ValueListenableBuilder<int>(
                  valueListenable: sdk.unreadCountListenable,
                  builder: (context, unread, _) =>
                      Chip(label: Text('unread: $unread')),
                ),
              ],
            ),
            const SizedBox(height: 16),
            FilledButton.icon(
              key: const Key('open-chat'),
              onPressed: () => _openChat(context),
              icon: const Icon(Icons.chat_bubble_outline),
              label: const Text('Open chat'),
            ),
            const SizedBox(height: 8),
            OutlinedButton(
              onPressed: () => unawaited(HatangChat.open(context)),
              child: const Text('HatangChat.open(context)'),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: () => unawaited(HatangChat.openInbox(context)),
              icon: const Icon(Icons.forum_outlined),
              label: const Text('Conversations (HatangChat.openInbox)'),
            ),
            const SizedBox(height: 8),
            OutlinedButton(
              onPressed: () => sdk.showMessage(
                'This bubble only exists on this phone.',
                senderName: 'QA app',
              ),
              child: const Text('showMessage (local bubble)'),
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton(
                    onPressed: () => unawaited(sdk.connect()),
                    child: const Text('connect'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: OutlinedButton(
                    onPressed: () => unawaited(sdk.disconnect()),
                    child: const Text('disconnect'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            Text('Session', style: Theme.of(context).textTheme.titleSmall),
            StreamBuilder<HatangConnectionState>(
              stream: sdk.connectionState,
              builder: (context, _) {
                final s = sdk.session;
                return SelectableText(
                  'visitor: ${s.visitorId ?? '-'}\n'
                  'conversation: ${s.conversationId ?? '-'}\n'
                  'locale: ${s.locale.code} · identified: ${s.isIdentified}\n'
                  'email: ${s.email ?? '-'} · name: ${s.name ?? '-'}\n'
                  'gateway: $baseUrl',
                  style: Theme.of(context).textTheme.bodySmall,
                );
              },
            ),
            const SizedBox(height: 16),
            Text('Events', style: Theme.of(context).textTheme.titleSmall),
            for (final line in log)
              Text(line, style: Theme.of(context).textTheme.bodySmall),
          ],
        ),
        HatangChatLauncher(onPressed: () => _openChat(context)),
      ],
    );
  }
}

class IdentifyPage extends StatefulWidget {
  const IdentifyPage({super.key});

  @override
  State<IdentifyPage> createState() => _IdentifyPageState();
}

class _IdentifyPageState extends State<IdentifyPage> {
  final _userId = TextEditingController();
  final _email = TextEditingController();
  final _name = TextEditingController();
  final _phone = TextEditingController();
  final _hash = TextEditingController();
  final _event = TextEditingController(text: 'viewed_pricing');

  @override
  void dispose() {
    for (final c in [_userId, _email, _name, _phone, _hash, _event]) {
      c.dispose();
    }
    super.dispose();
  }

  String? _v(TextEditingController c) =>
      c.text.trim().isEmpty ? null : c.text.trim();

  Future<void> _run(String label, Future<void> Function() action) async {
    final messenger = ScaffoldMessenger.of(context);
    try {
      await action();
      messenger.showSnackBar(SnackBar(content: Text('$label: ok')));
    } on Object catch (e) {
      messenger.showSnackBar(SnackBar(content: Text('$label failed: $e')));
    }
  }

  @override
  Widget build(BuildContext context) {
    final sdk = HatangChat.instance;
    Widget field(
      TextEditingController c,
      String label, {
      TextInputType? type,
    }) => Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: TextField(
        controller: c,
        keyboardType: type,
        decoration: InputDecoration(
          labelText: label,
          border: const OutlineInputBorder(),
          isDense: true,
        ),
      ),
    );
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        field(_userId, 'user id'),
        field(_email, 'email', type: TextInputType.emailAddress),
        field(_name, 'name'),
        field(_phone, 'phone', type: TextInputType.phone),
        field(_hash, 'hash (HMAC-SHA256 of user id, from your server)'),
        FilledButton(
          onPressed: () => _run(
            'identify',
            () => sdk.identify(
              HatangUser(
                userId: _v(_userId),
                email: _v(_email),
                name: _v(_name),
                phone: _v(_phone),
                hash: _v(_hash),
              ),
            ),
          ),
          child: const Text('identify'),
        ),
        const Divider(height: 32),
        field(_event, 'event name'),
        OutlinedButton(
          onPressed: () => _run(
            'pushEvent',
            () => sdk.pushEvent(_event.text.trim(), {'source': 'qa'}),
          ),
          child: const Text('pushEvent'),
        ),
        const Divider(height: 32),
        Row(
          children: [
            for (final l in HatangLocale.values) ...[
              Expanded(
                child: OutlinedButton(
                  onPressed: () =>
                      _run('setLocale ${l.code}', () => sdk.setLocale(l)),
                  child: Text('setLocale(${l.code})'),
                ),
              ),
              const SizedBox(width: 8),
            ],
          ],
        ),
        const SizedBox(height: 8),
        OutlinedButton(
          style: OutlinedButton.styleFrom(
            foregroundColor: Theme.of(context).colorScheme.error,
          ),
          onPressed: () => _run('reset', sdk.reset),
          child: const Text('reset (sign out)'),
        ),
      ],
    );
  }
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({required this.settings, super.key});

  final ExampleSettings settings;

  @override
  Widget build(BuildContext context) => ListenableBuilder(
    listenable: settings,
    builder: (context, _) => ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('App theme', style: Theme.of(context).textTheme.titleSmall),
        const SizedBox(height: 8),
        SegmentedButton<ThemeMode>(
          segments: const [
            ButtonSegment(value: ThemeMode.system, label: Text('system')),
            ButtonSegment(value: ThemeMode.light, label: Text('light')),
            ButtonSegment(value: ThemeMode.dark, label: Text('dark')),
          ],
          selected: {settings.appTheme},
          onSelectionChanged: (v) =>
              settings.update((s) => s.appTheme = v.single),
        ),
        const SizedBox(height: 24),
        Text('Chat theme mode', style: Theme.of(context).textTheme.titleSmall),
        const SizedBox(height: 8),
        SegmentedButton<HatangChatThemeMode>(
          segments: [
            for (final m in HatangChatThemeMode.values)
              ButtonSegment(value: m, label: Text(m.name)),
          ],
          selected: {settings.chatTheme},
          onSelectionChanged: (v) =>
              settings.update((s) => s.chatTheme = v.single),
        ),
        const SizedBox(height: 24),
        Text(
          'site $siteId\nSDK ${HatangAppInfo().sdk}\nprotocol $protocolVersion',
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    ),
  );
}

/// The chat inside a host screen: `HatangConversationView` under this app's
/// own app bar and above its navigation bar, driven by a controller.
class EmbeddedChatPage extends StatefulWidget {
  const EmbeddedChatPage({super.key});

  @override
  State<EmbeddedChatPage> createState() => _EmbeddedChatPageState();
}

class _EmbeddedChatPageState extends State<EmbeddedChatPage> {
  late final HatangChatController _controller = HatangChatController(
    HatangChat.instance.engine,
  );

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      HatangConversationView(controller: _controller);
}
0
likes
0
points
730
downloads

Publisher

verified publisherhatang.dev

Weekly Downloads

Hatang live chat for Flutter apps: a native customer-support chat screen that talks to your Hatang site over REST and WebSocket, with identity, events, presence and push hooks.

Repository (GitHub)
View/report issues

Topics

#chat #live-chat #customer-support #messaging #websocket

License

unknown (license)

Dependencies

clock, device_info_plus, file_picker, flutter, flutter_image_compress, flutter_secure_storage, http, http_parser, image_picker, meta, package_info_plus, shared_preferences, url_launcher, web_socket_channel

More

Packages that depend on hatang_chat