added collections
Browse files- app/lib/domain/models/card.dart +75 -0
- app/lib/domain/models/pipeline_event.dart +30 -0
- app/lib/main.dart +2 -0
- app/lib/ui/core/widgets/pipeline_progress.dart +37 -10
- app/lib/ui/core/widgets/processing_glyph.dart +101 -0
- app/lib/ui/core/widgets/stat_strip.dart +82 -0
- app/lib/ui/features/capture/views/capture_sheet.dart +47 -0
- app/lib/ui/features/catalog/view_models/catalog_view_model.dart +6 -0
- app/lib/ui/features/catalog/views/catalog_detail_screen.dart +167 -0
- app/lib/ui/features/catalog/views/catalog_screen.dart +13 -3
- app/lib/ui/features/graph/views/graph_screen.dart +144 -28
- app/lib/ui/features/library/views/card_tile.dart +66 -0
- app/lib/ui/features/onboarding/views/onboarding_screen.dart +42 -10
- app/lib/ui/features/onboarding/views/splash_screen.dart +48 -0
- app/lib/ui/features/profile/views/profile_screen.dart +34 -34
- app/lib/ui/features/reader/view_models/chat_view_model.dart +7 -0
- app/lib/ui/features/reader/views/chat_screen.dart +10 -2
- app/lib/ui/features/reader/views/insight_section.dart +561 -0
- app/lib/ui/features/reader/views/reader_screen.dart +50 -10
- app/lib/ui/features/search/views/search_screen.dart +152 -21
- app/lib/ui/features/share/views/share_screen.dart +5 -2
- backend/app/models/card.py +47 -1
- backend/app/pipeline/insight.py +175 -0
- backend/app/pipeline/structuring.py +38 -20
- backend/app/pipeline/worker.py +42 -0
- backend/app/store/db.py +8 -1
- backend/tests/test_api.py +1 -1
app/lib/domain/models/card.dart
CHANGED
|
@@ -117,6 +117,75 @@ class ActionItems {
|
|
| 117 |
{'followed': followed, 'items': items.map((e) => e.toJson()).toList()};
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
class Media {
|
| 121 |
const Media({this.thumbnail, this.keyframes = const []});
|
| 122 |
|
|
@@ -175,6 +244,7 @@ class Card {
|
|
| 175 |
this.primaryAction = const PrimaryAction(),
|
| 176 |
this.actionItems = const ActionItems(),
|
| 177 |
this.blocks = const [],
|
|
|
|
| 178 |
this.media = const Media(),
|
| 179 |
this.meta = const Meta(),
|
| 180 |
this.rawBlocks = const [],
|
|
@@ -189,6 +259,7 @@ class Card {
|
|
| 189 |
final PrimaryAction primaryAction;
|
| 190 |
final ActionItems actionItems;
|
| 191 |
final List<Block> blocks;
|
|
|
|
| 192 |
final Media media;
|
| 193 |
final Meta meta;
|
| 194 |
|
|
@@ -224,6 +295,9 @@ class Card {
|
|
| 224 |
(json['action_items'] as Map<String, dynamic>?) ?? const {},
|
| 225 |
),
|
| 226 |
blocks: rawBlocks.map(Block.fromJson).toList(),
|
|
|
|
|
|
|
|
|
|
| 227 |
media: Media.fromJson((json['media'] as Map<String, dynamic>?) ?? const {}),
|
| 228 |
meta: Meta.fromJson((json['meta'] as Map<String, dynamic>?) ?? const {}),
|
| 229 |
rawBlocks: rawBlocks,
|
|
@@ -246,6 +320,7 @@ class Card {
|
|
| 246 |
primaryAction: primaryAction,
|
| 247 |
actionItems: actionItems ?? this.actionItems,
|
| 248 |
blocks: blocks ?? this.blocks,
|
|
|
|
| 249 |
media: media,
|
| 250 |
meta: meta,
|
| 251 |
rawBlocks: rawBlocks ?? this.rawBlocks,
|
|
|
|
| 117 |
{'followed': followed, 'items': items.map((e) => e.toJson()).toList()};
|
| 118 |
}
|
| 119 |
|
| 120 |
+
/// Deep-analysis layer (docs/14). Present only on idea-rich cards; `null` for a
|
| 121 |
+
/// simple reel. Everything here is actionable: rabbit-hole threads tap into chat,
|
| 122 |
+
/// the topic map orients, the research prompt is paste-ready. Each sub-section is
|
| 123 |
+
/// independently optional — the UI renders only the parts that carry content.
|
| 124 |
+
class Insight {
|
| 125 |
+
const Insight({
|
| 126 |
+
this.rabbitHole = const RabbitHole(),
|
| 127 |
+
this.topicMap,
|
| 128 |
+
this.deepResearchPrompt,
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
final RabbitHole rabbitHole;
|
| 132 |
+
final TopicMap? topicMap;
|
| 133 |
+
final String? deepResearchPrompt;
|
| 134 |
+
|
| 135 |
+
bool get hasDeepResearch =>
|
| 136 |
+
deepResearchPrompt != null && deepResearchPrompt!.trim().isNotEmpty;
|
| 137 |
+
bool get hasContent => !rabbitHole.isEmpty || topicMap != null || hasDeepResearch;
|
| 138 |
+
|
| 139 |
+
factory Insight.fromJson(Map<String, dynamic> json) => Insight(
|
| 140 |
+
rabbitHole: RabbitHole.fromJson(
|
| 141 |
+
(json['rabbit_hole'] as Map<String, dynamic>?) ?? const {},
|
| 142 |
+
),
|
| 143 |
+
topicMap: json['topic_map'] is Map<String, dynamic>
|
| 144 |
+
? TopicMap.fromJson(json['topic_map'] as Map<String, dynamic>)
|
| 145 |
+
: null,
|
| 146 |
+
deepResearchPrompt: json['deep_research_prompt'] as String?,
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
class RabbitHole {
|
| 151 |
+
const RabbitHole({
|
| 152 |
+
this.questions = const [],
|
| 153 |
+
this.adjacentTopics = const [],
|
| 154 |
+
this.advancedConcepts = const [],
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
final List<String> questions;
|
| 158 |
+
final List<String> adjacentTopics;
|
| 159 |
+
final List<String> advancedConcepts;
|
| 160 |
+
|
| 161 |
+
bool get isEmpty =>
|
| 162 |
+
questions.isEmpty && adjacentTopics.isEmpty && advancedConcepts.isEmpty;
|
| 163 |
+
|
| 164 |
+
factory RabbitHole.fromJson(Map<String, dynamic> json) {
|
| 165 |
+
List<String> l(String k) =>
|
| 166 |
+
((json[k] as List?) ?? const []).map((e) => e.toString()).toList();
|
| 167 |
+
return RabbitHole(
|
| 168 |
+
questions: l('questions'),
|
| 169 |
+
adjacentTopics: l('adjacent_topics'),
|
| 170 |
+
advancedConcepts: l('advanced_concepts'),
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
class TopicMap {
|
| 176 |
+
const TopicMap({required this.center, this.nodes = const []});
|
| 177 |
+
|
| 178 |
+
final String center;
|
| 179 |
+
final List<String> nodes;
|
| 180 |
+
|
| 181 |
+
factory TopicMap.fromJson(Map<String, dynamic> json) => TopicMap(
|
| 182 |
+
center: (json['center'] as String?) ?? '',
|
| 183 |
+
nodes: ((json['nodes'] as List?) ?? const [])
|
| 184 |
+
.map((e) => e.toString())
|
| 185 |
+
.toList(),
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
class Media {
|
| 190 |
const Media({this.thumbnail, this.keyframes = const []});
|
| 191 |
|
|
|
|
| 244 |
this.primaryAction = const PrimaryAction(),
|
| 245 |
this.actionItems = const ActionItems(),
|
| 246 |
this.blocks = const [],
|
| 247 |
+
this.insight,
|
| 248 |
this.media = const Media(),
|
| 249 |
this.meta = const Meta(),
|
| 250 |
this.rawBlocks = const [],
|
|
|
|
| 259 |
final PrimaryAction primaryAction;
|
| 260 |
final ActionItems actionItems;
|
| 261 |
final List<Block> blocks;
|
| 262 |
+
final Insight? insight; // deep-analysis layer (docs/14); null for simple cards
|
| 263 |
final Media media;
|
| 264 |
final Meta meta;
|
| 265 |
|
|
|
|
| 295 |
(json['action_items'] as Map<String, dynamic>?) ?? const {},
|
| 296 |
),
|
| 297 |
blocks: rawBlocks.map(Block.fromJson).toList(),
|
| 298 |
+
insight: json['insight'] is Map<String, dynamic>
|
| 299 |
+
? Insight.fromJson(json['insight'] as Map<String, dynamic>)
|
| 300 |
+
: null,
|
| 301 |
media: Media.fromJson((json['media'] as Map<String, dynamic>?) ?? const {}),
|
| 302 |
meta: Meta.fromJson((json['meta'] as Map<String, dynamic>?) ?? const {}),
|
| 303 |
rawBlocks: rawBlocks,
|
|
|
|
| 320 |
primaryAction: primaryAction,
|
| 321 |
actionItems: actionItems ?? this.actionItems,
|
| 322 |
blocks: blocks ?? this.blocks,
|
| 323 |
+
insight: insight,
|
| 324 |
media: media,
|
| 325 |
meta: meta,
|
| 326 |
rawBlocks: rawBlocks ?? this.rawBlocks,
|
app/lib/domain/models/pipeline_event.dart
CHANGED
|
@@ -10,6 +10,7 @@ enum PipelineStage {
|
|
| 10 |
extracting,
|
| 11 |
structuring,
|
| 12 |
persisting,
|
|
|
|
| 13 |
done,
|
| 14 |
failed,
|
| 15 |
unknown;
|
|
@@ -26,6 +27,8 @@ enum PipelineStage {
|
|
| 26 |
return PipelineStage.structuring;
|
| 27 |
case 'persisting':
|
| 28 |
return PipelineStage.persisting;
|
|
|
|
|
|
|
| 29 |
case 'done':
|
| 30 |
return PipelineStage.done;
|
| 31 |
case 'failed':
|
|
@@ -48,6 +51,8 @@ enum PipelineStage {
|
|
| 48 |
return 'Structuring';
|
| 49 |
case PipelineStage.persisting:
|
| 50 |
return 'Finishing';
|
|
|
|
|
|
|
| 51 |
case PipelineStage.done:
|
| 52 |
return 'Ready';
|
| 53 |
case PipelineStage.failed:
|
|
@@ -57,6 +62,31 @@ enum PipelineStage {
|
|
| 57 |
}
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
/// Ordered pipeline steps shown as a progress track (excludes terminal/meta).
|
| 61 |
static const List<PipelineStage> track = [
|
| 62 |
PipelineStage.downloading,
|
|
|
|
| 10 |
extracting,
|
| 11 |
structuring,
|
| 12 |
persisting,
|
| 13 |
+
analyzing,
|
| 14 |
done,
|
| 15 |
failed,
|
| 16 |
unknown;
|
|
|
|
| 27 |
return PipelineStage.structuring;
|
| 28 |
case 'persisting':
|
| 29 |
return PipelineStage.persisting;
|
| 30 |
+
case 'analyzing':
|
| 31 |
+
return PipelineStage.analyzing;
|
| 32 |
case 'done':
|
| 33 |
return PipelineStage.done;
|
| 34 |
case 'failed':
|
|
|
|
| 51 |
return 'Structuring';
|
| 52 |
case PipelineStage.persisting:
|
| 53 |
return 'Finishing';
|
| 54 |
+
case PipelineStage.analyzing:
|
| 55 |
+
return 'Analyzing';
|
| 56 |
case PipelineStage.done:
|
| 57 |
return 'Ready';
|
| 58 |
case PipelineStage.failed:
|
|
|
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
| 65 |
+
/// A fixed one-line subtitle describing what each step does — shown beneath the
|
| 66 |
+
/// label so the pipeline reads as a narrated sequence, not bare keywords.
|
| 67 |
+
String get description {
|
| 68 |
+
switch (this) {
|
| 69 |
+
case PipelineStage.snapshot:
|
| 70 |
+
return 'Getting ready';
|
| 71 |
+
case PipelineStage.downloading:
|
| 72 |
+
return 'Fetching the video source';
|
| 73 |
+
case PipelineStage.extracting:
|
| 74 |
+
return 'Transcript + on-screen text';
|
| 75 |
+
case PipelineStage.structuring:
|
| 76 |
+
return 'Building your knowledge card';
|
| 77 |
+
case PipelineStage.persisting:
|
| 78 |
+
return 'Saving to your library';
|
| 79 |
+
case PipelineStage.analyzing:
|
| 80 |
+
return 'Surfacing deeper insight';
|
| 81 |
+
case PipelineStage.done:
|
| 82 |
+
return 'Card ready';
|
| 83 |
+
case PipelineStage.failed:
|
| 84 |
+
return 'Something went wrong';
|
| 85 |
+
case PipelineStage.unknown:
|
| 86 |
+
return '';
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
/// Ordered pipeline steps shown as a progress track (excludes terminal/meta).
|
| 91 |
static const List<PipelineStage> track = [
|
| 92 |
PipelineStage.downloading,
|
app/lib/main.dart
CHANGED
|
@@ -5,6 +5,7 @@ library;
|
|
| 5 |
|
| 6 |
import 'dart:async';
|
| 7 |
|
|
|
|
| 8 |
import 'package:flutter/material.dart';
|
| 9 |
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
| 10 |
import 'package:provider/provider.dart';
|
|
@@ -56,6 +57,7 @@ class _CachyAppState extends State<CachyApp> {
|
|
| 56 |
/// arrive while the app is already running. Degrades silently if the platform
|
| 57 |
/// channel is unavailable (e.g. desktop/test).
|
| 58 |
void _wireShareIntent() {
|
|
|
|
| 59 |
try {
|
| 60 |
final instance = ReceiveSharingIntent.instance;
|
| 61 |
instance.getInitialMedia().then((files) {
|
|
|
|
| 5 |
|
| 6 |
import 'dart:async';
|
| 7 |
|
| 8 |
+
import 'package:flutter/foundation.dart';
|
| 9 |
import 'package:flutter/material.dart';
|
| 10 |
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
| 11 |
import 'package:provider/provider.dart';
|
|
|
|
| 57 |
/// arrive while the app is already running. Degrades silently if the platform
|
| 58 |
/// channel is unavailable (e.g. desktop/test).
|
| 59 |
void _wireShareIntent() {
|
| 60 |
+
if (kIsWeb) return;
|
| 61 |
try {
|
| 62 |
final instance = ReceiveSharingIntent.instance;
|
| 63 |
instance.getInitialMedia().then((files) {
|
app/lib/ui/core/widgets/pipeline_progress.dart
CHANGED
|
@@ -33,23 +33,40 @@ class PipelineProgress extends StatelessWidget {
|
|
| 33 |
@override
|
| 34 |
Widget build(BuildContext context) {
|
| 35 |
final theme = Theme.of(context);
|
|
|
|
| 36 |
final idx = _currentIndex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return Column(
|
| 38 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 39 |
children: [
|
| 40 |
-
for (var i = 0; i <
|
| 41 |
_StageRow(
|
| 42 |
label: PipelineStage.track[i].label,
|
|
|
|
| 43 |
done: i < idx,
|
| 44 |
active: i == idx,
|
| 45 |
detail: i == idx ? detail : '',
|
| 46 |
-
isLast: i ==
|
| 47 |
),
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
],
|
| 54 |
);
|
| 55 |
}
|
|
@@ -58,6 +75,7 @@ class PipelineProgress extends StatelessWidget {
|
|
| 58 |
class _StageRow extends StatelessWidget {
|
| 59 |
const _StageRow({
|
| 60 |
required this.label,
|
|
|
|
| 61 |
required this.done,
|
| 62 |
required this.active,
|
| 63 |
required this.detail,
|
|
@@ -65,6 +83,7 @@ class _StageRow extends StatelessWidget {
|
|
| 65 |
});
|
| 66 |
|
| 67 |
final String label;
|
|
|
|
| 68 |
final bool done;
|
| 69 |
final bool active;
|
| 70 |
final String detail;
|
|
@@ -109,12 +128,20 @@ class _StageRow extends StatelessWidget {
|
|
| 109 |
color: lit ? scheme.onSurface : scheme.onSurfaceVariant,
|
| 110 |
),
|
| 111 |
),
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
Padding(
|
| 114 |
padding: const EdgeInsets.only(top: 2),
|
| 115 |
-
child: Text(
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
),
|
| 119 |
],
|
| 120 |
),
|
|
|
|
| 33 |
@override
|
| 34 |
Widget build(BuildContext context) {
|
| 35 |
final theme = Theme.of(context);
|
| 36 |
+
final scheme = theme.colorScheme;
|
| 37 |
final idx = _currentIndex;
|
| 38 |
+
final total = PipelineStage.track.length;
|
| 39 |
+
// Fraction complete: finished steps + a half-credit for the active one.
|
| 40 |
+
final progress = idx < 0
|
| 41 |
+
? 0.04
|
| 42 |
+
: ((idx.clamp(0, total) + (idx < total ? 0.5 : 0.0)) / total).clamp(0.0, 1.0);
|
| 43 |
return Column(
|
| 44 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 45 |
children: [
|
| 46 |
+
for (var i = 0; i < total; i++)
|
| 47 |
_StageRow(
|
| 48 |
label: PipelineStage.track[i].label,
|
| 49 |
+
description: PipelineStage.track[i].description,
|
| 50 |
done: i < idx,
|
| 51 |
active: i == idx,
|
| 52 |
detail: i == idx ? detail : '',
|
| 53 |
+
isLast: i == total - 1,
|
| 54 |
),
|
| 55 |
+
const SizedBox(height: 4),
|
| 56 |
+
ClipRRect(
|
| 57 |
+
borderRadius: BorderRadius.circular(2),
|
| 58 |
+
child: LinearProgressIndicator(
|
| 59 |
+
value: progress,
|
| 60 |
+
minHeight: 4,
|
| 61 |
+
backgroundColor: scheme.surfaceContainerHighest,
|
| 62 |
+
valueColor: AlwaysStoppedAnimation(scheme.primary),
|
| 63 |
),
|
| 64 |
+
),
|
| 65 |
+
const SizedBox(height: 8),
|
| 66 |
+
Text(
|
| 67 |
+
'${(progress * 100).round()}% complete',
|
| 68 |
+
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
|
| 69 |
+
),
|
| 70 |
],
|
| 71 |
);
|
| 72 |
}
|
|
|
|
| 75 |
class _StageRow extends StatelessWidget {
|
| 76 |
const _StageRow({
|
| 77 |
required this.label,
|
| 78 |
+
required this.description,
|
| 79 |
required this.done,
|
| 80 |
required this.active,
|
| 81 |
required this.detail,
|
|
|
|
| 83 |
});
|
| 84 |
|
| 85 |
final String label;
|
| 86 |
+
final String description;
|
| 87 |
final bool done;
|
| 88 |
final bool active;
|
| 89 |
final String detail;
|
|
|
|
| 128 |
color: lit ? scheme.onSurface : scheme.onSurfaceVariant,
|
| 129 |
),
|
| 130 |
),
|
| 131 |
+
// Active step shows the live SSE detail; other steps show the
|
| 132 |
+
// fixed subtitle so the whole sequence reads as narrated work.
|
| 133 |
+
if ((active ? (detail.isNotEmpty ? detail : description) : description)
|
| 134 |
+
.isNotEmpty)
|
| 135 |
Padding(
|
| 136 |
padding: const EdgeInsets.only(top: 2),
|
| 137 |
+
child: Text(
|
| 138 |
+
active ? (detail.isNotEmpty ? detail : description) : description,
|
| 139 |
+
style: theme.textTheme.bodySmall?.copyWith(
|
| 140 |
+
color: lit
|
| 141 |
+
? scheme.onSurfaceVariant
|
| 142 |
+
: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
| 143 |
+
),
|
| 144 |
+
),
|
| 145 |
),
|
| 146 |
],
|
| 147 |
),
|
app/lib/ui/core/widgets/processing_glyph.dart
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// The signature "working" mark (docs/06): a center badge carrying a spark glyph,
|
| 2 |
+
/// haloed by concentric rings that ripple outward — the calm pulse shown while a
|
| 3 |
+
/// reel is processed. Adapted from the Insightr prototype's star/processing motif,
|
| 4 |
+
/// reskinned into the cream/ink world (brand primary, flat — no neon glow).
|
| 5 |
+
library;
|
| 6 |
+
|
| 7 |
+
import 'package:flutter/material.dart';
|
| 8 |
+
|
| 9 |
+
import '../brand.dart';
|
| 10 |
+
|
| 11 |
+
class ProcessingGlyph extends StatefulWidget {
|
| 12 |
+
const ProcessingGlyph({
|
| 13 |
+
super.key,
|
| 14 |
+
this.size = 132,
|
| 15 |
+
this.icon = Icons.auto_awesome_rounded,
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
final double size;
|
| 19 |
+
final IconData icon;
|
| 20 |
+
|
| 21 |
+
@override
|
| 22 |
+
State<ProcessingGlyph> createState() => _ProcessingGlyphState();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
class _ProcessingGlyphState extends State<ProcessingGlyph>
|
| 26 |
+
with SingleTickerProviderStateMixin {
|
| 27 |
+
late final AnimationController _c = AnimationController(
|
| 28 |
+
vsync: this,
|
| 29 |
+
duration: const Duration(milliseconds: 2400),
|
| 30 |
+
)..repeat();
|
| 31 |
+
|
| 32 |
+
static const _ringCount = 3;
|
| 33 |
+
|
| 34 |
+
@override
|
| 35 |
+
void dispose() {
|
| 36 |
+
_c.dispose();
|
| 37 |
+
super.dispose();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
@override
|
| 41 |
+
Widget build(BuildContext context) {
|
| 42 |
+
final scheme = Theme.of(context).colorScheme;
|
| 43 |
+
final badge = widget.size * 0.42;
|
| 44 |
+
return SizedBox(
|
| 45 |
+
width: widget.size,
|
| 46 |
+
height: widget.size,
|
| 47 |
+
child: AnimatedBuilder(
|
| 48 |
+
animation: _c,
|
| 49 |
+
builder: (context, child) {
|
| 50 |
+
return Stack(
|
| 51 |
+
alignment: Alignment.center,
|
| 52 |
+
children: [
|
| 53 |
+
// Rippling halo rings, each offset in phase so they radiate steadily.
|
| 54 |
+
for (var i = 0; i < _ringCount; i++)
|
| 55 |
+
_ring(scheme, (_c.value + i / _ringCount) % 1.0, badge),
|
| 56 |
+
child!,
|
| 57 |
+
],
|
| 58 |
+
);
|
| 59 |
+
},
|
| 60 |
+
// Center badge: a rounded square that breathes gently.
|
| 61 |
+
child: TweenAnimationBuilder<double>(
|
| 62 |
+
tween: Tween(begin: 0.0, end: 1.0),
|
| 63 |
+
duration: const Duration(milliseconds: 600),
|
| 64 |
+
curve: Curves.easeOutBack,
|
| 65 |
+
builder: (context, t, _) {
|
| 66 |
+
final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
|
| 67 |
+
return Transform.scale(
|
| 68 |
+
scale: t * pulse,
|
| 69 |
+
child: Container(
|
| 70 |
+
width: badge,
|
| 71 |
+
height: badge,
|
| 72 |
+
decoration: BoxDecoration(
|
| 73 |
+
color: scheme.primary,
|
| 74 |
+
borderRadius: BorderRadius.circular(badge * 0.3),
|
| 75 |
+
boxShadow: Brand.softShadow(opacity: 0.22, blur: 22, y: 6),
|
| 76 |
+
),
|
| 77 |
+
child: Icon(widget.icon, size: badge * 0.5, color: scheme.onPrimary),
|
| 78 |
+
),
|
| 79 |
+
);
|
| 80 |
+
},
|
| 81 |
+
),
|
| 82 |
+
),
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
Widget _ring(ColorScheme scheme, double t, double base) {
|
| 87 |
+
final diameter = base * (1.0 + t * 1.4);
|
| 88 |
+
final opacity = (1.0 - t) * 0.35;
|
| 89 |
+
return Container(
|
| 90 |
+
width: diameter,
|
| 91 |
+
height: diameter,
|
| 92 |
+
decoration: BoxDecoration(
|
| 93 |
+
shape: BoxShape.circle,
|
| 94 |
+
border: Border.all(
|
| 95 |
+
color: scheme.primary.withValues(alpha: opacity),
|
| 96 |
+
width: 1.4,
|
| 97 |
+
),
|
| 98 |
+
),
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
}
|
app/lib/ui/core/widgets/stat_strip.dart
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// A compact dashboard strip: a row of boxed value/label cells. Used for vault
|
| 2 |
+
/// stats (Profile, Catalog) and the insight stat trio atop deep cards. Editorial,
|
| 3 |
+
/// flat — bordered cells on the surface, no glow.
|
| 4 |
+
library;
|
| 5 |
+
|
| 6 |
+
import 'package:flutter/material.dart';
|
| 7 |
+
|
| 8 |
+
import '../brand.dart';
|
| 9 |
+
import '../theme.dart';
|
| 10 |
+
|
| 11 |
+
class Stat {
|
| 12 |
+
const Stat({required this.value, required this.label, this.emphasize = false});
|
| 13 |
+
final String value;
|
| 14 |
+
final String label;
|
| 15 |
+
|
| 16 |
+
/// Tint the value in the brand accent (used for the headline stat).
|
| 17 |
+
final bool emphasize;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class StatStrip extends StatelessWidget {
|
| 21 |
+
const StatStrip({super.key, required this.stats});
|
| 22 |
+
final List<Stat> stats;
|
| 23 |
+
|
| 24 |
+
@override
|
| 25 |
+
Widget build(BuildContext context) {
|
| 26 |
+
if (stats.isEmpty) return const SizedBox.shrink();
|
| 27 |
+
return IntrinsicHeight(
|
| 28 |
+
child: Row(
|
| 29 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 30 |
+
children: [
|
| 31 |
+
for (var i = 0; i < stats.length; i++) ...[
|
| 32 |
+
if (i > 0) const SizedBox(width: 10),
|
| 33 |
+
Expanded(child: _Cell(stat: stats[i])),
|
| 34 |
+
],
|
| 35 |
+
],
|
| 36 |
+
),
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
class _Cell extends StatelessWidget {
|
| 42 |
+
const _Cell({required this.stat});
|
| 43 |
+
final Stat stat;
|
| 44 |
+
|
| 45 |
+
@override
|
| 46 |
+
Widget build(BuildContext context) {
|
| 47 |
+
final theme = Theme.of(context);
|
| 48 |
+
final scheme = theme.colorScheme;
|
| 49 |
+
return Container(
|
| 50 |
+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14),
|
| 51 |
+
decoration: BoxDecoration(
|
| 52 |
+
color: scheme.surfaceContainerLow,
|
| 53 |
+
borderRadius: BorderRadius.circular(Insets.radius),
|
| 54 |
+
border: Border.all(color: scheme.outlineVariant),
|
| 55 |
+
),
|
| 56 |
+
child: Column(
|
| 57 |
+
mainAxisSize: MainAxisSize.min,
|
| 58 |
+
children: [
|
| 59 |
+
Text(
|
| 60 |
+
stat.value,
|
| 61 |
+
maxLines: 1,
|
| 62 |
+
overflow: TextOverflow.ellipsis,
|
| 63 |
+
style: theme.textTheme.headlineSmall?.copyWith(
|
| 64 |
+
fontWeight: FontWeight.w800,
|
| 65 |
+
color: stat.emphasize ? scheme.primary : scheme.onSurface,
|
| 66 |
+
),
|
| 67 |
+
),
|
| 68 |
+
const SizedBox(height: 4),
|
| 69 |
+
Text(
|
| 70 |
+
stat.label.toUpperCase(),
|
| 71 |
+
textAlign: TextAlign.center,
|
| 72 |
+
style: Brand.label(
|
| 73 |
+
size: 9.5,
|
| 74 |
+
color: scheme.onSurfaceVariant,
|
| 75 |
+
weight: FontWeight.w700,
|
| 76 |
+
),
|
| 77 |
+
),
|
| 78 |
+
],
|
| 79 |
+
),
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
}
|
app/lib/ui/features/capture/views/capture_sheet.dart
CHANGED
|
@@ -105,6 +105,18 @@ class _CaptureSheetState extends State<_CaptureSheet> {
|
|
| 105 |
'Paste a link, or share to Cachy from Instagram, TikTok or YouTube.',
|
| 106 |
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
| 107 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
const SizedBox(height: 20),
|
| 109 |
|
| 110 |
// One-tap capture of a URL already on the clipboard.
|
|
@@ -143,6 +155,41 @@ class _CaptureSheetState extends State<_CaptureSheet> {
|
|
| 143 |
}
|
| 144 |
}
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
class _ClipboardChip extends StatelessWidget {
|
| 147 |
const _ClipboardChip({required this.url, required this.onTap});
|
| 148 |
final String url;
|
|
|
|
| 105 |
'Paste a link, or share to Cachy from Instagram, TikTok or YouTube.',
|
| 106 |
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
| 107 |
),
|
| 108 |
+
const SizedBox(height: 16),
|
| 109 |
+
|
| 110 |
+
// Supported-platform affordance — sets expectations at a glance.
|
| 111 |
+
const Row(
|
| 112 |
+
children: [
|
| 113 |
+
_PlatformChip(label: 'Instagram', dot: Color(0xFFE1306C)),
|
| 114 |
+
SizedBox(width: 8),
|
| 115 |
+
_PlatformChip(label: 'TikTok', dot: Color(0xFF22C3D6)),
|
| 116 |
+
SizedBox(width: 8),
|
| 117 |
+
_PlatformChip(label: 'YouTube', dot: Color(0xFFE0301E)),
|
| 118 |
+
],
|
| 119 |
+
),
|
| 120 |
const SizedBox(height: 20),
|
| 121 |
|
| 122 |
// One-tap capture of a URL already on the clipboard.
|
|
|
|
| 155 |
}
|
| 156 |
}
|
| 157 |
|
| 158 |
+
/// A small static chip naming a supported source platform (colored dot + label).
|
| 159 |
+
class _PlatformChip extends StatelessWidget {
|
| 160 |
+
const _PlatformChip({required this.label, required this.dot});
|
| 161 |
+
final String label;
|
| 162 |
+
final Color dot;
|
| 163 |
+
|
| 164 |
+
@override
|
| 165 |
+
Widget build(BuildContext context) {
|
| 166 |
+
final theme = Theme.of(context);
|
| 167 |
+
final scheme = theme.colorScheme;
|
| 168 |
+
return Container(
|
| 169 |
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
| 170 |
+
decoration: BoxDecoration(
|
| 171 |
+
color: scheme.surfaceContainerHigh,
|
| 172 |
+
borderRadius: BorderRadius.circular(999),
|
| 173 |
+
border: Border.all(color: scheme.outlineVariant),
|
| 174 |
+
),
|
| 175 |
+
child: Row(
|
| 176 |
+
mainAxisSize: MainAxisSize.min,
|
| 177 |
+
children: [
|
| 178 |
+
Container(
|
| 179 |
+
width: 7,
|
| 180 |
+
height: 7,
|
| 181 |
+
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
| 182 |
+
),
|
| 183 |
+
const SizedBox(width: 7),
|
| 184 |
+
Text(label,
|
| 185 |
+
style: theme.textTheme.labelMedium
|
| 186 |
+
?.copyWith(fontWeight: FontWeight.w600)),
|
| 187 |
+
],
|
| 188 |
+
),
|
| 189 |
+
);
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
class _ClipboardChip extends StatelessWidget {
|
| 194 |
const _ClipboardChip({required this.url, required this.onTap});
|
| 195 |
final String url;
|
app/lib/ui/features/catalog/view_models/catalog_view_model.dart
CHANGED
|
@@ -33,6 +33,12 @@ class CatalogViewModel extends ChangeNotifier {
|
|
| 33 |
String? _error;
|
| 34 |
String? get error => _error;
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
/// The type filters that actually have entries, in catalog order — so the
|
| 37 |
/// filter bar never offers an empty category.
|
| 38 |
List<ArtifactType> get availableTypes {
|
|
|
|
| 33 |
String? _error;
|
| 34 |
String? get error => _error;
|
| 35 |
|
| 36 |
+
/// Dashboard counts over the WHOLE catalog (ignore the active filter).
|
| 37 |
+
int get entryCount => _entries.length;
|
| 38 |
+
int get typeCount => {for (final e in _entries) e.type}.length;
|
| 39 |
+
int get referencedCardCount =>
|
| 40 |
+
{for (final e in _entries) ...e.sourceCardIds}.length;
|
| 41 |
+
|
| 42 |
/// The type filters that actually have entries, in catalog order — so the
|
| 43 |
/// filter bar never offers an empty category.
|
| 44 |
List<ArtifactType> get availableTypes {
|
app/lib/ui/features/catalog/views/catalog_detail_screen.dart
CHANGED
|
@@ -10,7 +10,10 @@ import 'package:provider/provider.dart';
|
|
| 10 |
|
| 11 |
import '../../../../data/repositories/card_repository.dart';
|
| 12 |
import '../../../../domain/models/artifact.dart';
|
|
|
|
|
|
|
| 13 |
import '../../../core/theme.dart';
|
|
|
|
| 14 |
import '../services/artifact_lookup.dart';
|
| 15 |
|
| 16 |
class CatalogDetailScreen extends StatefulWidget {
|
|
@@ -25,6 +28,50 @@ class _CatalogDetailScreenState extends State<CatalogDetailScreen> {
|
|
| 25 |
late CatalogEntry _entry = widget.entry;
|
| 26 |
bool _loading = false;
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
Future<void> _fetchInfo() async {
|
| 29 |
setState(() => _loading = true);
|
| 30 |
final messenger = ScaffoldMessenger.of(context);
|
|
@@ -169,10 +216,130 @@ class _CatalogDetailScreenState extends State<CatalogDetailScreen> {
|
|
| 169 |
icon: const Icon(Icons.open_in_new_rounded, size: 18),
|
| 170 |
label: const Text('Search the web'),
|
| 171 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
],
|
| 173 |
),
|
| 174 |
);
|
| 175 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
/// Cover with a typed placeholder fallback (mirrors the catalog grid tile).
|
|
|
|
| 10 |
|
| 11 |
import '../../../../data/repositories/card_repository.dart';
|
| 12 |
import '../../../../domain/models/artifact.dart';
|
| 13 |
+
import '../../../../domain/models/card.dart' as model;
|
| 14 |
+
import '../../../core/brand.dart';
|
| 15 |
import '../../../core/theme.dart';
|
| 16 |
+
import '../../reader/views/reader_screen.dart';
|
| 17 |
import '../services/artifact_lookup.dart';
|
| 18 |
|
| 19 |
class CatalogDetailScreen extends StatefulWidget {
|
|
|
|
| 28 |
late CatalogEntry _entry = widget.entry;
|
| 29 |
bool _loading = false;
|
| 30 |
|
| 31 |
+
// Backlink browsing (best-effort, async): the cards that reference this thing,
|
| 32 |
+
// and other catalog entries that co-occur in those same cards.
|
| 33 |
+
List<({String id, String title})> _appearsIn = const [];
|
| 34 |
+
List<CatalogEntry> _related = const [];
|
| 35 |
+
|
| 36 |
+
@override
|
| 37 |
+
void initState() {
|
| 38 |
+
super.initState();
|
| 39 |
+
_loadBacklinks();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
Future<void> _loadBacklinks() async {
|
| 43 |
+
final repo = context.read<CardRepository>();
|
| 44 |
+
final ids = _entry.sourceCardIds;
|
| 45 |
+
// "Appears in": resolve source cards to titles (cap to keep it light).
|
| 46 |
+
final cards = await Future.wait(
|
| 47 |
+
ids.take(12).map(
|
| 48 |
+
(id) => repo.getCard(id).then<model.Card?>((c) => c).catchError((_) => null),
|
| 49 |
+
),
|
| 50 |
+
);
|
| 51 |
+
final appears = <({String id, String title})>[];
|
| 52 |
+
for (final c in cards) {
|
| 53 |
+
if (c == null) continue;
|
| 54 |
+
final title = c.base.oneLiner.isNotEmpty ? c.base.oneLiner : 'Untitled card';
|
| 55 |
+
appears.add((id: c.cardId, title: title));
|
| 56 |
+
}
|
| 57 |
+
// "Related": catalog entries sharing at least one source card with this one.
|
| 58 |
+
var related = const <CatalogEntry>[];
|
| 59 |
+
try {
|
| 60 |
+
final all = await repo.catalog();
|
| 61 |
+
final mine = _entry.sourceCardIds.toSet();
|
| 62 |
+
related = all
|
| 63 |
+
.where((e) => e.id != _entry.id && e.sourceCardIds.any(mine.contains))
|
| 64 |
+
.take(12)
|
| 65 |
+
.toList();
|
| 66 |
+
} catch (_) {/* related is optional */}
|
| 67 |
+
if (mounted) {
|
| 68 |
+
setState(() {
|
| 69 |
+
_appearsIn = appears;
|
| 70 |
+
_related = related;
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
Future<void> _fetchInfo() async {
|
| 76 |
setState(() => _loading = true);
|
| 77 |
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
| 216 |
icon: const Icon(Icons.open_in_new_rounded, size: 18),
|
| 217 |
label: const Text('Search the web'),
|
| 218 |
),
|
| 219 |
+
|
| 220 |
+
if (_appearsIn.isNotEmpty) ...[
|
| 221 |
+
const SizedBox(height: 28),
|
| 222 |
+
_label(theme, 'Appears in'),
|
| 223 |
+
const SizedBox(height: 8),
|
| 224 |
+
for (final c in _appearsIn)
|
| 225 |
+
_AppearsRow(
|
| 226 |
+
title: c.title,
|
| 227 |
+
onTap: () => Navigator.of(context).push(
|
| 228 |
+
MaterialPageRoute(builder: (_) => ReaderScreen(cardId: c.id)),
|
| 229 |
+
),
|
| 230 |
+
),
|
| 231 |
+
],
|
| 232 |
+
|
| 233 |
+
if (_related.isNotEmpty) ...[
|
| 234 |
+
const SizedBox(height: 28),
|
| 235 |
+
_label(theme, 'Related'),
|
| 236 |
+
const SizedBox(height: 12),
|
| 237 |
+
Wrap(
|
| 238 |
+
spacing: 8,
|
| 239 |
+
runSpacing: 8,
|
| 240 |
+
children: [
|
| 241 |
+
for (final e in _related)
|
| 242 |
+
_RelatedChip(
|
| 243 |
+
entry: e,
|
| 244 |
+
onTap: () => Navigator.of(context).push(
|
| 245 |
+
MaterialPageRoute(builder: (_) => CatalogDetailScreen(entry: e)),
|
| 246 |
+
),
|
| 247 |
+
),
|
| 248 |
+
],
|
| 249 |
+
),
|
| 250 |
+
],
|
| 251 |
],
|
| 252 |
),
|
| 253 |
);
|
| 254 |
}
|
| 255 |
+
|
| 256 |
+
Widget _label(ThemeData theme, String text) => Text(
|
| 257 |
+
text.toUpperCase(),
|
| 258 |
+
style: Brand.label(
|
| 259 |
+
size: 11,
|
| 260 |
+
color: theme.colorScheme.onSurfaceVariant,
|
| 261 |
+
weight: FontWeight.w700,
|
| 262 |
+
letterSpacing: 1.2,
|
| 263 |
+
),
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
class _AppearsRow extends StatelessWidget {
|
| 268 |
+
const _AppearsRow({required this.title, required this.onTap});
|
| 269 |
+
final String title;
|
| 270 |
+
final VoidCallback onTap;
|
| 271 |
+
|
| 272 |
+
@override
|
| 273 |
+
Widget build(BuildContext context) {
|
| 274 |
+
final theme = Theme.of(context);
|
| 275 |
+
final scheme = theme.colorScheme;
|
| 276 |
+
return Padding(
|
| 277 |
+
padding: const EdgeInsets.only(bottom: 8),
|
| 278 |
+
child: Material(
|
| 279 |
+
color: scheme.surfaceContainerLow,
|
| 280 |
+
borderRadius: BorderRadius.circular(12),
|
| 281 |
+
child: InkWell(
|
| 282 |
+
onTap: onTap,
|
| 283 |
+
borderRadius: BorderRadius.circular(12),
|
| 284 |
+
child: Padding(
|
| 285 |
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
| 286 |
+
child: Row(
|
| 287 |
+
children: [
|
| 288 |
+
Icon(Icons.description_outlined, size: 18, color: scheme.primary),
|
| 289 |
+
const SizedBox(width: 12),
|
| 290 |
+
Expanded(
|
| 291 |
+
child: Text(title,
|
| 292 |
+
maxLines: 1,
|
| 293 |
+
overflow: TextOverflow.ellipsis,
|
| 294 |
+
style: theme.textTheme.bodyMedium),
|
| 295 |
+
),
|
| 296 |
+
Icon(Icons.chevron_right_rounded,
|
| 297 |
+
size: 18, color: scheme.onSurfaceVariant),
|
| 298 |
+
],
|
| 299 |
+
),
|
| 300 |
+
),
|
| 301 |
+
),
|
| 302 |
+
),
|
| 303 |
+
);
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
class _RelatedChip extends StatelessWidget {
|
| 308 |
+
const _RelatedChip({required this.entry, required this.onTap});
|
| 309 |
+
final CatalogEntry entry;
|
| 310 |
+
final VoidCallback onTap;
|
| 311 |
+
|
| 312 |
+
@override
|
| 313 |
+
Widget build(BuildContext context) {
|
| 314 |
+
final theme = Theme.of(context);
|
| 315 |
+
final scheme = theme.colorScheme;
|
| 316 |
+
return GestureDetector(
|
| 317 |
+
onTap: onTap,
|
| 318 |
+
child: Container(
|
| 319 |
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
| 320 |
+
decoration: BoxDecoration(
|
| 321 |
+
color: scheme.surfaceContainerLow,
|
| 322 |
+
borderRadius: BorderRadius.circular(999),
|
| 323 |
+
border: Border.all(color: scheme.outlineVariant),
|
| 324 |
+
),
|
| 325 |
+
child: Row(
|
| 326 |
+
mainAxisSize: MainAxisSize.min,
|
| 327 |
+
children: [
|
| 328 |
+
Text(entry.type.sectionLabel,
|
| 329 |
+
style: Brand.label(size: 9, color: scheme.primary, weight: FontWeight.w700)),
|
| 330 |
+
const SizedBox(width: 8),
|
| 331 |
+
ConstrainedBox(
|
| 332 |
+
constraints: const BoxConstraints(maxWidth: 160),
|
| 333 |
+
child: Text(entry.title,
|
| 334 |
+
maxLines: 1,
|
| 335 |
+
overflow: TextOverflow.ellipsis,
|
| 336 |
+
style: theme.textTheme.bodyMedium),
|
| 337 |
+
),
|
| 338 |
+
],
|
| 339 |
+
),
|
| 340 |
+
),
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
}
|
| 344 |
|
| 345 |
/// Cover with a typed placeholder fallback (mirrors the catalog grid tile).
|
app/lib/ui/features/catalog/views/catalog_screen.dart
CHANGED
|
@@ -11,6 +11,7 @@ import '../../../../data/repositories/card_repository.dart';
|
|
| 11 |
import '../../../../domain/models/artifact.dart';
|
| 12 |
import '../../../core/brand.dart';
|
| 13 |
import '../../../core/theme.dart';
|
|
|
|
| 14 |
import '../view_models/catalog_view_model.dart';
|
| 15 |
import 'catalog_detail_screen.dart';
|
| 16 |
|
|
@@ -77,10 +78,19 @@ class _CatalogView extends StatelessWidget {
|
|
| 77 |
case CatalogStatus.ready:
|
| 78 |
final sections = vm.sections;
|
| 79 |
return ListView.builder(
|
| 80 |
-
padding: const EdgeInsets.fromLTRB(Insets.page,
|
| 81 |
physics: const AlwaysScrollableScrollPhysics(),
|
| 82 |
-
itemCount: sections.length,
|
| 83 |
-
itemBuilder: (ctx, i)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
);
|
| 85 |
}
|
| 86 |
}
|
|
|
|
| 11 |
import '../../../../domain/models/artifact.dart';
|
| 12 |
import '../../../core/brand.dart';
|
| 13 |
import '../../../core/theme.dart';
|
| 14 |
+
import '../../../core/widgets/stat_strip.dart';
|
| 15 |
import '../view_models/catalog_view_model.dart';
|
| 16 |
import 'catalog_detail_screen.dart';
|
| 17 |
|
|
|
|
| 78 |
case CatalogStatus.ready:
|
| 79 |
final sections = vm.sections;
|
| 80 |
return ListView.builder(
|
| 81 |
+
padding: const EdgeInsets.fromLTRB(Insets.page, 12, Insets.page, 96),
|
| 82 |
physics: const AlwaysScrollableScrollPhysics(),
|
| 83 |
+
itemCount: sections.length + 1,
|
| 84 |
+
itemBuilder: (ctx, i) {
|
| 85 |
+
if (i == 0) {
|
| 86 |
+
return StatStrip(stats: [
|
| 87 |
+
Stat(value: '${vm.entryCount}', label: 'Entries', emphasize: true),
|
| 88 |
+
Stat(value: '${vm.typeCount}', label: 'Types'),
|
| 89 |
+
Stat(value: '${vm.referencedCardCount}', label: 'From cards'),
|
| 90 |
+
]);
|
| 91 |
+
}
|
| 92 |
+
return _Section(section: sections[i - 1], vm: vm);
|
| 93 |
+
},
|
| 94 |
);
|
| 95 |
}
|
| 96 |
}
|
app/lib/ui/features/graph/views/graph_screen.dart
CHANGED
|
@@ -40,7 +40,7 @@ class _PhysicsConfig {
|
|
| 40 |
_PhysicsConfig({
|
| 41 |
this.repelForce = 2.2,
|
| 42 |
this.linkForce = 1.3,
|
| 43 |
-
this.centerForce = 0.
|
| 44 |
this.linkDistance = 90,
|
| 45 |
});
|
| 46 |
}
|
|
@@ -71,13 +71,18 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 71 |
final Map<String, Offset> _vel = {};
|
| 72 |
final Map<String, List<String>> _adj = {};
|
| 73 |
final Map<String, double> _edgeWeights = {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
double _temperature = 0;
|
| 75 |
|
| 76 |
// View transform.
|
| 77 |
Offset _pan = Offset.zero;
|
| 78 |
-
double _zoom =
|
| 79 |
-
|
| 80 |
-
double _baseZoom = 1.0;
|
| 81 |
String? _selected;
|
| 82 |
String? _draggedNode;
|
| 83 |
|
|
@@ -125,7 +130,7 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 125 |
}
|
| 126 |
|
| 127 |
// --------------------------------------------------------------------------
|
| 128 |
-
// Layout seeding —
|
| 129 |
// --------------------------------------------------------------------------
|
| 130 |
|
| 131 |
void _seedLayout(GraphData data) {
|
|
@@ -133,20 +138,13 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 133 |
_vel.clear();
|
| 134 |
_adj.clear();
|
| 135 |
_edgeWeights.clear();
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
final
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
final a = (i / math.max(1, n)) * 2 * math.pi;
|
| 142 |
-
final r = 35 + rng.nextDouble() * 90;
|
| 143 |
-
_pos[data.nodes[i].id] =
|
| 144 |
-
Offset(math.cos(a) * r, math.sin(a) * r) +
|
| 145 |
-
Offset(rng.nextDouble() * 8 - 4, rng.nextDouble() * 8 - 4);
|
| 146 |
-
_vel[data.nodes[i].id] = Offset.zero;
|
| 147 |
-
_adj[data.nodes[i].id] = [];
|
| 148 |
}
|
| 149 |
-
|
| 150 |
for (final e in data.edges) {
|
| 151 |
_adj[e.source]?.add(e.target);
|
| 152 |
_adj[e.target]?.add(e.source);
|
|
@@ -156,8 +154,107 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 156 |
_edgeWeights[key] = e.weight;
|
| 157 |
}
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
// --------------------------------------------------------------------------
|
|
@@ -263,6 +360,18 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 263 |
disp[id] = disp[id]! - p * _physics.centerForce;
|
| 264 |
}
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
// --- Apply forces with temperature clamping + velocity damping ---
|
| 267 |
const damping = 0.80; // less drag = bouncier spring oscillation
|
| 268 |
for (final node in visible) {
|
|
@@ -313,7 +422,6 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 313 |
|
| 314 |
void _onScaleStart(ScaleStartDetails d, Size size) {
|
| 315 |
_baseZoom = _zoom;
|
| 316 |
-
_panStart = _pan;
|
| 317 |
final hit = _hitTest(d.localFocalPoint, size);
|
| 318 |
if (hit != null && d.pointerCount == 1) {
|
| 319 |
_draggedNode = hit;
|
|
@@ -350,7 +458,9 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 350 |
|
| 351 |
void _onScaleEnd(ScaleEndDetails d) {
|
| 352 |
if (_draggedNode != null) {
|
| 353 |
-
|
|
|
|
|
|
|
| 354 |
if (!_ticker.isActive) _ticker.start();
|
| 355 |
}
|
| 356 |
_draggedNode = null;
|
|
@@ -438,10 +548,10 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 438 |
setState(() {
|
| 439 |
_localMode = true;
|
| 440 |
_localRoot = node.id;
|
| 441 |
-
// Reheat so visible subset re-settles.
|
| 442 |
_temperature = 60;
|
| 443 |
if (!_ticker.isActive) _ticker.start();
|
| 444 |
});
|
|
|
|
| 445 |
},
|
| 446 |
),
|
| 447 |
);
|
|
@@ -475,6 +585,8 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 475 |
_temperature = 60;
|
| 476 |
if (!_ticker.isActive) _ticker.start();
|
| 477 |
});
|
|
|
|
|
|
|
| 478 |
(ctx as Element).markNeedsBuild();
|
| 479 |
},
|
| 480 |
onLocalDepthChanged: (v) {
|
|
@@ -483,6 +595,7 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 483 |
_temperature = 60;
|
| 484 |
if (!_ticker.isActive) _ticker.start();
|
| 485 |
});
|
|
|
|
| 486 |
(ctx as Element).markNeedsBuild();
|
| 487 |
},
|
| 488 |
),
|
|
@@ -563,12 +676,15 @@ class _GraphScreenState extends State<GraphScreen>
|
|
| 563 |
?.label ??
|
| 564 |
'Unknown',
|
| 565 |
depth: _localDepth,
|
| 566 |
-
onExit: ()
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
| 572 |
),
|
| 573 |
// Cluster filter chips.
|
| 574 |
if (!_localMode && data.clusters.isNotEmpty)
|
|
|
|
| 40 |
_PhysicsConfig({
|
| 41 |
this.repelForce = 2.2,
|
| 42 |
this.linkForce = 1.3,
|
| 43 |
+
this.centerForce = 0.005,
|
| 44 |
this.linkDistance = 90,
|
| 45 |
});
|
| 46 |
}
|
|
|
|
| 71 |
final Map<String, Offset> _vel = {};
|
| 72 |
final Map<String, List<String>> _adj = {};
|
| 73 |
final Map<String, double> _edgeWeights = {};
|
| 74 |
+
|
| 75 |
+
// Star layout anchors — the ideal position each node should occupy.
|
| 76 |
+
final Map<String, Offset> _starPos = {};
|
| 77 |
+
static const double _starRadius = 110.0;
|
| 78 |
+
// Loose strength so the spring-back feels natural, not snappy.
|
| 79 |
+
static const double _starRestoreStrength = 0.04;
|
| 80 |
double _temperature = 0;
|
| 81 |
|
| 82 |
// View transform.
|
| 83 |
Offset _pan = Offset.zero;
|
| 84 |
+
double _zoom = 0.72;
|
| 85 |
+
double _baseZoom = 0.72;
|
|
|
|
| 86 |
String? _selected;
|
| 87 |
String? _draggedNode;
|
| 88 |
|
|
|
|
| 130 |
}
|
| 131 |
|
| 132 |
// --------------------------------------------------------------------------
|
| 133 |
+
// Layout seeding — places nodes directly into star positions
|
| 134 |
// --------------------------------------------------------------------------
|
| 135 |
|
| 136 |
void _seedLayout(GraphData data) {
|
|
|
|
| 138 |
_vel.clear();
|
| 139 |
_adj.clear();
|
| 140 |
_edgeWeights.clear();
|
| 141 |
+
_starPos.clear();
|
| 142 |
|
| 143 |
+
// Initialise adjacency lists and zero velocities.
|
| 144 |
+
for (final node in data.nodes) {
|
| 145 |
+
_adj[node.id] = [];
|
| 146 |
+
_vel[node.id] = Offset.zero;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
|
|
|
| 148 |
for (final e in data.edges) {
|
| 149 |
_adj[e.source]?.add(e.target);
|
| 150 |
_adj[e.target]?.add(e.source);
|
|
|
|
| 154 |
_edgeWeights[key] = e.weight;
|
| 155 |
}
|
| 156 |
|
| 157 |
+
// Compute star positions and seed nodes directly into them so the graph
|
| 158 |
+
// opens already in star form — no physics convergence needed.
|
| 159 |
+
_starPos.addAll(_computeStarLayout(data, data.nodes));
|
| 160 |
+
for (final node in data.nodes) {
|
| 161 |
+
_pos[node.id] = _starPos[node.id] ?? Offset.zero;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
_temperature = 45;
|
| 165 |
+
if (!_ticker.isActive && data.nodes.isNotEmpty) _ticker.start();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// --------------------------------------------------------------------------
|
| 169 |
+
// Star layout computation
|
| 170 |
+
// --------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
/// Returns the ideal star position for each node in [visible].
|
| 173 |
+
///
|
| 174 |
+
/// Each connected cluster gets one **hub** (highest-degree node) placed on a
|
| 175 |
+
/// coarse ring, with all other cluster members as **spokes** radiating
|
| 176 |
+
/// outward at equal angular intervals.
|
| 177 |
+
///
|
| 178 |
+
/// Cluster-hub separation = `_starRadius * 2.4` so adjacent clusters' spoke
|
| 179 |
+
/// disks never spatially overlap, preventing cross-cluster edge crossings.
|
| 180 |
+
Map<String, Offset> _computeStarLayout(
|
| 181 |
+
GraphData data, List<GraphNode> visible) {
|
| 182 |
+
final result = <String, Offset>{};
|
| 183 |
+
|
| 184 |
+
// Group nodes by cluster ID.
|
| 185 |
+
final clusterMap = <int, List<GraphNode>>{};
|
| 186 |
+
for (final node in visible) {
|
| 187 |
+
clusterMap.putIfAbsent(node.clusterId, () => []).add(node);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Isolated nodes (-1) live on an outer ring, handled separately.
|
| 191 |
+
final isolated = clusterMap.remove(-1) ?? <GraphNode>[];
|
| 192 |
+
final clusterIds = clusterMap.keys.toList()..sort();
|
| 193 |
+
final numClusters = clusterIds.length;
|
| 194 |
+
|
| 195 |
+
// Minimum separation between adjacent cluster hubs so that no spoke from
|
| 196 |
+
// cluster A can reach any spoke of cluster B.
|
| 197 |
+
const clusterSep = _starRadius * 2.4;
|
| 198 |
+
// Chord formula: for n equally spaced points on a ring, the chord between
|
| 199 |
+
// adjacent points = 2 * R * sin(π / n). Solve for R given chord = clusterSep.
|
| 200 |
+
final hubRingRadius = numClusters <= 1
|
| 201 |
+
? 0.0
|
| 202 |
+
: clusterSep / (2 * math.sin(math.pi / numClusters));
|
| 203 |
+
|
| 204 |
+
for (var ci = 0; ci < numClusters; ci++) {
|
| 205 |
+
final cid = clusterIds[ci];
|
| 206 |
+
final nodes = List<GraphNode>.from(clusterMap[cid]!);
|
| 207 |
+
|
| 208 |
+
// Highest-degree node becomes the star's hub.
|
| 209 |
+
nodes.sort((a, b) => b.degree.compareTo(a.degree));
|
| 210 |
+
final hub = nodes.first;
|
| 211 |
+
|
| 212 |
+
final hubAngle =
|
| 213 |
+
(ci / math.max(1, numClusters)) * 2 * math.pi - math.pi / 2;
|
| 214 |
+
final hubPos = numClusters <= 1
|
| 215 |
+
? Offset.zero
|
| 216 |
+
: Offset(
|
| 217 |
+
math.cos(hubAngle) * hubRingRadius,
|
| 218 |
+
math.sin(hubAngle) * hubRingRadius,
|
| 219 |
+
);
|
| 220 |
+
result[hub.id] = hubPos;
|
| 221 |
+
|
| 222 |
+
// Place spoke nodes at equal angular intervals around the hub.
|
| 223 |
+
final spokes = nodes.skip(1).toList();
|
| 224 |
+
for (var si = 0; si < spokes.length; si++) {
|
| 225 |
+
final spokeAngle =
|
| 226 |
+
(si / math.max(1, spokes.length)) * 2 * math.pi - math.pi / 2;
|
| 227 |
+
result[spokes[si].id] = hubPos +
|
| 228 |
+
Offset(
|
| 229 |
+
math.cos(spokeAngle) * _starRadius,
|
| 230 |
+
math.sin(spokeAngle) * _starRadius,
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// Isolated nodes on a ring well beyond all star clusters.
|
| 236 |
+
final outerRadius = hubRingRadius + _starRadius * 1.6;
|
| 237 |
+
for (var ii = 0; ii < isolated.length; ii++) {
|
| 238 |
+
final angle =
|
| 239 |
+
(ii / math.max(1, isolated.length)) * 2 * math.pi - math.pi / 2;
|
| 240 |
+
result[isolated[ii].id] = Offset(
|
| 241 |
+
math.cos(angle) * outerRadius,
|
| 242 |
+
math.sin(angle) * outerRadius,
|
| 243 |
+
);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
return result;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/// Recomputes star target positions for the currently visible node set and
|
| 250 |
+
/// updates [_starPos]. Call whenever local-mode or depth changes.
|
| 251 |
+
void _recomputeStar() {
|
| 252 |
+
final data = _data;
|
| 253 |
+
if (data == null) return;
|
| 254 |
+
final visible = _visibleNodes(data);
|
| 255 |
+
_starPos
|
| 256 |
+
..clear()
|
| 257 |
+
..addAll(_computeStarLayout(data, visible));
|
| 258 |
}
|
| 259 |
|
| 260 |
// --------------------------------------------------------------------------
|
|
|
|
| 360 |
disp[id] = disp[id]! - p * _physics.centerForce;
|
| 361 |
}
|
| 362 |
|
| 363 |
+
// --- Force 4: Star restoring spring ---
|
| 364 |
+
// Pulls every non-dragged node gently back to its ideal star position.
|
| 365 |
+
// Strength 0.04 = loose and natural; large enough to correct drift over
|
| 366 |
+
// a few seconds, small enough not to fight the user's drag.
|
| 367 |
+
for (final id in ids) {
|
| 368 |
+
if (id == _draggedNode) continue;
|
| 369 |
+
final target = _starPos[id];
|
| 370 |
+
final current = _pos[id];
|
| 371 |
+
if (target == null || current == null) continue;
|
| 372 |
+
disp[id] = disp[id]! + (target - current) * _starRestoreStrength;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
// --- Apply forces with temperature clamping + velocity damping ---
|
| 376 |
const damping = 0.80; // less drag = bouncier spring oscillation
|
| 377 |
for (final node in visible) {
|
|
|
|
| 422 |
|
| 423 |
void _onScaleStart(ScaleStartDetails d, Size size) {
|
| 424 |
_baseZoom = _zoom;
|
|
|
|
| 425 |
final hit = _hitTest(d.localFocalPoint, size);
|
| 426 |
if (hit != null && d.pointerCount == 1) {
|
| 427 |
_draggedNode = hit;
|
|
|
|
| 458 |
|
| 459 |
void _onScaleEnd(ScaleEndDetails d) {
|
| 460 |
if (_draggedNode != null) {
|
| 461 |
+
// Reheat enough for the restoring force to animate the snap-back
|
| 462 |
+
// visibly, but not so hot that distant nodes thrash.
|
| 463 |
+
_temperature = math.max(_temperature, 45.0);
|
| 464 |
if (!_ticker.isActive) _ticker.start();
|
| 465 |
}
|
| 466 |
_draggedNode = null;
|
|
|
|
| 548 |
setState(() {
|
| 549 |
_localMode = true;
|
| 550 |
_localRoot = node.id;
|
|
|
|
| 551 |
_temperature = 60;
|
| 552 |
if (!_ticker.isActive) _ticker.start();
|
| 553 |
});
|
| 554 |
+
_recomputeStar();
|
| 555 |
},
|
| 556 |
),
|
| 557 |
);
|
|
|
|
| 585 |
_temperature = 60;
|
| 586 |
if (!_ticker.isActive) _ticker.start();
|
| 587 |
});
|
| 588 |
+
_recomputeStar();
|
| 589 |
+
// Update the bottom sheet's own state.
|
| 590 |
(ctx as Element).markNeedsBuild();
|
| 591 |
},
|
| 592 |
onLocalDepthChanged: (v) {
|
|
|
|
| 595 |
_temperature = 60;
|
| 596 |
if (!_ticker.isActive) _ticker.start();
|
| 597 |
});
|
| 598 |
+
_recomputeStar();
|
| 599 |
(ctx as Element).markNeedsBuild();
|
| 600 |
},
|
| 601 |
),
|
|
|
|
| 676 |
?.label ??
|
| 677 |
'Unknown',
|
| 678 |
depth: _localDepth,
|
| 679 |
+
onExit: () {
|
| 680 |
+
setState(() {
|
| 681 |
+
_localMode = false;
|
| 682 |
+
_localRoot = null;
|
| 683 |
+
_temperature = 60;
|
| 684 |
+
if (!_ticker.isActive) _ticker.start();
|
| 685 |
+
});
|
| 686 |
+
_recomputeStar();
|
| 687 |
+
},
|
| 688 |
),
|
| 689 |
// Cluster filter chips.
|
| 690 |
if (!_localMode && data.clusters.isNotEmpty)
|
app/lib/ui/features/library/views/card_tile.dart
CHANGED
|
@@ -95,6 +95,7 @@ class CardTile extends StatelessWidget {
|
|
| 95 |
height: 1.15,
|
| 96 |
),
|
| 97 |
),
|
|
|
|
| 98 |
],
|
| 99 |
),
|
| 100 |
),
|
|
@@ -133,3 +134,68 @@ class CardTile extends StatelessWidget {
|
|
| 133 |
if (ok == true) onDelete();
|
| 134 |
}
|
| 135 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
height: 1.15,
|
| 96 |
),
|
| 97 |
),
|
| 98 |
+
if (card.isReady) _MetaPills(card: card),
|
| 99 |
],
|
| 100 |
),
|
| 101 |
),
|
|
|
|
| 134 |
if (ok == true) onDelete();
|
| 135 |
}
|
| 136 |
}
|
| 137 |
+
|
| 138 |
+
/// Compact at-a-glance counts on a tile scrim — how much a card carries before
|
| 139 |
+
/// you open it (actions to do, steps inside, deeper analysis). Renders nothing
|
| 140 |
+
/// when the card has none.
|
| 141 |
+
class _MetaPills extends StatelessWidget {
|
| 142 |
+
const _MetaPills({required this.card});
|
| 143 |
+
final model.Card card;
|
| 144 |
+
|
| 145 |
+
@override
|
| 146 |
+
Widget build(BuildContext context) {
|
| 147 |
+
final actions = card.actionItems.items.length;
|
| 148 |
+
var steps = 0;
|
| 149 |
+
for (final b in card.rawBlocks) {
|
| 150 |
+
final type = b['type'];
|
| 151 |
+
if (type == 'step_list') steps += (b['steps'] as List?)?.length ?? 0;
|
| 152 |
+
if (type == 'checklist') steps += (b['items'] as List?)?.length ?? 0;
|
| 153 |
+
}
|
| 154 |
+
final hasInsight = card.insight?.hasContent ?? false;
|
| 155 |
+
|
| 156 |
+
final pills = <String>[
|
| 157 |
+
if (actions > 0) '$actions ${actions == 1 ? 'action' : 'actions'}',
|
| 158 |
+
if (steps > 0) '$steps steps',
|
| 159 |
+
];
|
| 160 |
+
if (pills.isEmpty && !hasInsight) return const SizedBox.shrink();
|
| 161 |
+
|
| 162 |
+
return Padding(
|
| 163 |
+
padding: const EdgeInsets.only(top: 7),
|
| 164 |
+
child: Wrap(
|
| 165 |
+
spacing: 6,
|
| 166 |
+
runSpacing: 6,
|
| 167 |
+
children: [
|
| 168 |
+
for (final p in pills) _Pill(label: p),
|
| 169 |
+
if (hasInsight) const _Pill(label: 'Deep', highlight: true),
|
| 170 |
+
],
|
| 171 |
+
),
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
class _Pill extends StatelessWidget {
|
| 177 |
+
const _Pill({required this.label, this.highlight = false});
|
| 178 |
+
final String label;
|
| 179 |
+
final bool highlight;
|
| 180 |
+
|
| 181 |
+
@override
|
| 182 |
+
Widget build(BuildContext context) {
|
| 183 |
+
return Container(
|
| 184 |
+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
| 185 |
+
decoration: BoxDecoration(
|
| 186 |
+
color: highlight
|
| 187 |
+
? Colors.white.withValues(alpha: 0.92)
|
| 188 |
+
: Colors.white.withValues(alpha: 0.18),
|
| 189 |
+
borderRadius: BorderRadius.circular(999),
|
| 190 |
+
),
|
| 191 |
+
child: Text(
|
| 192 |
+
label,
|
| 193 |
+
style: Brand.label(
|
| 194 |
+
size: 9.5,
|
| 195 |
+
color: highlight ? Colors.black87 : Colors.white,
|
| 196 |
+
weight: FontWeight.w700,
|
| 197 |
+
),
|
| 198 |
+
),
|
| 199 |
+
);
|
| 200 |
+
}
|
| 201 |
+
}
|
app/lib/ui/features/onboarding/views/onboarding_screen.dart
CHANGED
|
@@ -128,16 +128,7 @@ class _Panel extends StatelessWidget {
|
|
| 128 |
child: Column(
|
| 129 |
mainAxisAlignment: MainAxisAlignment.center,
|
| 130 |
children: [
|
| 131 |
-
|
| 132 |
-
width: 132,
|
| 133 |
-
height: 132,
|
| 134 |
-
decoration: BoxDecoration(
|
| 135 |
-
color: theme.colorScheme.primary.withValues(alpha: 0.10),
|
| 136 |
-
shape: BoxShape.circle,
|
| 137 |
-
border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)),
|
| 138 |
-
),
|
| 139 |
-
child: Icon(icon, size: 58, color: theme.colorScheme.primary),
|
| 140 |
-
),
|
| 141 |
const SizedBox(height: 40),
|
| 142 |
Text(title, textAlign: TextAlign.center, style: theme.textTheme.headlineMedium),
|
| 143 |
const SizedBox(height: 14),
|
|
@@ -152,3 +143,44 @@ class _Panel extends StatelessWidget {
|
|
| 152 |
);
|
| 153 |
}
|
| 154 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
child: Column(
|
| 129 |
mainAxisAlignment: MainAxisAlignment.center,
|
| 130 |
children: [
|
| 131 |
+
_RingedIcon(icon: icon),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
const SizedBox(height: 40),
|
| 133 |
Text(title, textAlign: TextAlign.center, style: theme.textTheme.headlineMedium),
|
| 134 |
const SizedBox(height: 14),
|
|
|
|
| 143 |
);
|
| 144 |
}
|
| 145 |
}
|
| 146 |
+
|
| 147 |
+
/// A haloed feature icon: a filled tinted core ringed by two concentric outlines
|
| 148 |
+
/// that fade outward — the onboarding's "feature highlight" motif.
|
| 149 |
+
class _RingedIcon extends StatelessWidget {
|
| 150 |
+
const _RingedIcon({required this.icon});
|
| 151 |
+
final IconData icon;
|
| 152 |
+
|
| 153 |
+
@override
|
| 154 |
+
Widget build(BuildContext context) {
|
| 155 |
+
final primary = Theme.of(context).colorScheme.primary;
|
| 156 |
+
Widget ring(double size, double alpha) => Container(
|
| 157 |
+
width: size,
|
| 158 |
+
height: size,
|
| 159 |
+
decoration: BoxDecoration(
|
| 160 |
+
shape: BoxShape.circle,
|
| 161 |
+
border: Border.all(color: primary.withValues(alpha: alpha)),
|
| 162 |
+
),
|
| 163 |
+
);
|
| 164 |
+
return SizedBox(
|
| 165 |
+
width: 168,
|
| 166 |
+
height: 168,
|
| 167 |
+
child: Stack(
|
| 168 |
+
alignment: Alignment.center,
|
| 169 |
+
children: [
|
| 170 |
+
ring(168, 0.10),
|
| 171 |
+
ring(140, 0.20),
|
| 172 |
+
Container(
|
| 173 |
+
width: 108,
|
| 174 |
+
height: 108,
|
| 175 |
+
decoration: BoxDecoration(
|
| 176 |
+
color: primary.withValues(alpha: 0.10),
|
| 177 |
+
shape: BoxShape.circle,
|
| 178 |
+
border: Border.all(color: primary.withValues(alpha: 0.35)),
|
| 179 |
+
),
|
| 180 |
+
child: Icon(icon, size: 50, color: primary),
|
| 181 |
+
),
|
| 182 |
+
],
|
| 183 |
+
),
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
}
|
app/lib/ui/features/onboarding/views/splash_screen.dart
CHANGED
|
@@ -82,6 +82,20 @@ class _SplashScreenState extends State<SplashScreen>
|
|
| 82 |
),
|
| 83 |
),
|
| 84 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
],
|
| 86 |
),
|
| 87 |
),
|
|
@@ -91,6 +105,40 @@ class _SplashScreenState extends State<SplashScreen>
|
|
| 91 |
}
|
| 92 |
}
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
/// A small loading splash variant reused while the app boots, without animation.
|
| 95 |
class SplashStatic extends StatelessWidget {
|
| 96 |
const SplashStatic({super.key});
|
|
|
|
| 82 |
),
|
| 83 |
),
|
| 84 |
),
|
| 85 |
+
const SizedBox(height: 28),
|
| 86 |
+
// Floating capability chips drift up as the wordmark settles.
|
| 87 |
+
_FloatingChip(
|
| 88 |
+
icon: Icons.video_library_rounded,
|
| 89 |
+
label: 'Short-form videos',
|
| 90 |
+
t: _wordmark.value,
|
| 91 |
+
),
|
| 92 |
+
const SizedBox(height: 12),
|
| 93 |
+
_FloatingChip(
|
| 94 |
+
icon: Icons.auto_awesome_rounded,
|
| 95 |
+
label: 'AI-powered recall',
|
| 96 |
+
// Slight stagger so the second chip trails the first.
|
| 97 |
+
t: (_wordmark.value * 1.25 - 0.25).clamp(0.0, 1.0),
|
| 98 |
+
),
|
| 99 |
],
|
| 100 |
),
|
| 101 |
),
|
|
|
|
| 105 |
}
|
| 106 |
}
|
| 107 |
|
| 108 |
+
/// A glassy capability pill that fades + drifts up — the splash's floating tags.
|
| 109 |
+
class _FloatingChip extends StatelessWidget {
|
| 110 |
+
const _FloatingChip({required this.icon, required this.label, required this.t});
|
| 111 |
+
final IconData icon;
|
| 112 |
+
final String label;
|
| 113 |
+
final double t;
|
| 114 |
+
|
| 115 |
+
@override
|
| 116 |
+
Widget build(BuildContext context) {
|
| 117 |
+
return Opacity(
|
| 118 |
+
opacity: t.clamp(0.0, 1.0),
|
| 119 |
+
child: Transform.translate(
|
| 120 |
+
offset: Offset(0, (1 - t) * 16),
|
| 121 |
+
child: Container(
|
| 122 |
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
| 123 |
+
decoration: BoxDecoration(
|
| 124 |
+
color: Brand.creamRaised,
|
| 125 |
+
borderRadius: BorderRadius.circular(999),
|
| 126 |
+
border: Border.all(color: Brand.ink.withValues(alpha: 0.08)),
|
| 127 |
+
),
|
| 128 |
+
child: Row(
|
| 129 |
+
mainAxisSize: MainAxisSize.min,
|
| 130 |
+
children: [
|
| 131 |
+
Icon(icon, size: 15, color: Brand.rust),
|
| 132 |
+
const SizedBox(width: 8),
|
| 133 |
+
Text(label, style: Brand.label(size: 12, color: Brand.ink, weight: FontWeight.w600)),
|
| 134 |
+
],
|
| 135 |
+
),
|
| 136 |
+
),
|
| 137 |
+
),
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
/// A small loading splash variant reused while the app boots, without animation.
|
| 143 |
class SplashStatic extends StatelessWidget {
|
| 144 |
const SplashStatic({super.key});
|
app/lib/ui/features/profile/views/profile_screen.dart
CHANGED
|
@@ -7,11 +7,12 @@ import 'package:flutter/material.dart';
|
|
| 7 |
import 'package:provider/provider.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
|
|
|
| 10 |
import '../../../../domain/models/card.dart' as model;
|
| 11 |
-
import '../../../../domain/models/enums.dart';
|
| 12 |
import '../../../core/app_controller.dart';
|
| 13 |
import '../../../core/brand.dart';
|
| 14 |
import '../../../core/theme.dart';
|
|
|
|
| 15 |
|
| 16 |
class ProfileScreen extends StatefulWidget {
|
| 17 |
const ProfileScreen({super.key});
|
|
@@ -22,11 +23,14 @@ class ProfileScreen extends StatefulWidget {
|
|
| 22 |
|
| 23 |
class _ProfileScreenState extends State<ProfileScreen> {
|
| 24 |
late Future<List<model.Card>> _cards;
|
|
|
|
| 25 |
|
| 26 |
@override
|
| 27 |
void initState() {
|
| 28 |
super.initState();
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
@override
|
|
@@ -68,44 +72,40 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|
| 68 |
}
|
| 69 |
|
| 70 |
Widget _header(ThemeData theme) {
|
|
|
|
| 71 |
return FutureBuilder<List<model.Card>>(
|
| 72 |
future: _cards,
|
| 73 |
builder: (context, snap) {
|
| 74 |
final cards = snap.data ?? const <model.Card>[];
|
| 75 |
final total = cards.length;
|
| 76 |
-
final
|
| 77 |
-
|
| 78 |
-
|
| 79 |
.length;
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
],
|
| 105 |
-
),
|
| 106 |
-
),
|
| 107 |
-
],
|
| 108 |
-
),
|
| 109 |
);
|
| 110 |
},
|
| 111 |
);
|
|
|
|
| 7 |
import 'package:provider/provider.dart';
|
| 8 |
|
| 9 |
import '../../../../data/repositories/card_repository.dart';
|
| 10 |
+
import '../../../../domain/models/artifact.dart';
|
| 11 |
import '../../../../domain/models/card.dart' as model;
|
|
|
|
| 12 |
import '../../../core/app_controller.dart';
|
| 13 |
import '../../../core/brand.dart';
|
| 14 |
import '../../../core/theme.dart';
|
| 15 |
+
import '../../../core/widgets/stat_strip.dart';
|
| 16 |
|
| 17 |
class ProfileScreen extends StatefulWidget {
|
| 18 |
const ProfileScreen({super.key});
|
|
|
|
| 23 |
|
| 24 |
class _ProfileScreenState extends State<ProfileScreen> {
|
| 25 |
late Future<List<model.Card>> _cards;
|
| 26 |
+
late Future<List<CatalogEntry>> _catalog;
|
| 27 |
|
| 28 |
@override
|
| 29 |
void initState() {
|
| 30 |
super.initState();
|
| 31 |
+
final repo = context.read<CardRepository>();
|
| 32 |
+
_cards = repo.list();
|
| 33 |
+
_catalog = repo.catalog().catchError((_) => <CatalogEntry>[]);
|
| 34 |
}
|
| 35 |
|
| 36 |
@override
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
Widget _header(ThemeData theme) {
|
| 75 |
+
final scheme = theme.colorScheme;
|
| 76 |
return FutureBuilder<List<model.Card>>(
|
| 77 |
future: _cards,
|
| 78 |
builder: (context, snap) {
|
| 79 |
final cards = snap.data ?? const <model.Card>[];
|
| 80 |
final total = cards.length;
|
| 81 |
+
final weekAgo = DateTime.now().subtract(const Duration(days: 7));
|
| 82 |
+
final thisWeek = cards
|
| 83 |
+
.where((c) => (c.meta.createdAt ?? DateTime(0)).isAfter(weekAgo))
|
| 84 |
.length;
|
| 85 |
+
return Column(
|
| 86 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 87 |
+
children: [
|
| 88 |
+
Row(
|
| 89 |
+
children: [
|
| 90 |
+
CachyGlyph(size: 44, color: scheme.onSurface, reelColor: scheme.primary),
|
| 91 |
+
const SizedBox(width: 14),
|
| 92 |
+
Text('Your shelf',
|
| 93 |
+
style: Brand.wordmarkStyle(24, color: scheme.onSurface)),
|
| 94 |
+
],
|
| 95 |
+
),
|
| 96 |
+
const SizedBox(height: 16),
|
| 97 |
+
FutureBuilder<List<CatalogEntry>>(
|
| 98 |
+
future: _catalog,
|
| 99 |
+
builder: (context, catSnap) {
|
| 100 |
+
final refs = catSnap.data?.length;
|
| 101 |
+
return StatStrip(stats: [
|
| 102 |
+
Stat(value: '$total', label: 'Cards', emphasize: true),
|
| 103 |
+
Stat(value: '$thisWeek', label: 'This week'),
|
| 104 |
+
Stat(value: refs == null ? '—' : '$refs', label: 'References'),
|
| 105 |
+
]);
|
| 106 |
+
},
|
| 107 |
+
),
|
| 108 |
+
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
);
|
| 110 |
},
|
| 111 |
);
|
app/lib/ui/features/reader/view_models/chat_view_model.dart
CHANGED
|
@@ -34,6 +34,13 @@ class ChatViewModel extends ChangeNotifier {
|
|
| 34 |
|
| 35 |
bool get isEmpty => _messages.isEmpty;
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
Future<void> send(String text) async {
|
| 38 |
final trimmed = text.trim();
|
| 39 |
if (trimmed.isEmpty || _busy) return;
|
|
|
|
| 34 |
|
| 35 |
bool get isEmpty => _messages.isEmpty;
|
| 36 |
|
| 37 |
+
/// Fire an opening question on entry (rabbit-hole tap → grounded chat). Runs
|
| 38 |
+
/// after the first frame so the screen is already mounted when the reply lands.
|
| 39 |
+
void seed(String? text) {
|
| 40 |
+
if (text == null || text.trim().isEmpty) return;
|
| 41 |
+
Future.microtask(() => send(text));
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
Future<void> send(String text) async {
|
| 45 |
final trimmed = text.trim();
|
| 46 |
if (trimmed.isEmpty || _busy) return;
|
app/lib/ui/features/reader/views/chat_screen.dart
CHANGED
|
@@ -12,17 +12,25 @@ import '../../../core/theme.dart';
|
|
| 12 |
import '../view_models/chat_view_model.dart';
|
| 13 |
|
| 14 |
class ChatScreen extends StatelessWidget {
|
| 15 |
-
const ChatScreen({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
final String cardId;
|
| 17 |
final String title;
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
@override
|
| 20 |
Widget build(BuildContext context) {
|
| 21 |
return ChangeNotifierProvider(
|
| 22 |
create: (ctx) => ChatViewModel(
|
| 23 |
repository: ctx.read<CardRepository>(),
|
| 24 |
cardId: cardId,
|
| 25 |
-
),
|
| 26 |
child: _ChatView(title: title),
|
| 27 |
);
|
| 28 |
}
|
|
|
|
| 12 |
import '../view_models/chat_view_model.dart';
|
| 13 |
|
| 14 |
class ChatScreen extends StatelessWidget {
|
| 15 |
+
const ChatScreen({
|
| 16 |
+
super.key,
|
| 17 |
+
required this.cardId,
|
| 18 |
+
required this.title,
|
| 19 |
+
this.seed,
|
| 20 |
+
});
|
| 21 |
final String cardId;
|
| 22 |
final String title;
|
| 23 |
|
| 24 |
+
/// Optional opening question, auto-sent on entry (rabbit-hole → ask the card).
|
| 25 |
+
final String? seed;
|
| 26 |
+
|
| 27 |
@override
|
| 28 |
Widget build(BuildContext context) {
|
| 29 |
return ChangeNotifierProvider(
|
| 30 |
create: (ctx) => ChatViewModel(
|
| 31 |
repository: ctx.read<CardRepository>(),
|
| 32 |
cardId: cardId,
|
| 33 |
+
)..seed(seed),
|
| 34 |
child: _ChatView(title: title),
|
| 35 |
);
|
| 36 |
}
|
app/lib/ui/features/reader/views/insight_section.dart
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// The deep-analysis layer (docs/14): claims, blind spots, rabbit holes, a small
|
| 2 |
+
/// topic map, and a doorway to the deep-research prompt. Rendered ONLY when a card
|
| 3 |
+
/// carries an `insight` (idea-rich content); a simple reel shows none of this. Each
|
| 4 |
+
/// sub-section guards on its own content, so a partial layer renders cleanly.
|
| 5 |
+
library;
|
| 6 |
+
|
| 7 |
+
import 'dart:math' as math;
|
| 8 |
+
|
| 9 |
+
import 'package:flutter/material.dart';
|
| 10 |
+
import 'package:flutter/services.dart';
|
| 11 |
+
|
| 12 |
+
import '../../../../domain/models/card.dart';
|
| 13 |
+
import '../../../core/brand.dart';
|
| 14 |
+
import '../../../core/content_accent.dart';
|
| 15 |
+
import '../../../core/theme.dart';
|
| 16 |
+
import '../../../core/widgets/stat_strip.dart';
|
| 17 |
+
import 'chat_screen.dart';
|
| 18 |
+
|
| 19 |
+
class InsightSection extends StatelessWidget {
|
| 20 |
+
const InsightSection({
|
| 21 |
+
super.key,
|
| 22 |
+
required this.insight,
|
| 23 |
+
required this.accent,
|
| 24 |
+
required this.cardId,
|
| 25 |
+
required this.cardTitle,
|
| 26 |
+
required this.readMinutes,
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
final Insight insight;
|
| 30 |
+
final ContentAccent accent;
|
| 31 |
+
final String cardId;
|
| 32 |
+
final String cardTitle;
|
| 33 |
+
|
| 34 |
+
/// Estimated read time of the card body, computed by the reader.
|
| 35 |
+
final int readMinutes;
|
| 36 |
+
|
| 37 |
+
List<Stat> _trio() {
|
| 38 |
+
final rh = insight.rabbitHole;
|
| 39 |
+
final threads =
|
| 40 |
+
rh.questions.length + rh.adjacentTopics.length + rh.advancedConcepts.length;
|
| 41 |
+
return [
|
| 42 |
+
Stat(value: '${readMinutes < 1 ? 1 : readMinutes}m', label: 'Read'),
|
| 43 |
+
Stat(value: '$threads', label: 'Threads', emphasize: true),
|
| 44 |
+
if (insight.topicMap != null)
|
| 45 |
+
Stat(value: '${insight.topicMap!.nodes.length}', label: 'Topics'),
|
| 46 |
+
];
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@override
|
| 50 |
+
Widget build(BuildContext context) {
|
| 51 |
+
final children = <Widget>[];
|
| 52 |
+
if (!insight.rabbitHole.isEmpty) {
|
| 53 |
+
children.add(_RabbitHoleCard(
|
| 54 |
+
rabbitHole: insight.rabbitHole,
|
| 55 |
+
accent: accent,
|
| 56 |
+
cardId: cardId,
|
| 57 |
+
cardTitle: cardTitle,
|
| 58 |
+
));
|
| 59 |
+
}
|
| 60 |
+
if (insight.topicMap != null) {
|
| 61 |
+
children.add(_TopicMapCard(map: insight.topicMap!, accent: accent));
|
| 62 |
+
}
|
| 63 |
+
if (insight.hasDeepResearch) {
|
| 64 |
+
children.add(_DeepResearchButton(prompt: insight.deepResearchPrompt!, accent: accent));
|
| 65 |
+
}
|
| 66 |
+
if (children.isEmpty) return const SizedBox.shrink();
|
| 67 |
+
|
| 68 |
+
return Padding(
|
| 69 |
+
padding: const EdgeInsets.only(top: 28),
|
| 70 |
+
child: Column(
|
| 71 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 72 |
+
children: [
|
| 73 |
+
_SectionHeader(icon: Icons.psychology_alt_rounded, label: 'Going deeper', accent: accent),
|
| 74 |
+
const SizedBox(height: 12),
|
| 75 |
+
StatStrip(stats: _trio()),
|
| 76 |
+
const SizedBox(height: 14),
|
| 77 |
+
_DiveDeeperButton(accent: accent, children: children),
|
| 78 |
+
],
|
| 79 |
+
),
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
class _DiveDeeperButton extends StatefulWidget {
|
| 85 |
+
const _DiveDeeperButton({required this.accent, required this.children});
|
| 86 |
+
final ContentAccent accent;
|
| 87 |
+
final List<Widget> children;
|
| 88 |
+
|
| 89 |
+
@override
|
| 90 |
+
State<_DiveDeeperButton> createState() => _DiveDeeperButtonState();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
class _DiveDeeperButtonState extends State<_DiveDeeperButton> {
|
| 94 |
+
bool _expanded = false;
|
| 95 |
+
|
| 96 |
+
@override
|
| 97 |
+
Widget build(BuildContext context) {
|
| 98 |
+
final theme = Theme.of(context);
|
| 99 |
+
final scheme = theme.colorScheme;
|
| 100 |
+
return Column(
|
| 101 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 102 |
+
children: [
|
| 103 |
+
Material(
|
| 104 |
+
color: _expanded ? scheme.surfaceContainerHighest : scheme.surfaceContainerLow,
|
| 105 |
+
shape: RoundedRectangleBorder(
|
| 106 |
+
borderRadius: BorderRadius.circular(Insets.radius),
|
| 107 |
+
side: BorderSide(
|
| 108 |
+
color: _expanded ? widget.accent.color : scheme.outlineVariant,
|
| 109 |
+
width: _expanded ? 1.5 : 1.0,
|
| 110 |
+
),
|
| 111 |
+
),
|
| 112 |
+
clipBehavior: Clip.antiAlias,
|
| 113 |
+
child: InkWell(
|
| 114 |
+
onTap: () => setState(() => _expanded = !_expanded),
|
| 115 |
+
child: Padding(
|
| 116 |
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
| 117 |
+
child: Row(
|
| 118 |
+
children: [
|
| 119 |
+
Icon(Icons.travel_explore_rounded, size: 20, color: widget.accent.color),
|
| 120 |
+
const SizedBox(width: 10),
|
| 121 |
+
Expanded(
|
| 122 |
+
child: Text(
|
| 123 |
+
'Dive deeper',
|
| 124 |
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
| 125 |
+
),
|
| 126 |
+
),
|
| 127 |
+
AnimatedRotation(
|
| 128 |
+
turns: _expanded ? 0.5 : 0.0,
|
| 129 |
+
duration: const Duration(milliseconds: 200),
|
| 130 |
+
curve: Curves.easeInOut,
|
| 131 |
+
child: Icon(Icons.expand_more_rounded, color: scheme.onSurfaceVariant),
|
| 132 |
+
),
|
| 133 |
+
],
|
| 134 |
+
),
|
| 135 |
+
),
|
| 136 |
+
),
|
| 137 |
+
),
|
| 138 |
+
AnimatedCrossFade(
|
| 139 |
+
firstChild: const SizedBox.shrink(),
|
| 140 |
+
secondChild: Column(
|
| 141 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 142 |
+
children: [
|
| 143 |
+
for (final child in widget.children)
|
| 144 |
+
Padding(padding: const EdgeInsets.only(top: 12), child: child),
|
| 145 |
+
],
|
| 146 |
+
),
|
| 147 |
+
crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
| 148 |
+
duration: const Duration(milliseconds: 250),
|
| 149 |
+
sizeCurve: Curves.easeOutCubic,
|
| 150 |
+
),
|
| 151 |
+
],
|
| 152 |
+
);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
class _SectionHeader extends StatelessWidget {
|
| 157 |
+
const _SectionHeader({required this.icon, required this.label, required this.accent});
|
| 158 |
+
final IconData icon;
|
| 159 |
+
final String label;
|
| 160 |
+
final ContentAccent accent;
|
| 161 |
+
|
| 162 |
+
@override
|
| 163 |
+
Widget build(BuildContext context) {
|
| 164 |
+
return Row(
|
| 165 |
+
children: [
|
| 166 |
+
Icon(icon, size: 16, color: accent.color),
|
| 167 |
+
const SizedBox(width: 7),
|
| 168 |
+
Text(label.toUpperCase(),
|
| 169 |
+
style: Brand.label(size: 11, color: accent.color, weight: FontWeight.w700)),
|
| 170 |
+
],
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/// A bordered container shared by the insight cards — matches the reader's
|
| 176 |
+
/// editorial surfaces.
|
| 177 |
+
class _Panel extends StatelessWidget {
|
| 178 |
+
const _Panel({required this.title, required this.icon, required this.child});
|
| 179 |
+
final String title;
|
| 180 |
+
final IconData icon;
|
| 181 |
+
final Widget child;
|
| 182 |
+
|
| 183 |
+
@override
|
| 184 |
+
Widget build(BuildContext context) {
|
| 185 |
+
final theme = Theme.of(context);
|
| 186 |
+
final scheme = theme.colorScheme;
|
| 187 |
+
return Container(
|
| 188 |
+
padding: const EdgeInsets.all(16),
|
| 189 |
+
decoration: BoxDecoration(
|
| 190 |
+
color: scheme.surfaceContainerLow,
|
| 191 |
+
borderRadius: BorderRadius.circular(Insets.radius),
|
| 192 |
+
border: Border.all(color: scheme.outlineVariant),
|
| 193 |
+
),
|
| 194 |
+
child: Column(
|
| 195 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 196 |
+
children: [
|
| 197 |
+
Row(
|
| 198 |
+
children: [
|
| 199 |
+
Icon(icon, size: 18, color: scheme.onSurfaceVariant),
|
| 200 |
+
const SizedBox(width: 8),
|
| 201 |
+
Text(title,
|
| 202 |
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
| 203 |
+
],
|
| 204 |
+
),
|
| 205 |
+
const SizedBox(height: 12),
|
| 206 |
+
child,
|
| 207 |
+
],
|
| 208 |
+
),
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// --- Rabbit hole ----------------------------------------------------------- //
|
| 214 |
+
|
| 215 |
+
class _RabbitHoleCard extends StatelessWidget {
|
| 216 |
+
const _RabbitHoleCard({
|
| 217 |
+
required this.rabbitHole,
|
| 218 |
+
required this.accent,
|
| 219 |
+
required this.cardId,
|
| 220 |
+
required this.cardTitle,
|
| 221 |
+
});
|
| 222 |
+
final RabbitHole rabbitHole;
|
| 223 |
+
final ContentAccent accent;
|
| 224 |
+
final String cardId;
|
| 225 |
+
final String cardTitle;
|
| 226 |
+
|
| 227 |
+
@override
|
| 228 |
+
Widget build(BuildContext context) {
|
| 229 |
+
final theme = Theme.of(context);
|
| 230 |
+
final groups = <(String, List<String>)>[
|
| 231 |
+
('Questions', rabbitHole.questions),
|
| 232 |
+
('Adjacent topics', rabbitHole.adjacentTopics),
|
| 233 |
+
('Advanced concepts', rabbitHole.advancedConcepts),
|
| 234 |
+
].where((g) => g.$2.isNotEmpty).toList();
|
| 235 |
+
|
| 236 |
+
return _Panel(
|
| 237 |
+
title: 'Rabbit hole',
|
| 238 |
+
icon: Icons.travel_explore_rounded,
|
| 239 |
+
child: Column(
|
| 240 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 241 |
+
children: [
|
| 242 |
+
Padding(
|
| 243 |
+
padding: const EdgeInsets.only(bottom: 6),
|
| 244 |
+
child: Text('Tap a thread to go deeper — the card answers, grounded in its content.',
|
| 245 |
+
style: theme.textTheme.bodySmall
|
| 246 |
+
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
| 247 |
+
),
|
| 248 |
+
for (final group in groups)
|
| 249 |
+
_RabbitHoleGroup(
|
| 250 |
+
label: group.$1,
|
| 251 |
+
items: group.$2,
|
| 252 |
+
accent: accent,
|
| 253 |
+
cardId: cardId,
|
| 254 |
+
cardTitle: cardTitle,
|
| 255 |
+
),
|
| 256 |
+
],
|
| 257 |
+
),
|
| 258 |
+
);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
class _RabbitHoleGroup extends StatelessWidget {
|
| 263 |
+
const _RabbitHoleGroup({
|
| 264 |
+
required this.label,
|
| 265 |
+
required this.items,
|
| 266 |
+
required this.accent,
|
| 267 |
+
required this.cardId,
|
| 268 |
+
required this.cardTitle,
|
| 269 |
+
});
|
| 270 |
+
final String label;
|
| 271 |
+
final List<String> items;
|
| 272 |
+
final ContentAccent accent;
|
| 273 |
+
final String cardId;
|
| 274 |
+
final String cardTitle;
|
| 275 |
+
|
| 276 |
+
void _ask(BuildContext context, String thread) {
|
| 277 |
+
Navigator.of(context).push(
|
| 278 |
+
MaterialPageRoute(
|
| 279 |
+
builder: (_) => ChatScreen(cardId: cardId, title: cardTitle, seed: thread),
|
| 280 |
+
),
|
| 281 |
+
);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
@override
|
| 285 |
+
Widget build(BuildContext context) {
|
| 286 |
+
final theme = Theme.of(context);
|
| 287 |
+
return Theme(
|
| 288 |
+
data: theme.copyWith(dividerColor: Colors.transparent),
|
| 289 |
+
child: ExpansionTile(
|
| 290 |
+
tilePadding: EdgeInsets.zero,
|
| 291 |
+
childrenPadding: const EdgeInsets.only(bottom: 4),
|
| 292 |
+
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
| 293 |
+
initiallyExpanded: true,
|
| 294 |
+
title: Text(label,
|
| 295 |
+
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
| 296 |
+
trailing: Container(
|
| 297 |
+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
| 298 |
+
decoration: BoxDecoration(
|
| 299 |
+
color: theme.colorScheme.surfaceContainerHighest,
|
| 300 |
+
borderRadius: BorderRadius.circular(999),
|
| 301 |
+
),
|
| 302 |
+
child: Text('${items.length}',
|
| 303 |
+
style: theme.textTheme.labelSmall
|
| 304 |
+
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
| 305 |
+
),
|
| 306 |
+
children: [
|
| 307 |
+
for (final item in items)
|
| 308 |
+
InkWell(
|
| 309 |
+
onTap: () => _ask(context, item),
|
| 310 |
+
borderRadius: BorderRadius.circular(8),
|
| 311 |
+
child: Padding(
|
| 312 |
+
padding: const EdgeInsets.symmetric(vertical: 8),
|
| 313 |
+
child: Row(
|
| 314 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 315 |
+
children: [
|
| 316 |
+
Padding(
|
| 317 |
+
padding: const EdgeInsets.only(top: 1),
|
| 318 |
+
child: Icon(Icons.chat_bubble_outline_rounded,
|
| 319 |
+
size: 15, color: accent.color),
|
| 320 |
+
),
|
| 321 |
+
const SizedBox(width: 8),
|
| 322 |
+
Expanded(
|
| 323 |
+
child: Text(item,
|
| 324 |
+
style: theme.textTheme.bodyMedium?.copyWith(height: 1.3)),
|
| 325 |
+
),
|
| 326 |
+
Icon(Icons.arrow_outward_rounded,
|
| 327 |
+
size: 14, color: theme.colorScheme.onSurfaceVariant),
|
| 328 |
+
],
|
| 329 |
+
),
|
| 330 |
+
),
|
| 331 |
+
),
|
| 332 |
+
],
|
| 333 |
+
),
|
| 334 |
+
);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// --- Topic map ------------------------------------------------------------- //
|
| 339 |
+
|
| 340 |
+
class _TopicMapCard extends StatelessWidget {
|
| 341 |
+
const _TopicMapCard({required this.map, required this.accent});
|
| 342 |
+
final TopicMap map;
|
| 343 |
+
final ContentAccent accent;
|
| 344 |
+
|
| 345 |
+
@override
|
| 346 |
+
Widget build(BuildContext context) {
|
| 347 |
+
return _Panel(
|
| 348 |
+
title: 'Topic map',
|
| 349 |
+
icon: Icons.hub_outlined,
|
| 350 |
+
child: SizedBox(
|
| 351 |
+
height: 240,
|
| 352 |
+
width: double.infinity,
|
| 353 |
+
child: CustomPaint(
|
| 354 |
+
painter: _TopicMapPainter(
|
| 355 |
+
center: map.center,
|
| 356 |
+
nodes: map.nodes,
|
| 357 |
+
accent: accent.color,
|
| 358 |
+
line: Theme.of(context).colorScheme.outlineVariant,
|
| 359 |
+
centerText: Colors.white,
|
| 360 |
+
nodeFill: Theme.of(context).colorScheme.surfaceContainerHighest,
|
| 361 |
+
nodeText: Theme.of(context).colorScheme.onSurface,
|
| 362 |
+
),
|
| 363 |
+
),
|
| 364 |
+
),
|
| 365 |
+
);
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
class _TopicMapPainter extends CustomPainter {
|
| 370 |
+
_TopicMapPainter({
|
| 371 |
+
required this.center,
|
| 372 |
+
required this.nodes,
|
| 373 |
+
required this.accent,
|
| 374 |
+
required this.line,
|
| 375 |
+
required this.centerText,
|
| 376 |
+
required this.nodeFill,
|
| 377 |
+
required this.nodeText,
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
final String center;
|
| 381 |
+
final List<String> nodes;
|
| 382 |
+
final Color accent;
|
| 383 |
+
final Color line;
|
| 384 |
+
final Color centerText;
|
| 385 |
+
final Color nodeFill;
|
| 386 |
+
final Color nodeText;
|
| 387 |
+
|
| 388 |
+
@override
|
| 389 |
+
void paint(Canvas canvas, Size size) {
|
| 390 |
+
final c = Offset(size.width / 2, size.height / 2);
|
| 391 |
+
final radius = math.min(size.width, size.height) / 2 - 38;
|
| 392 |
+
const centerR = 40.0;
|
| 393 |
+
const nodeR = 30.0;
|
| 394 |
+
|
| 395 |
+
final linePaint = Paint()
|
| 396 |
+
..color = line
|
| 397 |
+
..strokeWidth = 1.2
|
| 398 |
+
..style = PaintingStyle.stroke;
|
| 399 |
+
|
| 400 |
+
final positions = <Offset>[];
|
| 401 |
+
for (var i = 0; i < nodes.length; i++) {
|
| 402 |
+
final angle = -math.pi / 2 + (2 * math.pi * i / nodes.length);
|
| 403 |
+
positions.add(Offset(c.dx + radius * math.cos(angle), c.dy + radius * math.sin(angle)));
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// Connecting lines (dashed-ish: just straight, kept subtle).
|
| 407 |
+
for (final p in positions) {
|
| 408 |
+
canvas.drawLine(c, p, linePaint);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// Satellite nodes.
|
| 412 |
+
for (var i = 0; i < positions.length; i++) {
|
| 413 |
+
canvas.drawCircle(positions[i], nodeR, Paint()..color = nodeFill);
|
| 414 |
+
canvas.drawCircle(positions[i], nodeR, linePaint);
|
| 415 |
+
_label(canvas, nodes[i], positions[i], nodeR * 2 - 6, nodeText, 9);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Center node.
|
| 419 |
+
canvas.drawCircle(c, centerR, Paint()..color = accent);
|
| 420 |
+
_label(canvas, center, c, centerR * 2 - 6, centerText, 11, bold: true);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
void _label(Canvas canvas, String text, Offset at, double maxWidth, Color color,
|
| 424 |
+
double fontSize, {bool bold = false}) {
|
| 425 |
+
final tp = TextPainter(
|
| 426 |
+
text: TextSpan(
|
| 427 |
+
text: text,
|
| 428 |
+
style: TextStyle(
|
| 429 |
+
color: color,
|
| 430 |
+
fontSize: fontSize,
|
| 431 |
+
fontWeight: bold ? FontWeight.w800 : FontWeight.w600,
|
| 432 |
+
height: 1.1,
|
| 433 |
+
),
|
| 434 |
+
),
|
| 435 |
+
textAlign: TextAlign.center,
|
| 436 |
+
textDirection: TextDirection.ltr,
|
| 437 |
+
maxLines: 2,
|
| 438 |
+
ellipsis: '…',
|
| 439 |
+
)..layout(maxWidth: maxWidth);
|
| 440 |
+
tp.paint(canvas, Offset(at.dx - tp.width / 2, at.dy - tp.height / 2));
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
@override
|
| 444 |
+
bool shouldRepaint(covariant _TopicMapPainter old) =>
|
| 445 |
+
old.center != center || old.nodes != nodes || old.accent != accent;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// --- Deep research --------------------------------------------------------- //
|
| 449 |
+
|
| 450 |
+
class _DeepResearchButton extends StatelessWidget {
|
| 451 |
+
const _DeepResearchButton({required this.prompt, required this.accent});
|
| 452 |
+
final String prompt;
|
| 453 |
+
final ContentAccent accent;
|
| 454 |
+
|
| 455 |
+
@override
|
| 456 |
+
Widget build(BuildContext context) {
|
| 457 |
+
return SizedBox(
|
| 458 |
+
width: double.infinity,
|
| 459 |
+
child: FilledButton.icon(
|
| 460 |
+
style: FilledButton.styleFrom(
|
| 461 |
+
backgroundColor: accent.color,
|
| 462 |
+
foregroundColor: Colors.white,
|
| 463 |
+
padding: const EdgeInsets.symmetric(vertical: 16),
|
| 464 |
+
),
|
| 465 |
+
onPressed: () => Navigator.of(context).push(
|
| 466 |
+
MaterialPageRoute(builder: (_) => DeepResearchScreen(prompt: prompt, accent: accent)),
|
| 467 |
+
),
|
| 468 |
+
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
|
| 469 |
+
label: const Text('Deep research prompt'),
|
| 470 |
+
),
|
| 471 |
+
);
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/// The deep-research prompt screen (docs/14): a ready-to-paste research brief for
|
| 476 |
+
/// an external frontier LLM. Copy is the dominant action.
|
| 477 |
+
class DeepResearchScreen extends StatelessWidget {
|
| 478 |
+
const DeepResearchScreen({super.key, required this.prompt, required this.accent});
|
| 479 |
+
final String prompt;
|
| 480 |
+
final ContentAccent accent;
|
| 481 |
+
|
| 482 |
+
int get _tokenEstimate => (prompt.length / 4).ceil();
|
| 483 |
+
|
| 484 |
+
void _copy(BuildContext context) {
|
| 485 |
+
Clipboard.setData(ClipboardData(text: prompt));
|
| 486 |
+
ScaffoldMessenger.of(context).showSnackBar(
|
| 487 |
+
const SnackBar(content: Text('Prompt copied'), duration: Motion.medium),
|
| 488 |
+
);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
@override
|
| 492 |
+
Widget build(BuildContext context) {
|
| 493 |
+
final theme = Theme.of(context);
|
| 494 |
+
final scheme = theme.colorScheme;
|
| 495 |
+
return Scaffold(
|
| 496 |
+
appBar: AppBar(title: const Text('Deep research')),
|
| 497 |
+
body: Center(
|
| 498 |
+
child: ConstrainedBox(
|
| 499 |
+
constraints: const BoxConstraints(maxWidth: Insets.readingColumn),
|
| 500 |
+
child: ListView(
|
| 501 |
+
padding: const EdgeInsets.fromLTRB(Insets.page, 16, Insets.page, 120),
|
| 502 |
+
children: [
|
| 503 |
+
Row(
|
| 504 |
+
children: [
|
| 505 |
+
Icon(Icons.auto_awesome_rounded, size: 16, color: accent.color),
|
| 506 |
+
const SizedBox(width: 7),
|
| 507 |
+
Text('DEEP RESEARCH PROMPT',
|
| 508 |
+
style: Brand.label(size: 11, color: accent.color, weight: FontWeight.w700)),
|
| 509 |
+
],
|
| 510 |
+
),
|
| 511 |
+
const SizedBox(height: 8),
|
| 512 |
+
Text('~$_tokenEstimate tokens · paste into ChatGPT or Gemini',
|
| 513 |
+
style: theme.textTheme.bodySmall
|
| 514 |
+
?.copyWith(color: scheme.onSurfaceVariant)),
|
| 515 |
+
const SizedBox(height: 16),
|
| 516 |
+
Container(
|
| 517 |
+
padding: const EdgeInsets.all(16),
|
| 518 |
+
decoration: BoxDecoration(
|
| 519 |
+
color: scheme.surfaceContainerLow,
|
| 520 |
+
borderRadius: BorderRadius.circular(Insets.radius),
|
| 521 |
+
border: Border.all(color: scheme.outlineVariant),
|
| 522 |
+
),
|
| 523 |
+
child: SelectableText(
|
| 524 |
+
prompt,
|
| 525 |
+
style: theme.textTheme.bodyMedium?.copyWith(
|
| 526 |
+
fontFamily: 'monospace',
|
| 527 |
+
height: 1.5,
|
| 528 |
+
),
|
| 529 |
+
),
|
| 530 |
+
),
|
| 531 |
+
],
|
| 532 |
+
),
|
| 533 |
+
),
|
| 534 |
+
),
|
| 535 |
+
bottomSheet: Padding(
|
| 536 |
+
padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 20),
|
| 537 |
+
child: Row(
|
| 538 |
+
children: [
|
| 539 |
+
Expanded(
|
| 540 |
+
child: FilledButton.icon(
|
| 541 |
+
style: FilledButton.styleFrom(
|
| 542 |
+
backgroundColor: accent.color,
|
| 543 |
+
foregroundColor: Colors.white,
|
| 544 |
+
padding: const EdgeInsets.symmetric(vertical: 16),
|
| 545 |
+
),
|
| 546 |
+
onPressed: () => _copy(context),
|
| 547 |
+
icon: const Icon(Icons.copy_rounded, size: 18),
|
| 548 |
+
label: const Text('Copy prompt'),
|
| 549 |
+
),
|
| 550 |
+
),
|
| 551 |
+
const SizedBox(width: 12),
|
| 552 |
+
IconButton.filledTonal(
|
| 553 |
+
onPressed: () => _copy(context),
|
| 554 |
+
icon: const Icon(Icons.ios_share_rounded),
|
| 555 |
+
),
|
| 556 |
+
],
|
| 557 |
+
),
|
| 558 |
+
),
|
| 559 |
+
);
|
| 560 |
+
}
|
| 561 |
+
}
|
app/lib/ui/features/reader/views/reader_screen.dart
CHANGED
|
@@ -20,9 +20,11 @@ import '../../../core/theme.dart';
|
|
| 20 |
import '../../../core/widgets/card_face.dart';
|
| 21 |
import '../../../core/widgets/error_state.dart';
|
| 22 |
import '../../../core/widgets/pipeline_progress.dart';
|
|
|
|
| 23 |
import '../../blocks/block_renderer.dart';
|
| 24 |
import '../../catalog/services/artifact_lookup.dart';
|
| 25 |
import '../view_models/reader_view_model.dart';
|
|
|
|
| 26 |
import 'primary_action_bar.dart';
|
| 27 |
|
| 28 |
class ReaderScreen extends StatelessWidget {
|
|
@@ -122,6 +124,19 @@ class _ReaderView extends StatelessWidget {
|
|
| 122 |
// Action items (docs/13): follow into the Actions hub, tick off.
|
| 123 |
if (card.isReady && card.actionItems.isPresent)
|
| 124 |
_ActionItemsSection(card: card, accent: accent, vm: vm),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
// Referenced things (books/products/places) as tappable covers.
|
| 126 |
if (card.isReady) _ReferencesStrip(entries: vm.artifacts),
|
| 127 |
if (card.source.creator != null) ...[
|
|
@@ -140,6 +155,25 @@ class _ReaderView extends StatelessWidget {
|
|
| 140 |
);
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
void _copyUrl(BuildContext context, String url) {
|
| 144 |
Clipboard.setData(ClipboardData(text: url));
|
| 145 |
ScaffoldMessenger.of(context).showSnackBar(
|
|
@@ -270,9 +304,13 @@ class _ProcessingPanel extends StatelessWidget {
|
|
| 270 |
child: Column(
|
| 271 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 272 |
children: [
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
PipelineProgress(current: stage, detail: vm.lastEvent?.detail ?? ''),
|
| 277 |
],
|
| 278 |
),
|
|
@@ -363,7 +401,7 @@ class _ReferencesStrip extends StatelessWidget {
|
|
| 363 |
?.copyWith(fontWeight: FontWeight.w700)),
|
| 364 |
const SizedBox(height: 12),
|
| 365 |
SizedBox(
|
| 366 |
-
height:
|
| 367 |
child: ListView.separated(
|
| 368 |
scrollDirection: Axis.horizontal,
|
| 369 |
itemCount: entries.length,
|
|
@@ -440,12 +478,14 @@ class _ReferenceTile extends StatelessWidget {
|
|
| 440 |
),
|
| 441 |
),
|
| 442 |
const SizedBox(height: 5),
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
| 449 |
),
|
| 450 |
],
|
| 451 |
),
|
|
|
|
| 20 |
import '../../../core/widgets/card_face.dart';
|
| 21 |
import '../../../core/widgets/error_state.dart';
|
| 22 |
import '../../../core/widgets/pipeline_progress.dart';
|
| 23 |
+
import '../../../core/widgets/processing_glyph.dart';
|
| 24 |
import '../../blocks/block_renderer.dart';
|
| 25 |
import '../../catalog/services/artifact_lookup.dart';
|
| 26 |
import '../view_models/reader_view_model.dart';
|
| 27 |
+
import 'insight_section.dart';
|
| 28 |
import 'primary_action_bar.dart';
|
| 29 |
|
| 30 |
class ReaderScreen extends StatelessWidget {
|
|
|
|
| 124 |
// Action items (docs/13): follow into the Actions hub, tick off.
|
| 125 |
if (card.isReady && card.actionItems.isPresent)
|
| 126 |
_ActionItemsSection(card: card, accent: accent, vm: vm),
|
| 127 |
+
// Deep-analysis layer (docs/14): claims, blind spots, rabbit
|
| 128 |
+
// holes, topic map, deep-research prompt. Present only on
|
| 129 |
+
// idea-rich cards — a simple reel renders nothing here.
|
| 130 |
+
if (card.isReady &&
|
| 131 |
+
card.insight != null &&
|
| 132 |
+
card.insight!.hasContent)
|
| 133 |
+
InsightSection(
|
| 134 |
+
insight: card.insight!,
|
| 135 |
+
accent: accent,
|
| 136 |
+
cardId: card.cardId,
|
| 137 |
+
cardTitle: card.base.oneLiner,
|
| 138 |
+
readMinutes: _estimateReadMinutes(card),
|
| 139 |
+
),
|
| 140 |
// Referenced things (books/products/places) as tappable covers.
|
| 141 |
if (card.isReady) _ReferencesStrip(entries: vm.artifacts),
|
| 142 |
if (card.source.creator != null) ...[
|
|
|
|
| 155 |
);
|
| 156 |
}
|
| 157 |
|
| 158 |
+
/// Rough read time of the card body (~200 wpm) from its text fields — drives
|
| 159 |
+
/// the insight stat trio. Counts the tldr plus all block text content.
|
| 160 |
+
int _estimateReadMinutes(model.Card card) {
|
| 161 |
+
var words = card.base.tldr.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length;
|
| 162 |
+
for (final b in card.rawBlocks) {
|
| 163 |
+
void add(Object? v) {
|
| 164 |
+
if (v is String) words += v.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length;
|
| 165 |
+
}
|
| 166 |
+
add(b['text']);
|
| 167 |
+
for (final it in (b['items'] as List?) ?? const []) {
|
| 168 |
+
add(it is Map ? it['text'] : it);
|
| 169 |
+
}
|
| 170 |
+
for (final st in (b['steps'] as List?) ?? const []) {
|
| 171 |
+
add(st is Map ? st['text'] : st);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
return (words / 200).ceil();
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
void _copyUrl(BuildContext context, String url) {
|
| 178 |
Clipboard.setData(ClipboardData(text: url));
|
| 179 |
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
| 304 |
child: Column(
|
| 305 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 306 |
children: [
|
| 307 |
+
const Center(child: ProcessingGlyph(size: 116)),
|
| 308 |
+
const SizedBox(height: 18),
|
| 309 |
+
Center(
|
| 310 |
+
child: Text('Building your card',
|
| 311 |
+
style: Theme.of(context).textTheme.titleMedium),
|
| 312 |
+
),
|
| 313 |
+
const SizedBox(height: 18),
|
| 314 |
PipelineProgress(current: stage, detail: vm.lastEvent?.detail ?? ''),
|
| 315 |
],
|
| 316 |
),
|
|
|
|
| 401 |
?.copyWith(fontWeight: FontWeight.w700)),
|
| 402 |
const SizedBox(height: 12),
|
| 403 |
SizedBox(
|
| 404 |
+
height: 168,
|
| 405 |
child: ListView.separated(
|
| 406 |
scrollDirection: Axis.horizontal,
|
| 407 |
itemCount: entries.length,
|
|
|
|
| 478 |
),
|
| 479 |
),
|
| 480 |
const SizedBox(height: 5),
|
| 481 |
+
Flexible(
|
| 482 |
+
child: Text(
|
| 483 |
+
entry.title,
|
| 484 |
+
maxLines: 2,
|
| 485 |
+
overflow: TextOverflow.ellipsis,
|
| 486 |
+
style: theme.textTheme.labelSmall
|
| 487 |
+
?.copyWith(fontWeight: FontWeight.w600, height: 1.15),
|
| 488 |
+
),
|
| 489 |
),
|
| 490 |
],
|
| 491 |
),
|
app/lib/ui/features/search/views/search_screen.dart
CHANGED
|
@@ -10,9 +10,12 @@ import 'package:provider/provider.dart';
|
|
| 10 |
|
| 11 |
import '../../../../data/repositories/card_repository.dart';
|
| 12 |
import '../../../../domain/models/card.dart' as model;
|
|
|
|
|
|
|
| 13 |
import '../../../core/theme.dart';
|
| 14 |
import '../../../core/widgets/empty_state.dart';
|
| 15 |
import '../../../core/widgets/error_state.dart';
|
|
|
|
| 16 |
import '../../library/views/card_tile.dart';
|
| 17 |
import '../../reader/views/reader_screen.dart';
|
| 18 |
|
|
@@ -31,6 +34,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 31 |
_Status _status = _Status.idle;
|
| 32 |
List<model.Card> _results = const [];
|
| 33 |
String _query = '';
|
|
|
|
| 34 |
|
| 35 |
void _onChanged(String value) {
|
| 36 |
_query = value.trim();
|
|
@@ -50,6 +54,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 50 |
if (!mounted) return;
|
| 51 |
setState(() {
|
| 52 |
_results = cards;
|
|
|
|
| 53 |
_status = cards.isEmpty ? _Status.empty : _Status.results;
|
| 54 |
});
|
| 55 |
} catch (_) {
|
|
@@ -58,6 +63,20 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 58 |
}
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
@override
|
| 62 |
void dispose() {
|
| 63 |
_debounce?.cancel();
|
|
@@ -116,34 +135,146 @@ class _SearchScreenState extends State<SearchScreen> {
|
|
| 116 |
return EmptyState(
|
| 117 |
icon: Icons.search_off_rounded,
|
| 118 |
title: 'No matches',
|
| 119 |
-
message: 'Nothing matched "$_query". Try a different word.',
|
|
|
|
|
|
|
| 120 |
);
|
| 121 |
case _Status.results:
|
| 122 |
final api = context.read<CardRepository>().api;
|
| 123 |
final cols = (MediaQuery.of(context).size.width / 200).floor().clamp(2, 5);
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
),
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
},
|
| 146 |
);
|
| 147 |
}
|
| 148 |
}
|
| 149 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
import '../../../../data/repositories/card_repository.dart';
|
| 12 |
import '../../../../domain/models/card.dart' as model;
|
| 13 |
+
import '../../../../domain/models/enums.dart';
|
| 14 |
+
import '../../../core/brand.dart';
|
| 15 |
import '../../../core/theme.dart';
|
| 16 |
import '../../../core/widgets/empty_state.dart';
|
| 17 |
import '../../../core/widgets/error_state.dart';
|
| 18 |
+
import '../../capture/views/capture_sheet.dart';
|
| 19 |
import '../../library/views/card_tile.dart';
|
| 20 |
import '../../reader/views/reader_screen.dart';
|
| 21 |
|
|
|
|
| 34 |
_Status _status = _Status.idle;
|
| 35 |
List<model.Card> _results = const [];
|
| 36 |
String _query = '';
|
| 37 |
+
ContentType? _filter; // null = All
|
| 38 |
|
| 39 |
void _onChanged(String value) {
|
| 40 |
_query = value.trim();
|
|
|
|
| 54 |
if (!mounted) return;
|
| 55 |
setState(() {
|
| 56 |
_results = cards;
|
| 57 |
+
_filter = null; // reset filter on a fresh query
|
| 58 |
_status = cards.isEmpty ? _Status.empty : _Status.results;
|
| 59 |
});
|
| 60 |
} catch (_) {
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
|
| 66 |
+
/// Content types actually present in the current results — only offer filters
|
| 67 |
+
/// that match something, in the order they appear.
|
| 68 |
+
List<ContentType> get _presentTypes {
|
| 69 |
+
final seen = <ContentType>[];
|
| 70 |
+
for (final c in _results) {
|
| 71 |
+
if (!seen.contains(c.base.contentType)) seen.add(c.base.contentType);
|
| 72 |
+
}
|
| 73 |
+
return seen;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
List<model.Card> get _filtered => _filter == null
|
| 77 |
+
? _results
|
| 78 |
+
: _results.where((c) => c.base.contentType == _filter).toList();
|
| 79 |
+
|
| 80 |
@override
|
| 81 |
void dispose() {
|
| 82 |
_debounce?.cancel();
|
|
|
|
| 135 |
return EmptyState(
|
| 136 |
icon: Icons.search_off_rounded,
|
| 137 |
title: 'No matches',
|
| 138 |
+
message: 'Nothing matched "$_query". Try a different word, or capture a reel about it.',
|
| 139 |
+
actionLabel: 'Capture a reel',
|
| 140 |
+
onAction: () => showCaptureSheet(context),
|
| 141 |
);
|
| 142 |
case _Status.results:
|
| 143 |
final api = context.read<CardRepository>().api;
|
| 144 |
final cols = (MediaQuery.of(context).size.width / 200).floor().clamp(2, 5);
|
| 145 |
+
final cards = _filtered;
|
| 146 |
+
return Column(
|
| 147 |
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 148 |
+
children: [
|
| 149 |
+
_FilterBar(
|
| 150 |
+
types: _presentTypes,
|
| 151 |
+
selected: _filter,
|
| 152 |
+
total: _results.length,
|
| 153 |
+
shown: cards.length,
|
| 154 |
+
onSelect: (t) => setState(() => _filter = t),
|
| 155 |
+
),
|
| 156 |
+
Expanded(
|
| 157 |
+
child: GridView.builder(
|
| 158 |
+
padding: const EdgeInsets.fromLTRB(
|
| 159 |
+
Insets.page, 4, Insets.page, Insets.page),
|
| 160 |
+
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
| 161 |
+
crossAxisCount: cols,
|
| 162 |
+
mainAxisSpacing: Insets.block,
|
| 163 |
+
crossAxisSpacing: Insets.block,
|
| 164 |
+
childAspectRatio: 0.72,
|
| 165 |
),
|
| 166 |
+
itemCount: cards.length,
|
| 167 |
+
itemBuilder: (_, i) {
|
| 168 |
+
final card = cards[i];
|
| 169 |
+
return CardTile(
|
| 170 |
+
card: card,
|
| 171 |
+
api: api,
|
| 172 |
+
onTap: () => Navigator.of(context).push(
|
| 173 |
+
MaterialPageRoute(
|
| 174 |
+
builder: (_) => ReaderScreen(cardId: card.cardId),
|
| 175 |
+
),
|
| 176 |
+
),
|
| 177 |
+
onDelete: () {},
|
| 178 |
+
);
|
| 179 |
+
},
|
| 180 |
),
|
| 181 |
+
),
|
| 182 |
+
],
|
|
|
|
| 183 |
);
|
| 184 |
}
|
| 185 |
}
|
| 186 |
}
|
| 187 |
+
|
| 188 |
+
/// Result count + content-type filter pills above the result wall. Only shows
|
| 189 |
+
/// filters for types present in the results (docs/06 search).
|
| 190 |
+
class _FilterBar extends StatelessWidget {
|
| 191 |
+
const _FilterBar({
|
| 192 |
+
required this.types,
|
| 193 |
+
required this.selected,
|
| 194 |
+
required this.total,
|
| 195 |
+
required this.shown,
|
| 196 |
+
required this.onSelect,
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
final List<ContentType> types;
|
| 200 |
+
final ContentType? selected;
|
| 201 |
+
final int total;
|
| 202 |
+
final int shown;
|
| 203 |
+
final ValueChanged<ContentType?> onSelect;
|
| 204 |
+
|
| 205 |
+
@override
|
| 206 |
+
Widget build(BuildContext context) {
|
| 207 |
+
final theme = Theme.of(context);
|
| 208 |
+
final scheme = theme.colorScheme;
|
| 209 |
+
final count = selected == null ? total : shown;
|
| 210 |
+
return Column(
|
| 211 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 212 |
+
children: [
|
| 213 |
+
Padding(
|
| 214 |
+
padding: const EdgeInsets.fromLTRB(Insets.page, 10, Insets.page, 8),
|
| 215 |
+
child: Text(
|
| 216 |
+
'$count ${count == 1 ? 'result' : 'results'}',
|
| 217 |
+
style: Brand.label(
|
| 218 |
+
size: 11, color: scheme.onSurfaceVariant, weight: FontWeight.w700),
|
| 219 |
+
),
|
| 220 |
+
),
|
| 221 |
+
SizedBox(
|
| 222 |
+
height: 38,
|
| 223 |
+
child: ListView(
|
| 224 |
+
scrollDirection: Axis.horizontal,
|
| 225 |
+
padding: const EdgeInsets.symmetric(horizontal: Insets.page),
|
| 226 |
+
children: [
|
| 227 |
+
_Chip(
|
| 228 |
+
label: 'All',
|
| 229 |
+
selected: selected == null,
|
| 230 |
+
onTap: () => onSelect(null),
|
| 231 |
+
),
|
| 232 |
+
for (final t in types)
|
| 233 |
+
_Chip(
|
| 234 |
+
label: t.label,
|
| 235 |
+
selected: selected == t,
|
| 236 |
+
onTap: () => onSelect(t),
|
| 237 |
+
),
|
| 238 |
+
],
|
| 239 |
+
),
|
| 240 |
+
),
|
| 241 |
+
],
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
class _Chip extends StatelessWidget {
|
| 247 |
+
const _Chip({required this.label, required this.selected, required this.onTap});
|
| 248 |
+
final String label;
|
| 249 |
+
final bool selected;
|
| 250 |
+
final VoidCallback onTap;
|
| 251 |
+
|
| 252 |
+
@override
|
| 253 |
+
Widget build(BuildContext context) {
|
| 254 |
+
final scheme = Theme.of(context).colorScheme;
|
| 255 |
+
return Padding(
|
| 256 |
+
padding: const EdgeInsets.only(right: 8),
|
| 257 |
+
child: GestureDetector(
|
| 258 |
+
onTap: onTap,
|
| 259 |
+
child: Container(
|
| 260 |
+
alignment: Alignment.center,
|
| 261 |
+
padding: const EdgeInsets.symmetric(horizontal: 14),
|
| 262 |
+
decoration: BoxDecoration(
|
| 263 |
+
color: selected ? scheme.primary : scheme.surfaceContainerHigh,
|
| 264 |
+
borderRadius: BorderRadius.circular(999),
|
| 265 |
+
border: Border.all(
|
| 266 |
+
color: selected ? scheme.primary : scheme.outlineVariant),
|
| 267 |
+
),
|
| 268 |
+
child: Text(
|
| 269 |
+
label,
|
| 270 |
+
style: Brand.label(
|
| 271 |
+
size: 11,
|
| 272 |
+
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
|
| 273 |
+
weight: FontWeight.w700,
|
| 274 |
+
),
|
| 275 |
+
),
|
| 276 |
+
),
|
| 277 |
+
),
|
| 278 |
+
);
|
| 279 |
+
}
|
| 280 |
+
}
|
app/lib/ui/features/share/views/share_screen.dart
CHANGED
|
@@ -15,6 +15,7 @@ import '../../../core/brand.dart';
|
|
| 15 |
import '../../../core/theme.dart';
|
| 16 |
import '../../../core/widgets/error_state.dart';
|
| 17 |
import '../../../core/widgets/pipeline_progress.dart';
|
|
|
|
| 18 |
import '../../reader/views/reader_screen.dart';
|
| 19 |
import '../view_models/share_view_model.dart';
|
| 20 |
|
|
@@ -76,8 +77,8 @@ class _ShareView extends StatelessWidget {
|
|
| 76 |
child: Column(
|
| 77 |
mainAxisSize: MainAxisSize.min,
|
| 78 |
children: [
|
| 79 |
-
|
| 80 |
-
const SizedBox(height:
|
| 81 |
const Text('Sending to Cachy…'),
|
| 82 |
],
|
| 83 |
),
|
|
@@ -125,6 +126,8 @@ class _ShareView extends StatelessWidget {
|
|
| 125 |
return Column(
|
| 126 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 127 |
children: [
|
|
|
|
|
|
|
| 128 |
Text('Building your card', style: theme.textTheme.headlineSmall),
|
| 129 |
const SizedBox(height: 8),
|
| 130 |
Text(
|
|
|
|
| 15 |
import '../../../core/theme.dart';
|
| 16 |
import '../../../core/widgets/error_state.dart';
|
| 17 |
import '../../../core/widgets/pipeline_progress.dart';
|
| 18 |
+
import '../../../core/widgets/processing_glyph.dart';
|
| 19 |
import '../../reader/views/reader_screen.dart';
|
| 20 |
import '../view_models/share_view_model.dart';
|
| 21 |
|
|
|
|
| 77 |
child: Column(
|
| 78 |
mainAxisSize: MainAxisSize.min,
|
| 79 |
children: [
|
| 80 |
+
const ProcessingGlyph(size: 132),
|
| 81 |
+
const SizedBox(height: 20),
|
| 82 |
const Text('Sending to Cachy…'),
|
| 83 |
],
|
| 84 |
),
|
|
|
|
| 126 |
return Column(
|
| 127 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 128 |
children: [
|
| 129 |
+
const Center(child: ProcessingGlyph(size: 132)),
|
| 130 |
+
const SizedBox(height: 24),
|
| 131 |
Text('Building your card', style: theme.textTheme.headlineSmall),
|
| 132 |
const SizedBox(height: 8),
|
| 133 |
Text(
|
backend/app/models/card.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Annotated, Literal, Optional, Union
|
|
| 13 |
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
|
| 16 |
-
SCHEMA_VERSION = "1.
|
| 17 |
|
| 18 |
|
| 19 |
# --------------------------------------------------------------------------- #
|
|
@@ -217,6 +217,50 @@ class ActionItems(BaseModel):
|
|
| 217 |
items: list[ActionItem] = Field(default_factory=list)
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
class Media(BaseModel):
|
| 221 |
thumbnail: Optional[str] = None
|
| 222 |
keyframes: list[str] = Field(default_factory=list)
|
|
@@ -246,5 +290,7 @@ class Card(BaseModel):
|
|
| 246 |
primary_action: PrimaryAction = Field(default_factory=PrimaryAction)
|
| 247 |
action_items: ActionItems = Field(default_factory=ActionItems)
|
| 248 |
blocks: list[Block] = Field(default_factory=list)
|
|
|
|
|
|
|
| 249 |
media: Media = Field(default_factory=Media)
|
| 250 |
meta: Meta = Field(default_factory=Meta)
|
|
|
|
| 13 |
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
|
| 16 |
+
SCHEMA_VERSION = "1.4" # 1.1: artifacts list (docs/12); 1.2: base.tags (docs/09); 1.3: action_items (docs/13); 1.4: insight layer (docs/14)
|
| 17 |
|
| 18 |
|
| 19 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 217 |
items: list[ActionItem] = Field(default_factory=list)
|
| 218 |
|
| 219 |
|
| 220 |
+
# --------------------------------------------------------------------------- #
|
| 221 |
+
# Insight layer (docs/14) — the optional "deep" analysis. Populated by a SECOND,
|
| 222 |
+
# gated LLM pass ONLY for knowledge-rich cards; a simple reel (recipe, a quick
|
| 223 |
+
# tip) carries `insight = None` and renders none of this. Within a deep card each
|
| 224 |
+
# sub-section is independently optional — emit only what the content warrants.
|
| 225 |
+
#
|
| 226 |
+
# Everything here is ACTIONABLE, never a passive list: rabbit-hole threads are
|
| 227 |
+
# tappable doorways into grounded chat, the topic map orients, and the research
|
| 228 |
+
# prompt is paste-ready. (Earlier Claims / What's-Missing sections were removed —
|
| 229 |
+
# read-only analysis with nothing to do.)
|
| 230 |
+
# --------------------------------------------------------------------------- #
|
| 231 |
+
|
| 232 |
+
class RabbitHole(BaseModel):
|
| 233 |
+
"""Threads to pull on to go deeper. Each becomes a tappable prompt in the UI
|
| 234 |
+
(opens grounded chat). Each list is independently optional."""
|
| 235 |
+
questions: list[str] = Field(default_factory=list)
|
| 236 |
+
adjacent_topics: list[str] = Field(default_factory=list)
|
| 237 |
+
advanced_concepts: list[str] = Field(default_factory=list)
|
| 238 |
+
|
| 239 |
+
def is_empty(self) -> bool:
|
| 240 |
+
return not (self.questions or self.adjacent_topics or self.advanced_concepts)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class TopicMap(BaseModel):
|
| 244 |
+
"""A single-hop concept map: one center idea + a few connected satellites."""
|
| 245 |
+
center: str
|
| 246 |
+
nodes: list[str] = Field(default_factory=list) # satellite labels
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class Insight(BaseModel):
|
| 250 |
+
rabbit_hole: RabbitHole = Field(default_factory=RabbitHole)
|
| 251 |
+
topic_map: Optional[TopicMap] = None
|
| 252 |
+
# A ready-to-paste deep-research prompt for an external LLM (docs/14).
|
| 253 |
+
deep_research_prompt: Optional[str] = None
|
| 254 |
+
|
| 255 |
+
def has_content(self) -> bool:
|
| 256 |
+
"""True when at least one sub-section is non-empty — the gate the worker
|
| 257 |
+
and clients use to decide whether to attach/render the layer at all."""
|
| 258 |
+
return bool(
|
| 259 |
+
not self.rabbit_hole.is_empty()
|
| 260 |
+
or self.topic_map or self.deep_research_prompt
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
class Media(BaseModel):
|
| 265 |
thumbnail: Optional[str] = None
|
| 266 |
keyframes: list[str] = Field(default_factory=list)
|
|
|
|
| 290 |
primary_action: PrimaryAction = Field(default_factory=PrimaryAction)
|
| 291 |
action_items: ActionItems = Field(default_factory=ActionItems)
|
| 292 |
blocks: list[Block] = Field(default_factory=list)
|
| 293 |
+
# Deep analysis (docs/14). None for simple cards; only the gated 2nd pass fills it.
|
| 294 |
+
insight: Optional[Insight] = None
|
| 295 |
media: Media = Field(default_factory=Media)
|
| 296 |
meta: Meta = Field(default_factory=Meta)
|
backend/app/pipeline/insight.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Insight analysis (docs/14): the GATED second pass. Runs ONLY for cards the
|
| 2 |
+
structuring pass judged `depth == "deep"`. One text-only LLM call turns the card's
|
| 3 |
+
own summary + body into a reasoning layer — claims, blind spots, rabbit holes, a
|
| 4 |
+
small topic map, and a ready-to-paste deep-research prompt.
|
| 5 |
+
|
| 6 |
+
Same discipline as structuring: never trust the model. Every field is validated
|
| 7 |
+
and independently optional; a malformed or empty result yields `None` (no layer),
|
| 8 |
+
never a crash. A simple reel never reaches this module.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
from app.models.card import (
|
| 17 |
+
Insight,
|
| 18 |
+
RabbitHole,
|
| 19 |
+
TopicMap,
|
| 20 |
+
)
|
| 21 |
+
from app.pipeline.structuring import complete, _strip_fences
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger("pipeline.insight")
|
| 24 |
+
|
| 25 |
+
_MAX = {
|
| 26 |
+
"questions": 6,
|
| 27 |
+
"adjacent_topics": 6,
|
| 28 |
+
"advanced_concepts": 5,
|
| 29 |
+
"nodes": 6,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
_PROMPT = """You are a sharp analyst. You are given a knowledge card distilled from a
|
| 33 |
+
short-form video on an idea-rich topic. Produce a DEEP ANALYSIS layer — the
|
| 34 |
+
scaffolding a curious person would use to go FURTHER than the video. Everything
|
| 35 |
+
must be something the reader can act on, not passive commentary.
|
| 36 |
+
|
| 37 |
+
Return ONLY a JSON object (no prose, no markdown fences) with this exact shape:
|
| 38 |
+
{{
|
| 39 |
+
"rabbit_hole": {{ // threads to go deeper; each becomes a tappable
|
| 40 |
+
// question the reader can ask an AI. Each list may be empty.
|
| 41 |
+
"questions": [str], // sharp open questions the content raises
|
| 42 |
+
"adjacent_topics": [str], // neighbouring subjects worth exploring
|
| 43 |
+
"advanced_concepts": [str] // next-level ideas for someone who gets the basics
|
| 44 |
+
}},
|
| 45 |
+
"topic_map": {{ // a one-hop concept map, or null if not meaningful
|
| 46 |
+
"center": str, // the core idea (1-3 words)
|
| 47 |
+
"nodes": [str] // 3-6 connected concepts (1-3 words each)
|
| 48 |
+
}},
|
| 49 |
+
"deep_research_prompt": str // a ready-to-paste prompt for an external LLM (see below)
|
| 50 |
+
}}
|
| 51 |
+
|
| 52 |
+
Rules:
|
| 53 |
+
- Ground EVERY item in the card's actual content. Do not invent. If the card is
|
| 54 |
+
thin, return fewer items — an empty list is correct when there is nothing real
|
| 55 |
+
to say. Never pad.
|
| 56 |
+
- rabbit_hole: phrase questions/topics so they make sense as a prompt the reader
|
| 57 |
+
taps to ask an AI (e.g. "How does compound interest differ from simple
|
| 58 |
+
interest?", "Behavioural economics of saving"). Concrete, specific, self-contained.
|
| 59 |
+
- topic_map: only when the content genuinely connects several concepts. If it is a
|
| 60 |
+
single narrow point, set "topic_map": null.
|
| 61 |
+
- deep_research_prompt: a structured, rigorous research brief (research objectives,
|
| 62 |
+
desired output sections, constraints) an expert could paste into a frontier LLM to
|
| 63 |
+
go far beyond this video. Plain text, ~150-350 words, no markdown headers. Omit it
|
| 64 |
+
(use null) only if the topic does not reward independent research.
|
| 65 |
+
- Output strict JSON. No commentary.
|
| 66 |
+
|
| 67 |
+
Card:
|
| 68 |
+
---
|
| 69 |
+
{card}
|
| 70 |
+
---
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _card_digest(one_liner: str, tldr: str, body: str, tags: list[str]) -> str:
|
| 75 |
+
"""A compact text view of the card for the analysis prompt."""
|
| 76 |
+
parts = []
|
| 77 |
+
if one_liner:
|
| 78 |
+
parts.append(f"ONE-LINER: {one_liner}")
|
| 79 |
+
if tldr:
|
| 80 |
+
parts.append(f"SUMMARY: {tldr}")
|
| 81 |
+
if tags:
|
| 82 |
+
parts.append("TAGS: " + ", ".join(tags))
|
| 83 |
+
if body:
|
| 84 |
+
parts.append("BODY:\n" + body[:4000])
|
| 85 |
+
return "\n\n".join(parts).strip()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# --------------------------------------------------------------------------- #
|
| 89 |
+
# Validation — never trust the model
|
| 90 |
+
# --------------------------------------------------------------------------- #
|
| 91 |
+
|
| 92 |
+
def _clean_str(value, limit: int = 400) -> str:
|
| 93 |
+
if not isinstance(value, str):
|
| 94 |
+
return ""
|
| 95 |
+
return " ".join(value.split()).strip()[:limit]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _str_list(raw, cap: int) -> list[str]:
|
| 99 |
+
out: list[str] = []
|
| 100 |
+
if not isinstance(raw, list):
|
| 101 |
+
return out
|
| 102 |
+
seen: set[str] = set()
|
| 103 |
+
for item in raw:
|
| 104 |
+
text = _clean_str(item)
|
| 105 |
+
if not text or text.lower() in seen:
|
| 106 |
+
continue
|
| 107 |
+
seen.add(text.lower())
|
| 108 |
+
out.append(text)
|
| 109 |
+
if len(out) >= cap:
|
| 110 |
+
break
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _validate(raw_text: str) -> Insight | None:
|
| 115 |
+
try:
|
| 116 |
+
data = json.loads(_strip_fences(raw_text))
|
| 117 |
+
except (json.JSONDecodeError, TypeError):
|
| 118 |
+
log.warning("insight: LLM output was not valid JSON -> no insight layer")
|
| 119 |
+
return None
|
| 120 |
+
if not isinstance(data, dict):
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
rh_raw = data.get("rabbit_hole") if isinstance(data.get("rabbit_hole"), dict) else {}
|
| 124 |
+
rabbit_hole = RabbitHole(
|
| 125 |
+
questions=_str_list(rh_raw.get("questions"), _MAX["questions"]),
|
| 126 |
+
adjacent_topics=_str_list(rh_raw.get("adjacent_topics"), _MAX["adjacent_topics"]),
|
| 127 |
+
advanced_concepts=_str_list(
|
| 128 |
+
rh_raw.get("advanced_concepts"), _MAX["advanced_concepts"]
|
| 129 |
+
),
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
topic_map = None
|
| 133 |
+
tm_raw = data.get("topic_map")
|
| 134 |
+
if isinstance(tm_raw, dict):
|
| 135 |
+
center = _clean_str(tm_raw.get("center"), limit=60)
|
| 136 |
+
nodes = _str_list(tm_raw.get("nodes"), _MAX["nodes"])
|
| 137 |
+
if center and len(nodes) >= 2: # a center with <2 satellites is not a map
|
| 138 |
+
topic_map = TopicMap(center=center, nodes=nodes)
|
| 139 |
+
|
| 140 |
+
deep_research_prompt = _clean_str(data.get("deep_research_prompt"), limit=4000) or None
|
| 141 |
+
|
| 142 |
+
insight = Insight(
|
| 143 |
+
rabbit_hole=rabbit_hole,
|
| 144 |
+
topic_map=topic_map,
|
| 145 |
+
deep_research_prompt=deep_research_prompt,
|
| 146 |
+
)
|
| 147 |
+
# Drop a layer that came back empty after validation — render nothing rather
|
| 148 |
+
# than an empty shell (docs/14).
|
| 149 |
+
return insight if insight.has_content() else None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# --------------------------------------------------------------------------- #
|
| 153 |
+
# Entry point
|
| 154 |
+
# --------------------------------------------------------------------------- #
|
| 155 |
+
|
| 156 |
+
def analyze(one_liner: str, tldr: str, body: str, tags: list[str]) -> Insight | None:
|
| 157 |
+
"""Run the gated second pass. Returns a validated Insight, or None when the
|
| 158 |
+
backend is unavailable / the model returns nothing usable. Caller (worker)
|
| 159 |
+
treats None as "no insight layer" — never an error."""
|
| 160 |
+
card = _card_digest(one_liner, tldr, body, tags)
|
| 161 |
+
if not card:
|
| 162 |
+
return None
|
| 163 |
+
raw = complete(_PROMPT.format(card=card), max_tokens=4096)
|
| 164 |
+
if not raw:
|
| 165 |
+
log.info("insight: no LLM output -> skipping insight layer")
|
| 166 |
+
return None
|
| 167 |
+
return _validate(raw)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def analyze_async(
|
| 171 |
+
one_liner: str, tldr: str, body: str, tags: list[str]
|
| 172 |
+
) -> Insight | None:
|
| 173 |
+
import asyncio
|
| 174 |
+
|
| 175 |
+
return await asyncio.to_thread(analyze, one_liner, tldr, body, tags)
|
backend/app/pipeline/structuring.py
CHANGED
|
@@ -69,6 +69,7 @@ class StructuredCard:
|
|
| 69 |
primary_action: PrimaryAction,
|
| 70 |
artifacts: list[Artifact] | None = None,
|
| 71 |
action_items: ActionItems | None = None,
|
|
|
|
| 72 |
degraded: bool = False,
|
| 73 |
degraded_reason: str = "",
|
| 74 |
):
|
|
@@ -76,6 +77,10 @@ class StructuredCard:
|
|
| 76 |
self.blocks = blocks # list of plain dicts (validated), ready for JSON column
|
| 77 |
self.primary_action = primary_action
|
| 78 |
self.artifacts = artifacts or [] # referenced things for the catalog (docs/12)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# Concrete to-dos the video tells the viewer to do (docs/13). Inert
|
| 80 |
# (followed=False) at ingestion; the user opts a card into the hub later.
|
| 81 |
self.action_items = action_items or ActionItems()
|
|
@@ -140,7 +145,8 @@ Return ONLY a JSON object (no prose, no markdown fences) with this exact shape:
|
|
| 140 |
],
|
| 141 |
"action_items": [ // concrete things the viewer should DO (may be empty)
|
| 142 |
str // one short imperative task, e.g. "Batch emails into two daily windows"
|
| 143 |
-
]
|
|
|
|
| 144 |
}}
|
| 145 |
|
| 146 |
Rules:
|
|
@@ -161,6 +167,13 @@ Rules:
|
|
| 161 |
Short imperative phrases, max ~8. These are the takeaways to act on, NOT a
|
| 162 |
restatement of every step in a recipe/tutorial. Return an empty list if the
|
| 163 |
video is purely informational with nothing to act on.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
{vocab}
|
| 166 |
|
|
@@ -175,62 +188,66 @@ Extracted text bundle:
|
|
| 175 |
# LLM call (selectable backend: huggingface | groq)
|
| 176 |
# --------------------------------------------------------------------------- #
|
| 177 |
|
| 178 |
-
def
|
| 179 |
-
"""
|
| 180 |
-
|
|
|
|
| 181 |
settings = get_settings()
|
| 182 |
if settings.hf_enabled:
|
| 183 |
-
out = _call_hf(
|
| 184 |
if out:
|
| 185 |
return out
|
| 186 |
# HF's free router 504s on long generations (e.g. big carousels). Rather
|
| 187 |
-
# than drop straight to
|
| 188 |
if settings.groq_api_key.strip():
|
| 189 |
-
log.info("
|
| 190 |
-
return _call_groq(
|
| 191 |
return None
|
| 192 |
if settings.groq_llm_enabled:
|
| 193 |
-
return _call_groq(
|
| 194 |
return None
|
| 195 |
|
| 196 |
|
| 197 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
settings = get_settings()
|
| 199 |
try:
|
| 200 |
from huggingface_hub import InferenceClient
|
| 201 |
|
| 202 |
client = InferenceClient(api_key=settings.hf_api_key)
|
| 203 |
-
prompt = _PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle)
|
| 204 |
resp = client.chat_completion(
|
| 205 |
model=settings.hf_model, # free Inference Providers, e.g. Qwen2.5-72B-Instruct
|
| 206 |
messages=[{"role": "user", "content": prompt}],
|
| 207 |
-
temperature=
|
| 208 |
-
max_tokens=
|
| 209 |
)
|
| 210 |
text = resp.choices[0].message.content if resp.choices else ""
|
| 211 |
return (text or "").strip()
|
| 212 |
except Exception as e:
|
| 213 |
-
log.warning("
|
| 214 |
return None
|
| 215 |
|
| 216 |
|
| 217 |
-
def _call_groq(
|
| 218 |
settings = get_settings()
|
| 219 |
try:
|
| 220 |
from groq import Groq
|
| 221 |
|
| 222 |
client = Groq(api_key=settings.groq_api_key)
|
| 223 |
-
prompt = _PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle)
|
| 224 |
resp = client.chat.completions.create(
|
| 225 |
model=settings.groq_llm_model, # free tier; default llama-3.3-70b-versatile
|
| 226 |
messages=[{"role": "user", "content": prompt}],
|
| 227 |
-
temperature=
|
| 228 |
-
max_tokens=
|
| 229 |
)
|
| 230 |
text = resp.choices[0].message.content if resp.choices else ""
|
| 231 |
return (text or "").strip()
|
| 232 |
except Exception as e:
|
| 233 |
-
log.warning("
|
| 234 |
return None
|
| 235 |
|
| 236 |
|
|
@@ -417,6 +434,7 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str
|
|
| 417 |
|
| 418 |
artifacts = _coerce_artifacts(data.get("artifacts") or [])
|
| 419 |
action_items = _coerce_action_items(data.get("action_items") or [])
|
|
|
|
| 420 |
|
| 421 |
blocks = _coerce_blocks(data.get("blocks") or [])
|
| 422 |
if not blocks:
|
|
@@ -434,7 +452,7 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str
|
|
| 434 |
|
| 435 |
return StructuredCard(
|
| 436 |
base, blocks, _primary_action_for(base.content_type), artifacts,
|
| 437 |
-
action_items=action_items,
|
| 438 |
)
|
| 439 |
|
| 440 |
|
|
|
|
| 69 |
primary_action: PrimaryAction,
|
| 70 |
artifacts: list[Artifact] | None = None,
|
| 71 |
action_items: ActionItems | None = None,
|
| 72 |
+
depth: str = "shallow",
|
| 73 |
degraded: bool = False,
|
| 74 |
degraded_reason: str = "",
|
| 75 |
):
|
|
|
|
| 77 |
self.blocks = blocks # list of plain dicts (validated), ready for JSON column
|
| 78 |
self.primary_action = primary_action
|
| 79 |
self.artifacts = artifacts or [] # referenced things for the catalog (docs/12)
|
| 80 |
+
# Gate for the 2nd pass (docs/14): "deep" => run insight analysis;
|
| 81 |
+
# "shallow" => simple card, no extra LLM call. The model judges; the worker
|
| 82 |
+
# acts. Never block the card on this — default shallow.
|
| 83 |
+
self.depth = depth if depth in ("shallow", "deep") else "shallow"
|
| 84 |
# Concrete to-dos the video tells the viewer to do (docs/13). Inert
|
| 85 |
# (followed=False) at ingestion; the user opts a card into the hub later.
|
| 86 |
self.action_items = action_items or ActionItems()
|
|
|
|
| 145 |
],
|
| 146 |
"action_items": [ // concrete things the viewer should DO (may be empty)
|
| 147 |
str // one short imperative task, e.g. "Batch emails into two daily windows"
|
| 148 |
+
],
|
| 149 |
+
"depth": "shallow|deep" // does this content warrant deep analysis? (see below)
|
| 150 |
}}
|
| 151 |
|
| 152 |
Rules:
|
|
|
|
| 167 |
Short imperative phrases, max ~8. These are the takeaways to act on, NOT a
|
| 168 |
restatement of every step in a recipe/tutorial. Return an empty list if the
|
| 169 |
video is purely informational with nothing to act on.
|
| 170 |
+
- depth: judge whether this content rewards DEEPER analysis (claims to fact-check,
|
| 171 |
+
unstated assumptions, open questions, a web of related concepts). Answer "deep"
|
| 172 |
+
ONLY for idea-rich, knowledge-heavy, or argumentative content (e.g. a science
|
| 173 |
+
explainer, an investing thesis, a psychology breakdown, a contested take).
|
| 174 |
+
Answer "shallow" for practical/procedural or lightweight content with nothing
|
| 175 |
+
to interrogate (a recipe, a workout, a product list, a quick how-to, a meme).
|
| 176 |
+
When unsure, answer "shallow". Most videos are shallow.
|
| 177 |
|
| 178 |
{vocab}
|
| 179 |
|
|
|
|
| 188 |
# LLM call (selectable backend: huggingface | groq)
|
| 189 |
# --------------------------------------------------------------------------- #
|
| 190 |
|
| 191 |
+
def complete(prompt: str, *, max_tokens: int = 8192, temperature: float = 0.2) -> str | None:
|
| 192 |
+
"""Generic single-prompt completion via the configured backend (HF, with a
|
| 193 |
+
Groq fallback). Shared by the structuring pass and the gated insight pass
|
| 194 |
+
(docs/14). Any failure -> None; callers decide how to degrade."""
|
| 195 |
settings = get_settings()
|
| 196 |
if settings.hf_enabled:
|
| 197 |
+
out = _call_hf(prompt, max_tokens, temperature)
|
| 198 |
if out:
|
| 199 |
return out
|
| 200 |
# HF's free router 504s on long generations (e.g. big carousels). Rather
|
| 201 |
+
# than drop straight to failure, fall back to Groq when a key exists.
|
| 202 |
if settings.groq_api_key.strip():
|
| 203 |
+
log.info("llm: HF backend failed; falling back to Groq")
|
| 204 |
+
return _call_groq(prompt, max_tokens, temperature)
|
| 205 |
return None
|
| 206 |
if settings.groq_llm_enabled:
|
| 207 |
+
return _call_groq(prompt, max_tokens, temperature)
|
| 208 |
return None
|
| 209 |
|
| 210 |
|
| 211 |
+
def _call_llm(bundle: str) -> str | None:
|
| 212 |
+
"""Structuring (pass 1): format the card prompt and complete it."""
|
| 213 |
+
return complete(_PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _call_hf(prompt: str, max_tokens: int, temperature: float) -> str | None:
|
| 217 |
settings = get_settings()
|
| 218 |
try:
|
| 219 |
from huggingface_hub import InferenceClient
|
| 220 |
|
| 221 |
client = InferenceClient(api_key=settings.hf_api_key)
|
|
|
|
| 222 |
resp = client.chat_completion(
|
| 223 |
model=settings.hf_model, # free Inference Providers, e.g. Qwen2.5-72B-Instruct
|
| 224 |
messages=[{"role": "user", "content": prompt}],
|
| 225 |
+
temperature=temperature,
|
| 226 |
+
max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
|
| 227 |
)
|
| 228 |
text = resp.choices[0].message.content if resp.choices else ""
|
| 229 |
return (text or "").strip()
|
| 230 |
except Exception as e:
|
| 231 |
+
log.warning("llm call (huggingface) failed: %s", e)
|
| 232 |
return None
|
| 233 |
|
| 234 |
|
| 235 |
+
def _call_groq(prompt: str, max_tokens: int, temperature: float) -> str | None:
|
| 236 |
settings = get_settings()
|
| 237 |
try:
|
| 238 |
from groq import Groq
|
| 239 |
|
| 240 |
client = Groq(api_key=settings.groq_api_key)
|
|
|
|
| 241 |
resp = client.chat.completions.create(
|
| 242 |
model=settings.groq_llm_model, # free tier; default llama-3.3-70b-versatile
|
| 243 |
messages=[{"role": "user", "content": prompt}],
|
| 244 |
+
temperature=temperature,
|
| 245 |
+
max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
|
| 246 |
)
|
| 247 |
text = resp.choices[0].message.content if resp.choices else ""
|
| 248 |
return (text or "").strip()
|
| 249 |
except Exception as e:
|
| 250 |
+
log.warning("llm call (groq) failed: %s", e)
|
| 251 |
return None
|
| 252 |
|
| 253 |
|
|
|
|
| 434 |
|
| 435 |
artifacts = _coerce_artifacts(data.get("artifacts") or [])
|
| 436 |
action_items = _coerce_action_items(data.get("action_items") or [])
|
| 437 |
+
depth = data.get("depth") if data.get("depth") in ("shallow", "deep") else "shallow"
|
| 438 |
|
| 439 |
blocks = _coerce_blocks(data.get("blocks") or [])
|
| 440 |
if not blocks:
|
|
|
|
| 452 |
|
| 453 |
return StructuredCard(
|
| 454 |
base, blocks, _primary_action_for(base.content_type), artifacts,
|
| 455 |
+
action_items=action_items, depth=depth,
|
| 456 |
)
|
| 457 |
|
| 458 |
|
backend/app/pipeline/worker.py
CHANGED
|
@@ -25,6 +25,7 @@ from app.pipeline.ingestion.downloader import (
|
|
| 25 |
DownloaderConfig,
|
| 26 |
download_content_async,
|
| 27 |
)
|
|
|
|
| 28 |
from app.pipeline.structuring import structure_async
|
| 29 |
from app.services import artifact_images, embeddings, events, llm_chat, notify
|
| 30 |
from app.store import db, media
|
|
@@ -105,6 +106,16 @@ async def _write_blocks(session, card_id: str, blocks: list[dict]) -> None:
|
|
| 105 |
await session.commit()
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
async def _write_media_and_meta(
|
| 109 |
session, card_id: str, extraction, caption: str, resolver: str,
|
| 110 |
creator: str | None = None,
|
|
@@ -291,6 +302,37 @@ async def _run_job(session, job: db.JobRow) -> None:
|
|
| 291 |
creator=download.author,
|
| 292 |
)
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
# 5) Catalog: aggregate any referenced artifacts + fetch their thumbnails.
|
| 295 |
if structured.artifacts:
|
| 296 |
events.publish(card_id, "cataloging", "processing", "Cataloging references")
|
|
|
|
| 25 |
DownloaderConfig,
|
| 26 |
download_content_async,
|
| 27 |
)
|
| 28 |
+
from app.pipeline.insight import analyze_async
|
| 29 |
from app.pipeline.structuring import structure_async
|
| 30 |
from app.services import artifact_images, embeddings, events, llm_chat, notify
|
| 31 |
from app.store import db, media
|
|
|
|
| 106 |
await session.commit()
|
| 107 |
|
| 108 |
|
| 109 |
+
async def _write_insight(session, card_id: str, insight) -> None:
|
| 110 |
+
"""Persist the deep-analysis layer (docs/14). `insight` is a validated model."""
|
| 111 |
+
await session.execute(
|
| 112 |
+
update(db.CardRow)
|
| 113 |
+
.where(db.CardRow.id == card_id)
|
| 114 |
+
.values(insight=insight.model_dump())
|
| 115 |
+
)
|
| 116 |
+
await session.commit()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
async def _write_media_and_meta(
|
| 120 |
session, card_id: str, extraction, caption: str, resolver: str,
|
| 121 |
creator: str | None = None,
|
|
|
|
| 302 |
creator=download.author,
|
| 303 |
)
|
| 304 |
|
| 305 |
+
# 4b) Deep analysis (docs/14) — GATED. Only idea-rich cards (the structuring
|
| 306 |
+
# pass judged `depth == "deep"`) get a second LLM call. A simple reel skips
|
| 307 |
+
# this entirely. Best-effort + isolated: a failure leaves insight=None and
|
| 308 |
+
# never blocks the card from going READY.
|
| 309 |
+
if structured.depth == "deep":
|
| 310 |
+
events.publish(card_id, "analyzing", "processing", "Analyzing in depth")
|
| 311 |
+
log.info("%s Step 4b deep-analysis: depth=deep -> running insight pass", tag)
|
| 312 |
+
try:
|
| 313 |
+
row = await db.get_card_row(session, card_id)
|
| 314 |
+
body = llm_chat.card_context(row.to_card()) if row else ""
|
| 315 |
+
insight = await analyze_async(
|
| 316 |
+
structured.base.one_liner, structured.base.tldr, body,
|
| 317 |
+
structured.base.tags,
|
| 318 |
+
)
|
| 319 |
+
if insight is not None:
|
| 320 |
+
await _write_insight(session, card_id, insight)
|
| 321 |
+
rh = insight.rabbit_hole
|
| 322 |
+
log.info(
|
| 323 |
+
"%s Step 4b deep-analysis OK | threads=%d topic_map=%s "
|
| 324 |
+
"deep_research=%s", tag,
|
| 325 |
+
len(rh.questions) + len(rh.adjacent_topics) + len(rh.advanced_concepts),
|
| 326 |
+
"yes" if insight.topic_map else "no",
|
| 327 |
+
"yes" if insight.deep_research_prompt else "no",
|
| 328 |
+
)
|
| 329 |
+
else:
|
| 330 |
+
log.info("%s Step 4b deep-analysis: no usable layer produced", tag)
|
| 331 |
+
except Exception: # noqa: BLE001 — insight is non-critical to the card
|
| 332 |
+
log.warning("%s Step 4b deep-analysis failed", tag, exc_info=True)
|
| 333 |
+
else:
|
| 334 |
+
log.info("%s Step 4b deep-analysis: depth=shallow, skipping", tag)
|
| 335 |
+
|
| 336 |
# 5) Catalog: aggregate any referenced artifacts + fetch their thumbnails.
|
| 337 |
if structured.artifacts:
|
| 338 |
events.publish(card_id, "cataloging", "processing", "Cataloging references")
|
backend/app/store/db.py
CHANGED
|
@@ -35,6 +35,7 @@ from app.models.card import (
|
|
| 35 |
CardState,
|
| 36 |
ExtractionFlags,
|
| 37 |
FailureReason,
|
|
|
|
| 38 |
Media,
|
| 39 |
Meta,
|
| 40 |
PrimaryAction,
|
|
@@ -81,6 +82,8 @@ class CardRow(Base):
|
|
| 81 |
# {followed: bool, items: [{id, text, done}]} — the to-do list (docs/13).
|
| 82 |
action_items: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 83 |
blocks: Mapped[list] = mapped_column(JSON, default=list)
|
|
|
|
|
|
|
| 84 |
thumbnail: Mapped[str | None] = mapped_column(String, nullable=True)
|
| 85 |
keyframes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
| 86 |
extraction: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
@@ -118,6 +121,7 @@ class CardRow(Base):
|
|
| 118 |
primary_action=PrimaryAction(**(self.primary_action or {})),
|
| 119 |
action_items=ActionItems(**(self.action_items or {})),
|
| 120 |
blocks=self.blocks or [],
|
|
|
|
| 121 |
media=Media(
|
| 122 |
thumbnail=media_store.to_media_url(self.thumbnail),
|
| 123 |
keyframes=[
|
|
@@ -218,7 +222,10 @@ async def init_db() -> None:
|
|
| 218 |
await _add_missing_columns(
|
| 219 |
conn,
|
| 220 |
"cards",
|
| 221 |
-
{
|
|
|
|
|
|
|
|
|
|
| 222 |
)
|
| 223 |
|
| 224 |
|
|
|
|
| 35 |
CardState,
|
| 36 |
ExtractionFlags,
|
| 37 |
FailureReason,
|
| 38 |
+
Insight,
|
| 39 |
Media,
|
| 40 |
Meta,
|
| 41 |
PrimaryAction,
|
|
|
|
| 82 |
# {followed: bool, items: [{id, text, done}]} — the to-do list (docs/13).
|
| 83 |
action_items: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 84 |
blocks: Mapped[list] = mapped_column(JSON, default=list)
|
| 85 |
+
# Deep-analysis layer (docs/14): null for simple cards, filled by the gated pass.
|
| 86 |
+
insight: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 87 |
thumbnail: Mapped[str | None] = mapped_column(String, nullable=True)
|
| 88 |
keyframes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
| 89 |
extraction: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
|
|
| 121 |
primary_action=PrimaryAction(**(self.primary_action or {})),
|
| 122 |
action_items=ActionItems(**(self.action_items or {})),
|
| 123 |
blocks=self.blocks or [],
|
| 124 |
+
insight=Insight(**self.insight) if self.insight else None,
|
| 125 |
media=Media(
|
| 126 |
thumbnail=media_store.to_media_url(self.thumbnail),
|
| 127 |
keyframes=[
|
|
|
|
| 222 |
await _add_missing_columns(
|
| 223 |
conn,
|
| 224 |
"cards",
|
| 225 |
+
{
|
| 226 |
+
"action_items": "JSON", # docs/13 — added in schema 1.3
|
| 227 |
+
"insight": "JSON", # docs/14 — added in schema 1.4
|
| 228 |
+
},
|
| 229 |
)
|
| 230 |
|
| 231 |
|
backend/tests/test_api.py
CHANGED
|
@@ -18,7 +18,7 @@ async def client(database):
|
|
| 18 |
async def test_health(client):
|
| 19 |
r = await client.get("/health")
|
| 20 |
assert r.status_code == 200
|
| 21 |
-
assert r.json()["schema_version"] == "1.
|
| 22 |
|
| 23 |
|
| 24 |
async def test_create_returns_id_and_queued(client):
|
|
|
|
| 18 |
async def test_health(client):
|
| 19 |
r = await client.get("/health")
|
| 20 |
assert r.status_code == 200
|
| 21 |
+
assert r.json()["schema_version"] == "1.4"
|
| 22 |
|
| 23 |
|
| 24 |
async def test_create_returns_id_and_queued(client):
|