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.
681 lines
22 KiB
Dart
681 lines
22 KiB
Dart
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, // 0–12
|
||
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();
|
||
}
|
||
}
|
||
}
|