Files
stefwill 6f3fb74e7e feat: implement multi-step mood logging wizard
Adds sleep/energy/positivity/self-worth tracking columns to the database
(schema v2 migration), a new 5-option StepScale widget, per-answer chips
at the top of the screen, a 2×2 activity grid chip, and a full step-by-step
wizard flow: mood → sleep (first entry today only) → energy → positivity →
self-worth → activities → review+save.
2026-04-26 12:14:45 +10:00

681 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/activity_provider.dart';
import '../../core/providers/mood_provider.dart';
import '../../shared/widgets/activity_grid.dart';
import '../../shared/widgets/mood_selector.dart';
import 'widgets/activities_chip.dart';
import 'widgets/answer_chip.dart';
import 'widgets/step_scale.dart';
// ── Emoji / label data ────────────────────────────────────────────────────────
const _moodEmojis = ['😞', '😔', '😐', '🙂', '😄'];
const _moodLabels = ['Awful', 'Bad', 'Okay', 'Good', 'Great'];
const _energyEmojis = ['💤', '🥱', '⚡', '🔥', '🚀'];
const _energyLabels = ['Very low', 'Low', 'OK', 'High', 'Very high'];
const _positivityEmojis = ['🌧', '☁️', '🌤', '☀️', '🌈'];
const _positivityLabels = ['Very neg', 'Negative', 'Neutral', 'Positive', 'Very pos'];
const _selfWorthEmojis = ['💔', '😕', '😐', '💪', '⭐'];
const _selfWorthLabels = ['Very low', 'Low', 'Moderate', 'High', 'Very high'];
const _scaleColors = [
Color(0xFFE57373),
Color(0xFFFFB74D),
Color(0xFFFFD54F),
Color(0xFF81C784),
Color(0xFF4DB6AC),
];
// ── Step enum ─────────────────────────────────────────────────────────────────
enum _Step { mood, sleep, energy, positivity, selfWorth, activities }
// ── Screen ────────────────────────────────────────────────────────────────────
class LogMoodScreen extends ConsumerStatefulWidget {
const LogMoodScreen({super.key});
@override
ConsumerState<LogMoodScreen> createState() => _LogMoodScreenState();
}
class _LogMoodScreenState extends ConsumerState<LogMoodScreen> {
// answers
int? _mood;
int _sleepHours = 7;
int _sleepMinutesVal = 30; // 0/5/10…55
int? _energy;
int? _positivity;
int? _selfWorth;
// navigation state
int _stepIndex = 0;
bool _inReview = false;
bool _editingFromReview = false;
// sleep scroll controllers
late final FixedExtentScrollController _hoursCtrl;
late final FixedExtentScrollController _minsCtrl;
// computed step list (set once isFirstToday is known)
List<_Step>? _steps;
@override
void initState() {
super.initState();
_hoursCtrl = FixedExtentScrollController(initialItem: _sleepHours);
_minsCtrl =
FixedExtentScrollController(initialItem: _sleepMinutesVal ~/ 5);
}
@override
void dispose() {
_hoursCtrl.dispose();
_minsCtrl.dispose();
super.dispose();
}
// ── Helpers ─────────────────────────────────────────────────────────────────
List<_Step> _buildStepList(bool isFirstToday) {
return [
_Step.mood,
if (isFirstToday) _Step.sleep,
_Step.energy,
_Step.positivity,
_Step.selfWorth,
_Step.activities,
];
}
_Step get _currentStep => _steps![_stepIndex];
String _sleepLabel() {
final mm = _sleepMinutesVal.toString().padLeft(2, '0');
return '$_sleepHours:$mm';
}
int _sleepTotalMinutes() => _sleepHours * 60 + _sleepMinutesVal;
Color _colorFor(int level) => _scaleColors[level - 1];
// ── Navigation ───────────────────────────────────────────────────────────────
void _advance() {
if (_editingFromReview) {
setState(() {
_editingFromReview = false;
_inReview = true;
_stepIndex = _steps!.length; // back to review index
});
return;
}
final next = _stepIndex + 1;
if (next >= _steps!.length) {
setState(() {
_inReview = true;
_stepIndex = _steps!.length;
});
} else {
setState(() => _stepIndex = next);
}
}
void _editStepFromReview(int idx) {
setState(() {
_editingFromReview = true;
_stepIndex = idx;
_inReview = false;
});
}
// ── Save ─────────────────────────────────────────────────────────────────────
Future<void> _save() async {
final selectedActivities = ref.read(selectedActivitiesProvider);
await ref.read(moodEntryNotifierProvider.notifier).addEntry(
moodLevel: _mood ?? 3,
activityIds: selectedActivities.toList(),
sleepMinutes:
_steps!.contains(_Step.sleep) ? _sleepTotalMinutes() : null,
energyLevel: _energy,
positivityLevel: _positivity,
selfWorthLevel: _selfWorth,
);
if (!mounted) return;
ref.read(selectedActivitiesProvider.notifier).clear();
ref.invalidate(isFirstEntryTodayProvider);
setState(() {
_mood = null;
_sleepHours = 7;
_sleepMinutesVal = 30;
_energy = null;
_positivity = null;
_selfWorth = null;
_stepIndex = 0;
_inReview = false;
_editingFromReview = false;
_steps = null;
});
// reset scroll controllers
_hoursCtrl.jumpToItem(7);
_minsCtrl.jumpToItem(6); // 30 / 5 = 6
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Mood logged!')),
);
}
// ── Answer chip row ──────────────────────────────────────────────────────────
Widget _buildChipRow() {
final steps = _steps;
if (steps == null) return const SizedBox.shrink();
final chips = <Widget>[];
for (int i = 0; i < steps.length; i++) {
final step = steps[i];
final answered = _stepIndex > i || _inReview;
final isLiveSleep =
step == _Step.sleep && _stepIndex == i && !_inReview;
if (!answered && !isLiveSleep) continue;
Widget chip;
final tappable = _inReview ? () => _editStepFromReview(i) : null;
switch (step) {
case _Step.mood:
if (_mood == null) continue;
chip = AnswerChip(
label: _moodEmojis[_mood! - 1],
backgroundColor: _colorFor(_mood!),
onTap: tappable,
);
case _Step.sleep:
chip = AnswerChip(
label: _sleepLabel(),
backgroundColor: const Color(0xFF42A5F5),
onTap: tappable,
);
case _Step.energy:
if (_energy == null) continue;
chip = AnswerChip(
label: _energyEmojis[_energy! - 1],
backgroundColor: _colorFor(_energy!),
onTap: tappable,
);
case _Step.positivity:
if (_positivity == null) continue;
chip = AnswerChip(
label: _positivityEmojis[_positivity! - 1],
backgroundColor: _colorFor(_positivity!),
onTap: tappable,
);
case _Step.selfWorth:
if (_selfWorth == null) continue;
chip = AnswerChip(
label: _selfWorthEmojis[_selfWorth! - 1],
backgroundColor: _colorFor(_selfWorth!),
onTap: tappable,
);
case _Step.activities:
chip = ActivitiesChip(
backgroundColor: const Color(0xFF78909C),
onTap: tappable,
);
}
chips.add(
Padding(
padding: const EdgeInsets.only(right: 8),
child: chip,
),
);
}
if (chips.isEmpty) return const SizedBox(height: 60);
return SizedBox(
height: 60,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 24),
children: chips,
),
);
}
// ── Step builders ─────────────────────────────────────────────────────────────
Widget _buildMoodStep() {
final theme = Theme.of(context);
return Column(
children: [
Text(
'How are you feeling?',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
MoodSelector(
selected: _mood ?? 3,
emojis: _moodEmojis,
labels: _moodLabels,
colors: _scaleColors,
onChanged: (v) {
setState(() => _mood = v);
Future.delayed(const Duration(milliseconds: 250), _advance);
},
),
const SizedBox(height: 16),
if (_mood != null)
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_moodLabels[_mood! - 1],
key: ValueKey(_mood),
style: theme.textTheme.titleLarge?.copyWith(
color: _colorFor(_mood!),
fontWeight: FontWeight.w500,
),
),
),
],
);
}
Widget _buildSleepStep() {
final theme = Theme.of(context);
const minuteOptions = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
return Column(
children: [
Text(
'How long did you sleep?',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// live circle
Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: Color(0xFF42A5F5),
shape: BoxShape.circle,
),
child: Center(
child: Text(
_sleepLabel(),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Hours wheel
Column(
children: [
Text('Hours', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
SizedBox(
width: 80,
height: 160,
child: ListWheelScrollView.useDelegate(
controller: _hoursCtrl,
itemExtent: 48,
perspective: 0.003,
diameterRatio: 1.4,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: (i) =>
setState(() => _sleepHours = i),
childDelegate: ListWheelChildBuilderDelegate(
childCount: 13, // 012
builder: (ctx, i) => Center(
child: Text(
'$i',
style: theme.textTheme.titleLarge,
),
),
),
),
),
],
),
const SizedBox(width: 16),
Text(':', style: theme.textTheme.headlineMedium),
const SizedBox(width: 16),
// Minutes wheel
Column(
children: [
Text('Minutes', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
SizedBox(
width: 80,
height: 160,
child: ListWheelScrollView.useDelegate(
controller: _minsCtrl,
itemExtent: 48,
perspective: 0.003,
diameterRatio: 1.4,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: (i) =>
setState(() => _sleepMinutesVal = minuteOptions[i]),
childDelegate: ListWheelChildBuilderDelegate(
childCount: minuteOptions.length,
builder: (ctx, i) => Center(
child: Text(
minuteOptions[i].toString().padLeft(2, '0'),
style: theme.textTheme.titleLarge,
),
),
),
),
),
],
),
],
),
const SizedBox(height: 32),
FilledButton(
onPressed: _advance,
child: const Text('Next →'),
),
],
);
}
Widget _buildScaleStep({
required String title,
required List<String> emojis,
required List<String> labels,
required int? current,
required ValueChanged<int> onSelected,
}) {
final theme = Theme.of(context);
return Column(
children: [
Text(
title,
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
StepScale(
emojis: emojis,
labels: labels,
colors: _scaleColors,
selected: current,
onSelected: (v) {
onSelected(v);
Future.delayed(const Duration(milliseconds: 250), _advance);
},
),
const SizedBox(height: 16),
if (current != null)
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
labels[current - 1],
key: ValueKey(current),
style: theme.textTheme.titleLarge?.copyWith(
color: _colorFor(current),
fontWeight: FontWeight.w500,
),
),
),
],
);
}
Widget _buildActivitiesStep() {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'What have you been doing?',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
const ActivityGrid(),
const SizedBox(height: 32),
Center(
child: FilledButton(
onPressed: _advance,
child: const Text('Done →'),
),
),
],
);
}
Widget _buildReviewStep() {
final theme = Theme.of(context);
final steps = _steps!;
return Column(
children: [
Text(
'Ready to save?',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
for (int i = 0; i < steps.length; i++)
_reviewChipFor(steps[i], i),
],
),
const SizedBox(height: 48),
// Save FAB
GestureDetector(
onTap: _save,
child: Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
color: Color(0xFF4CAF50),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x554CAF50),
blurRadius: 12,
offset: Offset(0, 4),
)
],
),
child: const Icon(Icons.check, color: Colors.white, size: 36),
),
),
],
);
}
Widget _reviewChipFor(_Step step, int idx) {
switch (step) {
case _Step.mood:
return _reviewChipTile(
label: _mood != null ? _moodLabels[_mood! - 1] : '—',
emoji: _mood != null ? _moodEmojis[_mood! - 1] : '?',
color: _mood != null ? _colorFor(_mood!) : Colors.grey,
onTap: () => _editStepFromReview(idx),
);
case _Step.sleep:
return _reviewChipTile(
label: 'Sleep',
emoji: _sleepLabel(),
color: const Color(0xFF42A5F5),
onTap: () => _editStepFromReview(idx),
);
case _Step.energy:
return _reviewChipTile(
label: _energy != null ? _energyLabels[_energy! - 1] : '—',
emoji: _energy != null ? _energyEmojis[_energy! - 1] : '?',
color: _energy != null ? _colorFor(_energy!) : Colors.grey,
onTap: () => _editStepFromReview(idx),
);
case _Step.positivity:
return _reviewChipTile(
label: _positivity != null ? _positivityLabels[_positivity! - 1] : '—',
emoji: _positivity != null ? _positivityEmojis[_positivity! - 1] : '?',
color: _positivity != null ? _colorFor(_positivity!) : Colors.grey,
onTap: () => _editStepFromReview(idx),
);
case _Step.selfWorth:
return _reviewChipTile(
label: _selfWorth != null ? _selfWorthLabels[_selfWorth! - 1] : '—',
emoji: _selfWorth != null ? _selfWorthEmojis[_selfWorth! - 1] : '?',
color: _selfWorth != null ? _colorFor(_selfWorth!) : Colors.grey,
onTap: () => _editStepFromReview(idx),
);
case _Step.activities:
return GestureDetector(
onTap: () => _editStepFromReview(idx),
child: ActivitiesChip(
backgroundColor: const Color(0xFF78909C),
onTap: () => _editStepFromReview(idx),
),
);
}
}
Widget _reviewChipTile({
required String label,
required String emoji,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: color.withValues(alpha: 0.5), width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(width: 4),
Icon(Icons.edit, size: 14, color: color.withValues(alpha: 0.7)),
],
),
),
);
}
// ── Build ─────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final firstEntryAsync = ref.watch(isFirstEntryTodayProvider);
return firstEntryAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (isFirstToday) {
// Build (or reuse) the step list once resolved
_steps ??= _buildStepList(isFirstToday);
return Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
// ── Answer chip row ──────────────────────────────────
_buildChipRow(),
const SizedBox(height: 24),
// ── Step content ─────────────────────────────────────
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: _buildCurrentStep(),
),
),
],
),
),
);
},
);
}
Widget _buildCurrentStep() {
if (_inReview) return _buildReviewStep();
final step = _currentStep;
switch (step) {
case _Step.mood:
return _buildMoodStep();
case _Step.sleep:
return _buildSleepStep();
case _Step.energy:
return _buildScaleStep(
title: 'How is your energy level?',
emojis: _energyEmojis,
labels: _energyLabels,
current: _energy,
onSelected: (v) => setState(() => _energy = v),
);
case _Step.positivity:
return _buildScaleStep(
title: 'How positive do you feel?',
emojis: _positivityEmojis,
labels: _positivityLabels,
current: _positivity,
onSelected: (v) => setState(() => _positivity = v),
);
case _Step.selfWorth:
return _buildScaleStep(
title: 'How is your self-worth?',
emojis: _selfWorthEmojis,
labels: _selfWorthLabels,
current: _selfWorth,
onSelected: (v) => setState(() => _selfWorth = v),
);
case _Step.activities:
return _buildActivitiesStep();
}
}
}