Files
GroundedHelper/frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
alvocool 4b71257429 Add groups with shared goals, objectives and chat; drop the water tracker
Groups let people keep each other honest: create one or join with a
six-letter invite code. Each group has shared goals split into measurable
objectives (km, sessions, words) that every member logs toward, a
leaderboard of kept rate and streak with nudges, and a group chat where
check-ins and nudges appear as cards beside the talk. The open chat polls
for new messages.

Reached from a new Groups tab (Training moves into the day rail), a Home
"Your groups" rail that opens the chat, and a Group tile in the add sheet.
Mock mode seeds two groups, a joinable one and members who reply.

The water tracker is removed; it did not fit the app. Bottom sheets are
now square app-wide via the theme.

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

318 lines
9.9 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/initial/GroupGoalRequest.dart';
import '../../about/external/initial/ObjectiveDraft.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectGroupGoalEditor.dart';
import 'GroupGoalEditor.dart';
import 'ViewGroupGoalEditor.dart';
/// The three fields of one objective while it is being typed.
typedef _ObjectiveFields = ({
TextEditingController title,
TextEditingController target,
TextEditingController unit,
});
class GroupGoalEditorState extends State<GroupGoalEditor>
implements ConnectGroupGoalEditor {
ViewGroupGoalEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _title = TextEditingController();
final TextEditingController _description = TextEditingController();
final List<_ObjectiveFields> _objectives = <_ObjectiveFields>[];
DateTime? _targetDate;
@override
void initState() {
super.initState();
_objectives.add(_blankObjective());
}
@override
void dispose() {
_title.dispose();
_description.dispose();
for (_ObjectiveFields fields in _objectives) {
fields.title.dispose();
fields.target.dispose();
fields.unit.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupGoalEditor>.reactive(
viewModelBuilder: () => ViewGroupGoalEditor(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() {}
_ObjectiveFields _blankObjective() {
return (
title: TextEditingController(),
target: TextEditingController(),
unit: TextEditingController(),
);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onAddObjective() {
setState(() {
_objectives.add(_blankObjective());
});
}
void _onRemoveObjective(_ObjectiveFields fields) {
setState(() {
_objectives.remove(fields);
});
fields.title.dispose();
fields.target.dispose();
fields.unit.dispose();
}
void _onPickTarget() async {
final DateTime now = DateTime.now();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _targetDate ?? now.add(const Duration(days: 30)),
firstDate: now,
lastDate: now.add(const Duration(days: 365 * 3)),
);
if (picked == null) {
return;
}
setState(() {
_targetDate = picked;
});
}
void _onClearTarget() {
setState(() {
_targetDate = null;
});
}
void _onSave() {
if (_formKey.currentState?.validate() != true) {
return;
}
_model?.save(GroupGoalRequest(
groupId: widget.group.id,
title: _title.text.trim(),
description: _description.text.trim(),
targetDate: _targetDate?.toIso8601String() ?? "",
colourHex: widget.group.colourHex,
objectives: _objectives
.map((fields) => ObjectiveDraft(
title: fields.title.text.trim(),
unit: fields.unit.text.trim(),
target: double.tryParse(fields.target.text.trim()) ?? 0,
))
.toList(),
));
}
String? _validateTarget(String? value) {
final double? target = double.tryParse((value ?? "").trim());
if (target == null || target <= 0) {
return "A number above 0.";
}
return null;
}
String? _validateUnit(String? value) {
return Validators.required(value, "Unit");
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: widget.group.name,
title: "Shared goal",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("What are you\nall going for?"),
const SizedBox(height: 24),
inputField(
"Goal",
_title,
hint: "Run 200 km this month, finish the drafts…",
validator: Validators.title,
),
const SizedBox(height: 16),
inputField(
"Why it matters",
_description,
hint: "Optional",
maxLines: 2,
),
const SizedBox(height: 28),
sectionBreak("Objectives",
caption: "what everyone logs toward"),
text(
"Break the goal into numbers people can add to: kilometres, sessions, pages, hours. Each member logs their share and the totals add up.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 14),
for (int index = 0; index < _objectives.length; index++)
_objectiveEditor(index, _objectives[index]),
outlinedActionButton("Add objective", _onAddObjective,
icon: CupertinoIcons.add),
const SizedBox(height: 28),
sectionBreak("Deadline", caption: "optional"),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: selectField(
"Target date",
_targetDate == null ? "" : formatDate(_targetDate),
_onPickTarget,
hint: "No deadline",
icon: CupertinoIcons.calendar,
),
),
if (_targetDate != null) ...[
const SizedBox(width: 8),
iconButton(
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
_onClearTarget,
bordered: true,
background: colorWhite,
size: 50,
),
],
],
),
const SizedBox(height: 32),
roundedCornerButton("Share with the group", _onSave,
icon: CupertinoIcons.person_3_fill),
],
),
),
);
}
Widget _objectiveEditor(int index, _ObjectiveFields fields) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
text("OBJECTIVE ${index + 1}", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const Spacer(),
if (_objectives.length > 1)
iconButton(
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
() => _onRemoveObjective(fields),
size: 36,
),
],
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
inputField("Name", fields.title,
hint: "Distance run", validator: Validators.title),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: inputField(
"Target",
fields.target,
hint: "200",
validator: _validateTarget,
keyboard: const TextInputType.numberWithOptions(
decimal: true),
formatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
],
),
),
const SizedBox(width: 10),
Expanded(
child: inputField("Unit", fields.unit,
hint: "km", validator: _validateUnit),
),
],
),
],
),
),
],
),
),
);
}
// ── ConnectGroupGoalEditor ────────────────────────────────────────────────
@override
void onGoalSaved() {
Navigator.pop(context, true);
}
}