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
318 lines
9.9 KiB
Dart
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);
|
|
}
|
|
}
|