Files
GroundedHelper/frontend/lib/Grounded/see/group/GroupChatState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

483 lines
16 KiB
Dart

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<GroupChat> implements ConnectGroupChat {
ViewGroupChat? _model;
List<GroupMessage> _messages = <GroupMessage>[];
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<String> _quickReplies = <String>[
"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<ViewGroupChat>.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<GroupMessage> 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(
borderRadius: const BorderRadius.all(Radius.circular(12)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
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),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.7),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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),
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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(
borderRadius: const BorderRadius.all(Radius.circular(12)),
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(
borderRadius: const BorderRadius.all(Radius.circular(12)),
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(
decoration: BoxDecoration(
color: colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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,
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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,
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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<GroupMessage> messages) {
if (!mounted) return;
setState(() {
_messages = messages;
});
}
@override
void onMessageSent(GroupMessage message) {
if (!mounted) return;
setState(() {
_messages = <GroupMessage>[..._messages, message];
});
}
@override
void onSendFailed(String body) {
if (!mounted) return;
_input.text = body;
}
}