Skip to content

Commit e83f344

Browse files
committed
feat(patterns): add AI-generated narrative feature and corresponding UI components
1 parent 69c7b49 commit e83f344

5 files changed

Lines changed: 418 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to BodyPress Flutter will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.13] - 2026-03-08
9+
10+
### Added
11+
12+
- **Your Body Story**: AI-generated narrative at the top of the Patterns page that synthesises all pattern data (themes, energy, correlations, rhythms, signals, AI hints) into a warm 3–5 sentence summary spoken "as the body" — giving users an instant, human-readable overview of their patterns
13+
- `PatternNarrativeService` — feeds full `PatternAnalysis` into the AI with a carefully tuned prompt (temperature 0.75, max 300 tokens)
14+
- `PatternNarrativeCard` widget with shimmer loading state and accent-tinted presentation
15+
- Narrative auto-regenerates when the interval filter changes or new captures are analysed
16+
817
## [1.0.12] - 2026-03-08
918

1019
### Added

lib/features/patterns/screens/patterns_screen.dart

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import '../../../core/services/service_providers.dart';
88
import '../../body_blog/widgets/weekly_self_portrait.dart';
99
import '../../shared/widgets/app_header.dart';
1010
import '../models/pattern_analysis.dart';
11+
import '../services/pattern_narrative_service.dart';
1112
import '../widgets/co_occurrence_list.dart';
1213
import '../widgets/pattern_hints_card.dart';
14+
import '../widgets/pattern_narrative_card.dart';
1315
import '../widgets/rhythm_strip.dart';
1416
import '../widgets/section_card.dart';
1517
import '../widgets/theme_energy_insights.dart';
@@ -71,6 +73,12 @@ class _PatternsScreenState extends ConsumerState<PatternsScreen> {
7173
bool get _isAnalyzing =>
7274
_analyzingTotal > 0 && _analyzingDone < _analyzingTotal;
7375

76+
// ── Narrative ───────────────────────────────────────────────────────────
77+
String? _narrative;
78+
bool _narrativeLoading = false;
79+
/// Track which analysis hash we last requested narrative for.
80+
int _lastNarrativeHash = 0;
81+
7482
@override
7583
void initState() {
7684
super.initState();
@@ -122,6 +130,10 @@ class _PatternsScreenState extends ConsumerState<PatternsScreen> {
122130
}
123131
final filtered = _filterByInterval(captures, _interval);
124132
final summary = buildPatternAnalysis(filtered);
133+
134+
// Trigger narrative generation when data changes
135+
_maybeGenerateNarrative(summary);
136+
125137
return _PatternBody(
126138
summary: summary,
127139
filtered: filtered,
@@ -133,13 +145,45 @@ class _PatternsScreenState extends ConsumerState<PatternsScreen> {
133145
analyzingTotal: _analyzingTotal,
134146
justFinished: _justFinished,
135147
selectedInterval: _interval,
136-
onIntervalChanged: (v) => setState(() => _interval = v),
148+
onIntervalChanged: (v) {
149+
setState(() {
150+
_interval = v;
151+
// Reset narrative when interval changes
152+
_narrative = null;
153+
_lastNarrativeHash = 0;
154+
});
155+
},
156+
narrative: _narrative,
157+
narrativeLoading: _narrativeLoading,
137158
);
138159
},
139160
),
140161
),
141162
);
142163
}
164+
165+
/// Fire-and-forget narrative generation. Only re-fires when analysis changes.
166+
void _maybeGenerateNarrative(PatternAnalysis analysis) {
167+
final hash = Object.hash(
168+
analysis.analyzedCaptures,
169+
analysis.topThemes.length,
170+
_interval,
171+
);
172+
if (hash == _lastNarrativeHash) return;
173+
_lastNarrativeHash = hash;
174+
175+
if (analysis.analyzedCaptures < 2) return;
176+
177+
setState(() => _narrativeLoading = true);
178+
final svc = PatternNarrativeService(ref.read(aiServiceProvider));
179+
svc.generate(analysis).then((result) {
180+
if (!mounted) return;
181+
setState(() {
182+
_narrative = result;
183+
_narrativeLoading = false;
184+
});
185+
});
186+
}
143187
}
144188

145189
// ── Body ────────────────────────────────────────────────────────────────────
@@ -156,6 +200,8 @@ class _PatternBody extends StatelessWidget {
156200
final bool justFinished;
157201
final _PatternInterval selectedInterval;
158202
final ValueChanged<_PatternInterval> onIntervalChanged;
203+
final String? narrative;
204+
final bool narrativeLoading;
159205

160206
const _PatternBody({
161207
required this.summary,
@@ -169,6 +215,8 @@ class _PatternBody extends StatelessWidget {
169215
required this.justFinished,
170216
required this.selectedInterval,
171217
required this.onIntervalChanged,
218+
required this.narrative,
219+
required this.narrativeLoading,
172220
});
173221

174222
@override
@@ -219,6 +267,17 @@ class _PatternBody extends StatelessWidget {
219267
),
220268
),
221269

270+
// ── 0. Body Story (AI narrative) ────────────────────
271+
SliverToBoxAdapter(
272+
child: Padding(
273+
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
274+
child: PatternNarrativeCard(
275+
narrative: narrative,
276+
isLoading: narrativeLoading,
277+
),
278+
),
279+
),
280+
222281
// ── 1. Weekly Self-Portrait ──────────────────────────
223282
SliverToBoxAdapter(
224283
child: SectionCard(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import '../../../core/services/ai_service.dart';
4+
import '../models/pattern_analysis.dart';
5+
6+
/// Feeds the full [PatternAnalysis] into the AI and returns a short
7+
/// narrative that reads like the body talking to its human — keeping
8+
/// the BodyPress journal spirit alive on the Patterns page.
9+
class PatternNarrativeService {
10+
final AiService _ai;
11+
12+
PatternNarrativeService(this._ai);
13+
14+
/// Generate a 3–5 sentence narrative summarising the user's patterns.
15+
///
16+
/// Returns `null` on any failure (no AI configured, network, etc.).
17+
Future<String?> generate(PatternAnalysis analysis) async {
18+
if (analysis.analyzedCaptures == 0) return null;
19+
20+
final prompt = _buildPrompt(analysis);
21+
try {
22+
final result = await _ai.ask(
23+
prompt,
24+
systemPrompt: _systemPrompt,
25+
temperature: 0.75,
26+
maxTokens: 300,
27+
);
28+
// Strip surrounding quotes if the model wraps it
29+
return result.trim().replaceAll(RegExp(r"""^["']+|["']+$"""), '');
30+
} catch (e) {
31+
debugPrint('[PatternNarrative] AI error: $e');
32+
return null;
33+
}
34+
}
35+
36+
// ── Prompts ─────────────────────────────────────────────────────────────
37+
38+
static const _systemPrompt = '''
39+
You are writing a short body-pattern narrative for BodyPress — a personal body journal app.
40+
Speak as the body itself, addressing the user in second person ("you").
41+
Be warm, wise, and grounded in the data provided. No fluff.
42+
3–5 sentences maximum. No headings, no bullet points, no markdown.
43+
Highlight the most meaningful pattern or correlation you see.
44+
End with one forward-looking observation or gentle nudge.''';
45+
46+
String _buildPrompt(PatternAnalysis a) {
47+
final buf = StringBuffer();
48+
buf.writeln('Here is the user\'s aggregated body-pattern data:');
49+
buf.writeln();
50+
51+
// Captures
52+
buf.writeln(
53+
'• ${a.analyzedCaptures} of ${a.totalCaptures} captures analysed.',
54+
);
55+
56+
// Energy
57+
buf.writeln(
58+
'• Energy breakdown: '
59+
'high ${a.energyBreakdown['high'] ?? 0}, '
60+
'medium ${a.energyBreakdown['medium'] ?? 0}, '
61+
'low ${a.energyBreakdown['low'] ?? 0}.',
62+
);
63+
64+
// Top themes + trends
65+
if (a.topThemes.isNotEmpty) {
66+
final themed = a.topThemes
67+
.take(6)
68+
.map((e) {
69+
final trend = a.themeTrends[e.key];
70+
final arrow = trend != null && trend.abs() > 0.15
71+
? (trend > 0 ? ' ↑' : ' ↓')
72+
: '';
73+
return '${e.key} ×${e.value}$arrow';
74+
})
75+
.join(', ');
76+
buf.writeln('• Top themes: $themed.');
77+
}
78+
79+
// Theme–energy links
80+
if (a.themeEnergyMap.isNotEmpty) {
81+
final links = <String>[];
82+
for (final entry in a.themeEnergyMap.entries) {
83+
final counts = entry.value;
84+
final total =
85+
(counts['high'] ?? 0) +
86+
(counts['medium'] ?? 0) +
87+
(counts['low'] ?? 0);
88+
if (total < 3) continue;
89+
final highPct = ((counts['high'] ?? 0) / total * 100).round();
90+
final lowPct = ((counts['low'] ?? 0) / total * 100).round();
91+
if (highPct >= 60) {
92+
links.add('"${entry.key}" → high energy $highPct%');
93+
} else if (lowPct >= 60) {
94+
links.add('"${entry.key}" → low energy $lowPct%');
95+
}
96+
}
97+
if (links.isNotEmpty) {
98+
buf.writeln('• Theme–energy correlations: ${links.join('; ')}.');
99+
}
100+
}
101+
102+
// Co-occurrences
103+
if (a.coOccurrences.isNotEmpty) {
104+
final pairs = a.coOccurrences
105+
.take(4)
106+
.map((e) => '${e.key} ×${e.value}')
107+
.join(', ');
108+
buf.writeln('• Co-occurring themes: $pairs.');
109+
}
110+
111+
// Rhythms
112+
if (a.timeOfDayDistribution.isNotEmpty) {
113+
final sorted = a.timeOfDayDistribution.entries.toList()
114+
..sort((a, b) => b.value.compareTo(a.value));
115+
final peak = sorted.first;
116+
buf.writeln(
117+
'• Peak capture window: ${peak.key} (${peak.value} captures).',
118+
);
119+
}
120+
121+
// Body signals
122+
if (a.bodySignalDistribution.isNotEmpty) {
123+
final sorted = a.bodySignalDistribution.entries.toList()
124+
..sort((a, b) => b.value.compareTo(a.value));
125+
final top = sorted.take(3).map((e) => '${e.key} ×${e.value}').join(', ');
126+
buf.writeln('• Body signals: $top.');
127+
}
128+
129+
// AI pattern hints
130+
if (a.aggregatedPatternHints.isNotEmpty) {
131+
final hints = a.aggregatedPatternHints
132+
.take(4)
133+
.map((e) => e.key)
134+
.join(', ');
135+
buf.writeln('• AI-observed patterns: $hints.');
136+
}
137+
138+
// Recurring signals
139+
if (a.topSignals.isNotEmpty) {
140+
final sigs = a.topSignals
141+
.take(4)
142+
.map((e) => '${e.key} ×${e.value}')
143+
.join(', ');
144+
buf.writeln('• Recurring signals: $sigs.');
145+
}
146+
147+
buf.writeln();
148+
buf.writeln(
149+
'Write a short narrative (3–5 sentences) that synthesises these '
150+
'patterns. Speak as the body. Be specific — reference the actual '
151+
'themes and data. Plain text only.',
152+
);
153+
154+
return buf.toString();
155+
}
156+
}

0 commit comments

Comments
 (0)