import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/GroupMessage.dart'; import '../../about/external/initial/GroupMessageRequest.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/AmbientBackground.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/MemberAvatar.dart'; import '../../designs/Responsive.dart'; import '../../designs/input/InputFields.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import 'ConnectGroupChat.dart'; import 'GroupChat.dart'; import 'ViewGroupChat.dart'; class GroupChatState extends State implements ConnectGroupChat { ViewGroupChat? _model; List _messages = []; final TextEditingController _input = TextEditingController(); Timer? _poll; /// How often the open chat looks for new messages. static const Duration _pollEvery = Duration(seconds: 4); static const List _quickReplies = [ "Done for today", "On my way", "Need a push", "Who's in tomorrow?", ]; @override void dispose() { _poll?.cancel(); _input.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewGroupChat(context, this), onViewModelReady: (viewModel) { _model = viewModel; _initiate(); }, builder: (context, viewModel, child) => LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Responsive( mobile: _mobileView(constraints), tablet: _mobileView(constraints), desktop: _mobileView(constraints), ); }, ), ); } void _initiate() { _model?.loadMessages(widget.group.id); _poll = Timer.periodic(_pollEvery, _onPoll); } // ── Handlers ────────────────────────────────────────────────────────────── void _onBack() { Navigator.pop(context, true); } void _onPoll(Timer timer) { _model?.refresh(widget.group.id); } void _onSend(String value) { final String body = value.trim(); if (body.isEmpty) { return; } _input.clear(); _model?.send(GroupMessageRequest(groupId: widget.group.id, body: body)); } void _onSendPressed() { _onSend(_input.text); } void _onQuickReply(String reply) { _onSend(reply); } // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { final List newestFirst = _messages.reversed.toList(); return Scaffold( backgroundColor: colorAmbientMid, body: AmbientBackground( child: Column( children: [ _topBar(), Expanded( child: ListView.builder( reverse: true, padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), itemCount: newestFirst.length, itemBuilder: (context, index) { final GroupMessage message = newestFirst[index]; final GroupMessage? older = index + 1 < newestFirst.length ? newestFirst[index + 1] : null; final bool newDay = older == null || !_sameDay(older.sentAt, message.sentAt); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (newDay) _dayLabel(message.sentAt), _message(message, older), ], ); }, ), ), _composer(), ], ), ), ); } Widget _topBar() { final Color accent = goalColor(widget.group.colourHex); return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color.lerp(accent, colorHeaderBottom, 0.55)!, colorHeaderBottom, ], ), ), padding: EdgeInsets.fromLTRB( 16, MediaQuery.of(context).padding.top + 10, 16, 14), child: Row( children: [ glassCircle(CupertinoIcons.arrow_left, _onBack, size: 46, onDark: true), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(widget.group.name, 19, TextType.Medium, color: colorWhite, maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 3), text( widget.group.members .map((member) => member.me ? "you" : member.name) .join(", "), 12, TextType.Regular, color: colorHeaderMuted, maxLines: 1, overflow: TextOverflow.ellipsis), ], ), ), const SizedBox(width: 10), avatarStack( widget.group.members.map((member) => member.name).toList(), 30, max: 3, border: colorHeaderBottom), ], ), ); } Widget _dayLabel(DateTime? moment) { return Padding( padding: const EdgeInsets.symmetric(vertical: 14), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), color: colorWhite.withValues(alpha: 0.7), child: text(_dayName(moment), 11, TextType.Medium, color: colorInkSoft), ), ), ); } /// Text as bubbles, check-ins and nudges as cards in the thread, system /// notes as a quiet centred line. Widget _message(GroupMessage message, GroupMessage? older) { if (message.isSystem) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: text( "${message.mine ? "You" : message.authorName} ${message.body}", 12, TextType.Regular, color: colorInkSoft, align: TextAlign.center, ), ); } if (message.isCheckIn || message.isNudge) { return _eventCard(message); } // Consecutive lines from one person share a single name and portrait. final bool grouped = older != null && older.authorId == message.authorId && !older.isSystem && !older.isCheckIn && !older.isNudge && _sameDay(older.sentAt, message.sentAt); if (message.mine) { return Padding( padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64), child: Align( alignment: Alignment.centerRight, child: Container( padding: const EdgeInsets.fromLTRB(14, 11, 14, 10), color: colorNight, child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ text(message.body, 15, TextType.Regular, color: colorWhite, height: 1.4), const SizedBox(height: 4), text(_time(message.sentAt), 10, TextType.Regular, color: colorHeaderMuted), ], ), ), ), ); } final Color tone = _authorColour(message.authorName); return Padding( padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ grouped ? const SizedBox(width: 34) : memberAvatar(message.authorName, 34), const SizedBox(width: 8), Flexible( child: Container( padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), decoration: BoxDecoration( color: colorWhite, border: Border.all(color: colorGlassBorder), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (!grouped) ...[ text(message.authorName, 12, TextType.Medium, color: tone), const SizedBox(height: 3), ], text(message.body, 15, TextType.Regular, color: colorInk, height: 1.4), const SizedBox(height: 4), text(_time(message.sentAt), 10, TextType.Regular, color: colorInkSoft), ], ), ), ), ], ), ); } /// A check-in or a nudge, set apart from the talk so the work is visible. Widget _eventCard(GroupMessage message) { final bool checkIn = message.isCheckIn; final String who = message.mine ? "You" : message.authorName; return Padding( padding: const EdgeInsets.only(top: 12), child: Container( padding: const EdgeInsets.fromLTRB(12, 12, 14, 12), decoration: BoxDecoration( color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd, border: Border( left: BorderSide( color: checkIn ? colorPositive : colorBarStandard, width: 4, ), ), ), child: Row( children: [ memberAvatar(message.authorName, 34), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Icon( checkIn ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.hand_point_right_fill, size: 13, color: checkIn ? colorPositive : colorWarning, ), const SizedBox(width: 5), text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold, color: checkIn ? colorPositive : colorWarning, letterSpacing: 1.0), const Spacer(), text(_time(message.sentAt), 10, TextType.Regular, color: colorInkSoft), ], ), const SizedBox(height: 4), text("$who ${message.body}", 14, TextType.Regular, color: colorInk, height: 1.4), ], ), ), ], ), ), ); } Widget _composer() { return Container( color: colorWhite, padding: EdgeInsets.fromLTRB( 0, 10, 0, MediaQuery.of(context).padding.bottom + 10), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 38, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _quickReplies.length, separatorBuilder: (context, index) => const SizedBox(width: 8), itemBuilder: (context, index) => GestureDetector( onTap: () => _onQuickReply(_quickReplies[index]), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14), alignment: Alignment.center, color: colorAmbientMid, child: text(_quickReplies[index], 13, TextType.Regular, color: colorInk), ), ), ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 10, 0), child: Row( children: [ Expanded( child: quickAddField(_input, "Message the group", _onSend), ), const SizedBox(width: 8), GestureDetector( onTap: _onSendPressed, child: Container( width: 50, height: 50, color: colorNight, child: Icon(CupertinoIcons.paperplane_fill, size: 20, color: colorLime), ), ), ], ), ), ], ), ); } // ── Helpers ─────────────────────────────────────────────────────────────── Color _authorColour(String name) { return goalAccents[sceneSeed(name) % goalAccents.length]; } bool _sameDay(DateTime? a, DateTime? b) { if (a == null || b == null) return false; final DateTime x = a.toLocal(); final DateTime y = b.toLocal(); return x.year == y.year && x.month == y.month && x.day == y.day; } String _dayName(DateTime? moment) { if (moment == null) return ""; final DateTime now = DateTime.now(); if (_sameDay(moment, now)) return "Today"; if (_sameDay(moment, now.subtract(const Duration(days: 1)))) { return "Yesterday"; } return formatDate(moment.toLocal()); } String _time(DateTime? moment) { if (moment == null) return ""; final DateTime local = moment.toLocal(); return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}"; } // ── ConnectGroupChat ────────────────────────────────────────────────────── @override void onMessagesLoaded(List messages) { if (!mounted) return; setState(() { _messages = messages; }); } @override void onMessageSent(GroupMessage message) { if (!mounted) return; setState(() { _messages = [..._messages, message]; }); } @override void onSendFailed(String body) { if (!mounted) return; _input.text = body; } }