locality_social_cloud_chat 1.8.0
locality_social_cloud_chat: ^1.8.0 copied to clipboard
A chat based on Locality Social Cloud that supported End-to-End encrypted Chats and GroupChats and Chat Message state management.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:locality_core/locality_core.dart';
import 'package:locality_social_cloud_chat/locality_social_cloud_chat.dart';
import 'package:locality_social_cloud_chat/model/framework/chat_message.dart';
import 'package:locality_social_cloud_chat/model/framework/chat_message_factory.dart';
import 'package:locality_social_cloud_chat/model/topic/key_exchange_lobby.dart';
import 'package:locality_social_cloud_chat/model/topic/messaging_topic.dart';
import 'package:provider/provider.dart';
// ── Test realm ──────────────────────────────────────────────────────────────
const kRealmId = 'ptr2';
const kRealmSecret = 'h3w1uzXhmHc4JvZi6ZE6bGXlPPISH/t26Pb6vtGzffE=';
const kServerRoomTopic = 'ServerRoom';
// ── Colors ──────────────────────────────────────────────────────────────────
const kAccent = Color(0xFF007AFF);
const kNavy = Color(0xFF002D52);
const kPageBg = Color(0xFFF2F4F7);
const kOwnBubble = Color(0xFF002D52);
const kOtherBubble = Color(0xFFE8EEF4);
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ChatExampleApp());
}
class ChatExampleApp extends StatelessWidget {
const ChatExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'locality_social_cloud_chat example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kNavy),
useMaterial3: true,
scaffoldBackgroundColor: kPageBg,
),
home: const LoginPage(),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AppMessage – immer an Broadcast gebunden
// text aus getPayload(), senderId nur aus broadcast.senderId
// ═══════════════════════════════════════════════════════════════════════════
class AppMessage extends ChatMessage {
final Broadcast broadcast;
bool delivered;
final List<String> seenByUsers;
AppMessage({
required this.broadcast,
this.delivered = false,
List<String>? seenByUsers,
}) : seenByUsers = seenByUsers ?? [];
Map<String, dynamic> get _payload {
if (!broadcast.hasUnencryptedPayload()) return const {};
return broadcast.getPayload();
}
/// Sender steht in der Payload (broadcast.senderId ist ungenutzt).
String get senderId {
final p = _payload;
return (p['senderId'] ?? p['userId'] ?? p['user_id'] ?? '')
.toString()
.trim();
}
String get text {
final p = _payload;
return p['message']?.toString() ?? p['content']?.toString() ?? '';
}
int get sequenceId => broadcast.sequenceId ?? 0;
int get clientTimestamp => broadcast.clientTimestamp;
int? get servertime => broadcast.servertime;
BroadcastStatus get status => broadcast.status;
DateTime get timestamp {
final ms = broadcast.servertime ?? broadcast.clientTimestamp;
return DateTime.fromMillisecondsSinceEpoch(ms);
}
bool isOwn(String myUserId) {
final me = myUserId.trim();
final them = senderId.trim();
if (me.isEmpty || them.isEmpty) return false;
return them == me;
}
@override
String? getId() => broadcast.uuid;
@override
void setId(String id) {}
@override
String getMessageType() => 'text';
@override
void setDelivered() {
delivered = true;
notifyListeners();
}
@override
void setSeen(String byUserId) {
if (!seenByUsers.contains(byUserId)) {
seenByUsers.add(byUserId);
notifyListeners();
}
}
@override
void addReaction(dynamic event) {}
@override
Map<String, dynamic> toMap() => {
'senderId': senderId,
'userId': senderId,
'message': text,
'content': text,
'type': 'text',
};
}
/// Ausgehend – analog SimpleMessage; Framework erzeugt den Broadcast.
class OutboundMessage extends ChatMessage {
final String senderId;
final String text;
String? _id;
OutboundMessage({required this.senderId, required this.text});
@override
String? getId() => _id;
@override
void setId(String id) => _id = id;
@override
String getMessageType() => 'text';
@override
void setDelivered() {}
@override
void setSeen(String byUserId) {}
@override
void addReaction(dynamic event) {}
@override
Map<String, dynamic> toMap() => {
'senderId': senderId,
'userId': senderId,
'message': text,
'content': text,
'type': 'text',
};
}
class AppChatMessageFactory implements ChatMessageFactory {
@override
ChatMessage createFrom(Broadcast evt) {
return AppMessage(
broadcast: evt,
delivered: evt.status == BroadcastStatus.confirmed,
);
}
}
String sessionUserId(AuthenticatedSessionUser user) {
try {
final id = user.publicUser.id;
if (id.isNotEmpty) return id;
} catch (_) {}
try {
final id = (user as dynamic).id?.toString();
if (id != null && id.isNotEmpty) return id;
} catch (_) {}
return user.toString();
}
// ═══════════════════════════════════════════════════════════════════════════
// Session
// ═══════════════════════════════════════════════════════════════════════════
class ChatSession extends ChangeNotifier {
final LocalitySocialCloud cloud;
final AuthenticatedSessionUser user;
final KeyExchangeLobby serverRoom;
final factory = AppChatMessageFactory();
final Map<String, MessagingTopic> openTopics = {};
ChatSession({
required this.cloud,
required this.user,
required this.serverRoom,
});
String get userId => sessionUserId(user);
List<PublicUser> get presentUsers {
try {
final map = serverRoom.publicKeyByUserId;
final me = userId;
final out = <PublicUser>[];
map.forEach((key, u) {
final id = u.id.isNotEmpty ? u.id : key;
if (id.isNotEmpty && id != me) out.add(u);
});
return out;
} catch (e) {
debugPrint('presentUsers: $e');
return const [];
}
}
void refreshLobby() => notifyListeners();
Future<MessagingTopic> openChatWith(PublicUser peer) async {
final peerId = peer.id;
if (openTopics.containsKey(peerId)) return openTopics[peerId]!;
final topic = await LocalitySocialCloudChat.getUserChat(
user,
peer,
factory,
);
openTopics[peerId] = topic;
topic.addListener(notifyListeners);
notifyListeners();
return topic;
}
List<AppMessage> messagesOf(MessagingTopic topic) {
final out = <AppMessage>[];
try {
final dynamic list = (topic as dynamic).messageList;
if (list is! Iterable) return out;
for (final m in list) {
if (m is AppMessage) {
debugPrint(
'msg uuid=${m.getId()} sender=${m.senderId} seq=${m.sequenceId} text=${m.text}',
);
out.add(m);
}
}
out.sort((a, b) {
final c = a.sequenceId.compareTo(b.sequenceId);
if (c != 0) return c;
return a.clientTimestamp.compareTo(b.clientTimestamp);
});
} catch (e) {
debugPrint('messagesOf: $e');
}
return out;
}
String? previewOf(MessagingTopic? topic) {
if (topic == null) return null;
final m = messagesOf(topic);
return m.isEmpty ? null : m.last.text;
}
@override
void dispose() {
for (final t in openTopics.values) {
t.removeListener(notifyListeners);
}
super.dispose();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Login
// ═══════════════════════════════════════════════════════════════════════════
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _register = true;
bool _loading = false;
String? _error;
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _go() async {
final name = _user.text.trim();
final pass = _pass.text;
if (name.isEmpty || pass.isEmpty) {
setState(() => _error = 'Benutzername und Passwort nötig');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final cloud = await LocalitySocialCloud.startTest(
userCredentials: UserCredentials(name, pass),
developerCredentials: DeveloperCredentials(
realmId: kRealmId,
realmSecret: kRealmSecret,
),
isRegistration: _register,
);
final user = cloud.getLoggedInUser();
debugPrint('Logged in: ${sessionUserId(user)}');
final lobby =
await LocalitySocialCloudChat.getKeyExchangeLobby(kServerRoomTopic);
if (!lobby.containsMe(user)) {
lobby.announcePresence(user);
}
if (!mounted) return;
final session = ChatSession(
cloud: cloud,
user: user,
serverRoom: lobby,
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => ChangeNotifierProvider.value(
value: session,
child: const ServerRoomPage(),
),
),
);
} catch (e, st) {
debugPrint('$e\n$st');
if (mounted) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.forum_rounded, size: 64, color: kAccent),
const SizedBox(height: 12),
const Text(
'ServerRoom Chat',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: kNavy,
),
),
const Text(
'locality_social_cloud_chat example',
style: TextStyle(color: Colors.black54),
),
const SizedBox(height: 28),
TextField(
controller: _user,
decoration: const InputDecoration(
labelText: 'Benutzername',
border: OutlineInputBorder(),
),
enabled: !_loading,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _pass,
decoration: const InputDecoration(
labelText: 'Passwort',
border: OutlineInputBorder(),
),
obscureText: true,
enabled: !_loading,
onSubmitted: (_) => _go(),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Neu registrieren'),
value: _register,
onChanged:
_loading ? null : (v) => setState(() => _register = v),
),
if (_error != null)
Text(_error!,
style: const TextStyle(color: Colors.red, fontSize: 13)),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: Colors.white,
),
onPressed: _loading ? null : _go,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_register ? 'Registrieren & Login' : 'Login'),
),
),
],
),
),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ServerRoom
// ═══════════════════════════════════════════════════════════════════════════
class ServerRoomPage extends StatefulWidget {
const ServerRoomPage({super.key});
@override
State<ServerRoomPage> createState() => _ServerRoomPageState();
}
class _ServerRoomPageState extends State<ServerRoomPage> {
late final Timer _poll;
@override
void initState() {
super.initState();
_poll = Timer.periodic(const Duration(seconds: 2), (_) {
if (mounted) context.read<ChatSession>().refreshLobby();
});
}
@override
void dispose() {
_poll.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = context.watch<ChatSession>();
final users = session.presentUsers;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: kNavy,
elevation: 0.5,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('ServerRoom', style: TextStyle(fontSize: 18)),
Text(
'Du: ${session.userId}',
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
],
),
),
body: users.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'Noch niemand sonst im ServerRoom.\n\n'
'Zweites Fenster / Gerät mit anderem Account öffnen.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black54, fontSize: 15),
),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: users.length,
itemBuilder: (context, i) {
final peer = users[i];
final topic = session.openTopics[peer.id];
final preview =
session.previewOf(topic) ?? 'Online · tippen zum Chatten';
return _ChatListTile(
title: peer.id,
preview: preview,
onTap: () async {
final t = await session.openChatWith(peer);
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: session),
ChangeNotifierProvider<MessagingTopic>.value(
value: t,
),
],
child: ConversationPage(peerId: peer.id),
),
),
);
},
);
},
),
);
}
}
class _ChatListTile extends StatelessWidget {
final String title;
final String preview;
final VoidCallback onTap;
const _ChatListTile({
required this.title,
required this.preview,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Container(
height: 64,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: kNavy.withOpacity(0.12),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
alignment: Alignment.center,
child: Text(
title.isNotEmpty ? title[0].toUpperCase() : '?',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: kNavy,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: kNavy,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
preview,
style: TextStyle(
fontSize: 12,
color: Colors.black.withOpacity(0.45),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Conversation
// ═══════════════════════════════════════════════════════════════════════════
class ConversationPage extends StatefulWidget {
final String peerId;
const ConversationPage({super.key, required this.peerId});
@override
State<ConversationPage> createState() => _ConversationPageState();
}
class _ConversationPageState extends State<ConversationPage> {
final _text = TextEditingController();
final _scroll = ScrollController();
int _lastMsgCount = 0;
bool _stickToBottom = true;
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_text.dispose();
_scroll.dispose();
super.dispose();
}
/// reverse:true → pixels ≈ 0 ist unten. Wenn der User hochscrollt, nicht mehr auto-jumpen.
void _onScroll() {
if (!_scroll.hasClients) return;
_stickToBottom = _scroll.position.pixels < 80;
}
void _scrollToBottom({bool force = false}) {
if (!force && !_stickToBottom) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return;
_scroll.animateTo(
0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
});
}
Future<void> _send(
MessagingTopic topic,
AuthenticatedSessionUser user,
) async {
final t = _text.text.trim();
if (t.isEmpty) return;
_text.clear();
_stickToBottom = true;
await topic.sendMessage(
OutboundMessage(senderId: sessionUserId(user), text: t),
);
_scrollToBottom(force: true);
}
@override
Widget build(BuildContext context) {
final session = context.watch<ChatSession>();
final topic = context.watch<MessagingTopic>();
final messages = session.messagesOf(topic);
// Nur bei neuer Nachricht und wenn User unten klebt (oder selbst gesendet hat)
if (messages.length != _lastMsgCount) {
final grew = messages.length > _lastMsgCount;
_lastMsgCount = messages.length;
if (grew) _scrollToBottom();
}
return Scaffold(
backgroundColor: kPageBg,
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: kNavy,
elevation: 0.5,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.peerId, style: const TextStyle(fontSize: 16)),
const Text(
'MessagingTopic',
style: TextStyle(fontSize: 11, color: Colors.black54),
),
],
),
),
body: Column(
children: [
Expanded(
child: messages.isEmpty
? const Center(
child: Text(
'Noch keine Nachrichten',
style: TextStyle(color: Colors.black54),
),
)
: ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final m = messages[messages.length - 1 - index];
final own = m.isOwn(session.userId);
return _Bubble(
text: m.text,
isOwn: own,
senderLabel: own ? null : (m.senderId.isEmpty ? '?' : m.senderId),
time: m.timestamp,
delivered: m.delivered ||
m.status == BroadcastStatus.confirmed,
seen: m.seenByUsers.isNotEmpty,
);
},
),
),
_InputBar(
controller: _text,
onSend: () => _send(topic, session.user),
),
],
),
);
}
}
class _Bubble extends StatelessWidget {
final String text;
final bool isOwn;
final String? senderLabel;
final DateTime time;
final bool delivered;
final bool seen;
const _Bubble({
required this.text,
required this.isOwn,
required this.time,
this.senderLabel,
this.delivered = false,
this.seen = false,
});
String _fmt(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
return '$h:$m';
}
@override
Widget build(BuildContext context) {
return Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.88,
),
decoration: BoxDecoration(
color: isOwn ? kOwnBubble : kOtherBubble,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isOwn ? 16 : 4),
bottomRight: Radius.circular(isOwn ? 4 : 16),
),
),
child: Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (!isOwn && senderLabel != null) ...[
Text(
senderLabel!,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: kAccent,
),
),
const SizedBox(height: 2),
],
Text(
text,
style: TextStyle(
color: isOwn ? Colors.white : kNavy,
fontSize: 14,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_fmt(time),
style: TextStyle(
fontSize: 10,
color: isOwn ? Colors.white70 : Colors.black45,
),
),
if (isOwn) ...[
const SizedBox(width: 4),
Icon(
seen
? Icons.done_all
: (delivered ? Icons.done_all : Icons.done),
size: 14,
color: seen ? const Color(0xFF63C82F) : Colors.white70,
),
],
],
),
],
),
),
);
}
}
class _InputBar extends StatelessWidget {
final TextEditingController controller;
final VoidCallback onSend;
const _InputBar({required this.controller, required this.onSend});
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Enter ohne Shift → senden; Shift+Enter → Zeilenumbruch (Standard)
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
final shift = HardwareKeyboard.instance.isShiftPressed;
if (!shift) {
onSend();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xCC1A1A1A),
borderRadius: BorderRadius.circular(24),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: 40,
maxHeight: 140,
),
child: Focus(
onKeyEvent: _onKey,
child: TextField(
controller: controller,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: const InputDecoration(
hintText: 'Nachricht…',
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: null,
),
),
),
),
IconButton(
onPressed: onSend,
icon: const Icon(Icons.send_rounded, color: kAccent),
),
],
),
),
);
}
}