noverachat_flutter 0.5.0
noverachat_flutter: ^0.5.0 copied to clipboard
Flutter bindings for NoveraChat — lifecycle-managed providers and controllers over the headless noverachat_dart core.
example/lib/main.dart
// Minimal example wiring the noverachat_flutter bindings into one chat screen.
//
// Configuration is read at compile time so no secret lives in the source. Run:
//
// flutter run \
// --dart-define=NOVERACHAT_ENDPOINT=https://chat.example.com \
// --dart-define=NOVERACHAT_APP_ID=app_9f8k2x \
// --dart-define=NOVERACHAT_TOKEN=<a per-user JWT from your backend> \
// --dart-define=NOVERACHAT_ROOM=room_123
//
// In a real app, `tokenProvider` fetches a fresh JWT from YOUR backend; here we
// read one from a --dart-define just to keep the example runnable.
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:noverachat_flutter/noverachat_flutter.dart';
const _endpoint =
String.fromEnvironment('NOVERACHAT_ENDPOINT', defaultValue: 'https://chat.example.com');
const _appId =
String.fromEnvironment('NOVERACHAT_APP_ID', defaultValue: 'app_9f8k2x');
const _token = String.fromEnvironment('NOVERACHAT_TOKEN', defaultValue: '');
const _roomId =
String.fromEnvironment('NOVERACHAT_ROOM', defaultValue: 'room_123');
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'noverachat_flutter example',
// Provide a connected NoveraChat to the whole app.
home: NoveraChatScope(
options: ClientOptions(
appId: _appId,
endpoint: _endpoint,
tokenProvider: () async => _token,
),
child: const ChatScreen(roomId: _roomId),
),
);
}
}
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.roomId});
final String roomId;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
// Who "I" am — used only for the reaction toggle demo. In a real app this
// comes from your auth layer (the user the JWT was issued for).
static const _myUserId =
String.fromEnvironment('NOVERACHAT_USER', defaultValue: 'me');
RoomController? _messages;
TypingController? _typing;
final _input = TextEditingController();
ChatMessage? _replyTo;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Build the controllers once, now that NoveraChatScope is above us.
if (_messages == null) {
final chat = NoveraChatScope.of(context);
final room = chat.room(widget.roomId);
_messages = RoomController(room)..loadHistory();
_typing = TypingController(room);
}
}
@override
void dispose() {
_messages?.dispose();
_typing?.dispose();
_input.dispose();
super.dispose();
}
void _send() {
final text = _input.text.trim();
if (text.isEmpty) return;
// Replying? The target id rides along and the banner clears.
_messages!.send(text, replyToId: _replyTo?.id);
setState(() => _replyTo = null);
_input.clear();
}
// File-send demo without a picker dependency: upload a tiny generated
// text file. Swap in file_picker/image_picker bytes in a real app.
void _sendDemoFile() {
final bytes = Uint8List.fromList(
utf8.encode('hello from noverachat_flutter example '
'${DateTime.now().toIso8601String()}'),
);
_messages!.sendFile(
FileSource(bytes: bytes, mime: 'text/plain', name: 'demo.txt'),
);
}
String _subtitle(ChatMessage m) {
final parts = [
m.senderId ?? 'me',
m.status.name,
if (m.messageType != 'TEXT') m.messageType,
if (m.uploadProgress != null)
'uploading ${(m.uploadProgress! * 100).round()}%',
if (m.editedCount > 0) 'edited',
if (m.replyToId != null) '↩ ${m.replyToId}',
for (final r in m.reactions) '${r.key}×${r.userIds.length}',
];
return parts.join(' · ');
}
@override
Widget build(BuildContext context) {
final messages = _messages!;
return Scaffold(
appBar: AppBar(title: Text('Room ${widget.roomId}')),
body: Column(
children: [
Expanded(
child: MessagesBuilder(
controller: messages,
builder: (context, list) => ListView.builder(
// +1 for the scrollback button while older pages remain.
itemCount: list.length + (messages.hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (messages.hasMore && i == 0) {
return TextButton(
onPressed: messages.loadMore,
child: const Text('load older messages'),
);
}
final m = list[i - (messages.hasMore ? 1 : 0)];
return ListTile(
dense: true,
title: Text(m.isDeleted
? '(deleted)'
: m.messageType == 'FILE'
? '📎 ${m.file?.name ?? m.fileId ?? 'file'}'
: (m.content ?? '')),
subtitle: Text(_subtitle(m)),
// Tap: toggle a ❤️ reaction. Long-press: reply to it.
onTap: m.status == ChatMessageStatus.sent
? () => messages.toggleReaction(m.id, 'emoji_heart',
myUserId: _myUserId)
: null,
onLongPress: () => setState(() => _replyTo = m),
);
},
),
),
),
if (_replyTo != null)
ListTile(
dense: true,
leading: const Icon(Icons.reply, size: 16),
title: Text(
'replying to: ${_replyTo!.content ?? _replyTo!.id}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(() => _replyTo = null),
),
),
// "… is typing" indicator.
ListenableBuilder(
listenable: _typing!,
builder: (context, _) => _typing!.isAnyoneTyping
? const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text('typing…', style: TextStyle(fontStyle: FontStyle.italic)),
),
)
: const SizedBox.shrink(),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: _sendDemoFile,
),
Expanded(
child: TextField(
controller: _input,
onChanged: (_) =>
NoveraChatScope.of(context).room(widget.roomId).setTyping(true),
onSubmitted: (_) => _send(),
decoration: const InputDecoration(hintText: 'Message'),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
),
],
),
);
}
}