Skip to main content

locality_social_cloud_chat 1.8.0 copy "locality_social_cloud_chat: ^1.8.0" to clipboard
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),
            ),
          ],
        ),
      ),
    );
  }
}
2
likes
150
points
280
downloads

Documentation

API reference

Publisher

verified publisherlocality.media

Weekly Downloads

A chat based on Locality Social Cloud that supported End-to-End encrypted Chats and GroupChats and Chat Message state management.

Homepage
Repository
View/report issues

License

BSD-3-Clause (license)

Dependencies

crypto, flutter, locality_core, m511chacha20, secure_local_key_value_storage

More

Packages that depend on locality_social_cloud_chat