Files
GroundedHelper/frontend/lib/Grounded/see/goal/GoalEditorState.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

399 lines
12 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/initial/GoalRequest.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/NotificationType.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 'ConnectGoalEditor.dart';
import 'GoalEditor.dart';
import 'ViewGoalEditor.dart';
class GoalEditorState extends State<GoalEditor> implements ConnectGoalEditor {
ViewGoalEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _title = TextEditingController();
final TextEditingController _description = TextEditingController();
final TextEditingController _category = TextEditingController();
CommitmentClass _defaultClass = CommitmentClass.Standard;
Color _accent = goalAccents.first;
DateTime? _targetDate;
bool get _editing {
return widget.goal != null;
}
@override
void initState() {
super.initState();
final goal = widget.goal;
if (goal != null) {
_title.text = goal.title;
_description.text = goal.description;
_category.text = goal.category;
_defaultClass = goal.defaultClass;
_targetDate = goal.targetDate;
if (goal.colourHex.isNotEmpty) {
_accent = goalColor(goal.colourHex);
}
}
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGoalEditor>.reactive(
viewModelBuilder: () => ViewGoalEditor(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() {}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onTitleChanged(String value) {
setState(() {});
}
void _onAccentSelected(Color colour) {
setState(() {
_accent = colour;
});
}
void _onClassSelected(CommitmentClass value) {
setState(() {
_defaultClass = value;
});
}
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 * 5)),
);
if (picked == null) {
return;
}
setState(() {
_targetDate = picked;
});
}
void _onClearTarget() {
setState(() {
_targetDate = null;
});
}
void _onSave() {
if (_formKey.currentState?.validate() != true) {
return;
}
_model?.save(GoalRequest(
id: widget.goal?.id,
title: _title.text.trim(),
description: _description.text.trim(),
category: _category.text.trim(),
defaultClass: _defaultClass.name,
startDate: (widget.goal?.startDate ?? DateTime.now()).toIso8601String(),
targetDate: _targetDate?.toIso8601String() ?? "",
colourHex: colorToHex(_accent),
));
}
void _onArchive() {
_model?.showApplicationNotification(
NotificationType.warning,
"Archive this goal?",
"It leaves your goals list. Its tasks and their history stay on the record, and any debt they carry is not forgiven.",
true,
true,
null,
action: "Archive",
positiveAction: _onConfirmArchive,
);
}
void _onConfirmArchive() {
Navigator.pop(context);
_model?.archive(widget.goal?.id ?? "");
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: _editing ? "Edit" : "New",
title: "Goal",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle(_editing ? "Adjust the goal." : "What are you\nworking toward?"),
const SizedBox(height: 24),
_preview(),
const SizedBox(height: 28),
inputField(
"Goal",
_title,
hint: "Workout, thesis, get the flat sorted…",
validator: Validators.title,
onChanged: _onTitleChanged,
),
const SizedBox(height: 16),
inputField(
"Why it matters",
_description,
hint: "Optional, but it helps on the bad days",
maxLines: 3,
),
const SizedBox(height: 16),
inputField(
"Category",
_category,
hint: "Health, work, home…",
),
const SizedBox(height: 28),
sectionBreak("Colour", caption: "how it reads everywhere"),
_swatches(),
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,
radius: 12,
size: 50,
),
],
],
),
const SizedBox(height: 10),
text(
"A deadline is for the goal as a whole. The tasks and routines inside it keep their own windows.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 28),
sectionBreak("Tasks default to", caption: "you can change each one"),
segmentedSelector<CommitmentClass>(
options: CommitmentClass.values,
selected: _defaultClass,
label: classLabel,
onSelected: _onClassSelected,
activeColor: classColor(_defaultClass),
),
const SizedBox(height: 10),
text(
"The goal never carries debt itself — the tasks and routines inside it do.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 32),
roundedCornerButton(
_editing ? "Save changes" : "Create goal",
_onSave,
icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add,
),
if (_editing) ...[
const SizedBox(height: 8),
Center(
child: textButton("Archive goal", _onArchive,
color: colorDestructive, textSize: 13),
),
],
],
),
),
);
}
/// A live preview of the goal card, so colour and deadline are chosen by
/// looking, not imagining.
Widget _preview() {
final String title =
_title.text.trim().isEmpty ? "Your goal" : _title.text.trim();
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[_accent, Color.lerp(_accent, colorBlack, 0.45)!],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(CupertinoIcons.flag_fill,
size: 14, color: colorWhite.withValues(alpha: 0.7)),
const SizedBox(width: 8),
text(
_targetDate == null
? "NO DEADLINE"
: targetLabel(_targetDate).toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.6),
letterSpacing: 1.2,
),
],
),
const SizedBox(height: 14),
text(title, 24, TextType.Light,
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 16),
meter(_editing ? widget.goal!.progress : 0,
fill: colorWhite,
track: colorWhite.withValues(alpha: 0.16),
height: 5),
],
),
);
}
Widget _swatches() {
return Wrap(
spacing: 9,
runSpacing: 9,
children: goalAccents.map((colour) {
final bool selected = colour.toARGB32() == _accent.toARGB32();
return GestureDetector(
onTap: () => _onAccentSelected(colour),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colour,
shape: BoxShape.circle,
border: Border.all(
color: selected ? colorWhite : Colors.transparent,
width: 3,
),
boxShadow: selected
? [BoxShadow(color: colour, spreadRadius: 1.5)]
: null,
),
child: selected
? Icon(Icons.check_rounded, size: 18, color: colorWhite)
: null,
),
);
}).toList(),
);
}
// ── ConnectGoalEditor ─────────────────────────────────────────────────────
/// Pops the goal as saved, carrying over the server-derived counts, so the
/// screen underneath can update in place without a reload.
@override
void onSaved() {
final Goal? before = widget.goal;
Navigator.pop(
context,
Goal(
id: before?.id,
title: _title.text.trim(),
description: _description.text.trim(),
category: _category.text.trim(),
defaultClass: _defaultClass,
startDate: before?.startDate ?? DateTime.now(),
targetDate: _targetDate,
colourHex: colorToHex(_accent),
totalTasks: before?.totalTasks ?? 0,
completedTasks: before?.completedTasks ?? 0,
overdueTasks: before?.overdueTasks ?? 0,
debtContribution: before?.debtContribution ?? 0,
),
);
}
@override
void onArchived() {
Navigator.pop(context, "archived");
}
@override
void dispose() {
_title.dispose();
_description.dispose();
_category.dispose();
super.dispose();
}
}