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
This commit is contained in:
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user