hatang_chat 0.3.2
hatang_chat: ^0.3.2 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);
}