Vatxzz commited on
Commit
67fa223
·
1 Parent(s): 3cae397

feat: overhaul splash screen with custom SVG animation and update platform theme configurations

Browse files
Files changed (39) hide show
  1. app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png +0 -0
  2. app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png +0 -0
  3. app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png +0 -0
  4. app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png +0 -0
  5. app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png +0 -0
  6. app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png +0 -0
  7. app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png +0 -0
  8. app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png +0 -0
  9. app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
  10. app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png +0 -0
  11. app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  12. app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  13. app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  14. app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  15. app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  16. app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  17. app/android/app/src/main/res/values/colors.xml +1 -1
  18. app/assets/brand/adaptive_foreground.png +0 -0
  19. app/assets/brand/adaptive_monochrome.png +0 -0
  20. app/assets/brand/icon.png +0 -0
  21. app/lib/main.dart +5 -0
  22. app/lib/ui/core/brand.dart +1 -1
  23. app/lib/ui/core/root_gate.dart +24 -7
  24. app/lib/ui/core/theme.dart +67 -7
  25. app/lib/ui/core/widgets/spot_art.dart +51 -15
  26. app/lib/ui/features/concepts/view_models/concepts_view_model.dart +8 -0
  27. app/lib/ui/features/graph/views/graph_screen.dart +316 -265
  28. app/lib/ui/features/onboarding/views/onboarding_screen.dart +1 -1
  29. app/lib/ui/features/onboarding/views/splash_screen.dart +18 -61
  30. app/pubspec.yaml +12 -4
  31. app/test/obsidian_export_test.dart +0 -1
  32. app/web/favicon.png +0 -0
  33. app/web/icons/Icon-192.png +0 -0
  34. app/web/icons/Icon-512.png +0 -0
  35. app/web/icons/Icon-maskable-192.png +0 -0
  36. app/web/icons/Icon-maskable-512.png +0 -0
  37. app/web/index.html +114 -57
  38. app/web/manifest.json +3 -3
  39. backend/app/store/db.py +0 -1
app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png CHANGED
app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png ADDED
app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png CHANGED
app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png ADDED
app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png CHANGED
app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png ADDED
app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png CHANGED
app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png ADDED
app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png CHANGED
app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png ADDED
app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml CHANGED
@@ -6,4 +6,9 @@
6
  android:drawable="@drawable/ic_launcher_foreground"
7
  android:inset="16%" />
8
  </foreground>
 
 
 
 
 
9
  </adaptive-icon>
 
6
  android:drawable="@drawable/ic_launcher_foreground"
7
  android:inset="16%" />
8
  </foreground>
9
+ <monochrome>
10
+ <inset
11
+ android:drawable="@drawable/ic_launcher_monochrome"
12
+ android:inset="16%" />
13
+ </monochrome>
14
  </adaptive-icon>
app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png CHANGED
app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png CHANGED
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png CHANGED
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png CHANGED
app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png CHANGED
app/android/app/src/main/res/values/colors.xml CHANGED
@@ -1,4 +1,4 @@
1
  <?xml version="1.0" encoding="utf-8"?>
2
  <resources>
3
- <color name="ic_launcher_background">#5B5BD6</color>
4
  </resources>
 
1
  <?xml version="1.0" encoding="utf-8"?>
2
  <resources>
3
+ <color name="ic_launcher_background">#F5F0E8</color>
4
  </resources>
app/assets/brand/adaptive_foreground.png ADDED
app/assets/brand/adaptive_monochrome.png ADDED
app/assets/brand/icon.png CHANGED
app/lib/main.dart CHANGED
@@ -140,6 +140,11 @@ class _CachyAppState extends State<CachyApp> {
140
  class DesktopScrollBehavior extends MaterialScrollBehavior {
141
  const DesktopScrollBehavior();
142
 
 
 
 
 
 
143
  @override
144
  Set<PointerDeviceKind> get dragDevices => {
145
  PointerDeviceKind.touch,
 
140
  class DesktopScrollBehavior extends MaterialScrollBehavior {
141
  const DesktopScrollBehavior();
142
 
143
+ @override
144
+ ScrollPhysics getScrollPhysics(BuildContext context) {
145
+ return const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
146
+ }
147
+
148
  @override
149
  Set<PointerDeviceKind> get dragDevices => {
150
  PointerDeviceKind.touch,
app/lib/ui/core/brand.dart CHANGED
@@ -175,7 +175,7 @@ class _GlyphPainter extends CustomPainter {
175
  canvas.drawPath(path, bracketPaint);
176
 
177
  final topY = h * 0.06;
178
- final restY = h * 0.40;
179
  final cy = topY + (restY - topY) * reelDrop.clamp(0.0, 1.0);
180
  final reelSize = w * 0.30;
181
  final reelRect = RRect.fromRectAndRadius(
 
175
  canvas.drawPath(path, bracketPaint);
176
 
177
  final topY = h * 0.06;
178
+ final restY = h * 0.50;
179
  final cy = topY + (restY - topY) * reelDrop.clamp(0.0, 1.0);
180
  final reelSize = w * 0.30;
181
  final reelRect = RRect.fromRectAndRadius(
app/lib/ui/core/root_gate.dart CHANGED
@@ -2,10 +2,12 @@
2
  /// onboarding, then the home shell. Keeps that branching out of main.dart.
3
  library;
4
 
 
5
  import 'package:flutter/material.dart';
6
  import 'package:provider/provider.dart';
7
 
8
  import '../features/onboarding/views/onboarding_screen.dart';
 
9
  import 'app_controller.dart';
10
  import 'home_shell.dart';
11
 
@@ -25,7 +27,15 @@ class _RootGateState extends State<RootGate> {
25
  void initState() {
26
  super.initState();
27
  final app = context.read<AppController>();
28
- _phase = app.seenOnboarding ? _Phase.shell : _Phase.onboarding;
 
 
 
 
 
 
 
 
29
  }
30
 
31
  Future<void> _finishOnboarding() async {
@@ -35,11 +45,18 @@ class _RootGateState extends State<RootGate> {
35
 
36
  @override
37
  Widget build(BuildContext context) {
38
- return switch (_phase) {
39
- _Phase.splash => const SizedBox.shrink(),
40
- _Phase.onboarding =>
41
- OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
42
- _Phase.shell => const HomeShell(key: ValueKey('shell')),
43
- };
 
 
 
 
 
 
 
44
  }
45
  }
 
2
  /// onboarding, then the home shell. Keeps that branching out of main.dart.
3
  library;
4
 
5
+ import 'package:flutter/foundation.dart';
6
  import 'package:flutter/material.dart';
7
  import 'package:provider/provider.dart';
8
 
9
  import '../features/onboarding/views/onboarding_screen.dart';
10
+ import '../features/onboarding/views/splash_screen.dart';
11
  import 'app_controller.dart';
12
  import 'home_shell.dart';
13
 
 
27
  void initState() {
28
  super.initState();
29
  final app = context.read<AppController>();
30
+ _phase = kIsWeb
31
+ ? (app.seenOnboarding ? _Phase.shell : _Phase.onboarding)
32
+ : _Phase.splash;
33
+ }
34
+
35
+ void _finishSplash() {
36
+ if (!mounted) return;
37
+ final app = context.read<AppController>();
38
+ setState(() => _phase = app.seenOnboarding ? _Phase.shell : _Phase.onboarding);
39
  }
40
 
41
  Future<void> _finishOnboarding() async {
 
45
 
46
  @override
47
  Widget build(BuildContext context) {
48
+ return AnimatedSwitcher(
49
+ duration: const Duration(milliseconds: 550),
50
+ switchInCurve: Curves.easeOutCubic,
51
+ switchOutCurve: Curves.easeInCubic,
52
+ transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
53
+ child: switch (_phase) {
54
+ _Phase.splash =>
55
+ SplashScreen(key: const ValueKey('splash'), onDone: _finishSplash),
56
+ _Phase.onboarding =>
57
+ OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
58
+ _Phase.shell => const HomeShell(key: ValueKey('shell')),
59
+ },
60
+ );
61
  }
62
  }
app/lib/ui/core/theme.dart CHANGED
@@ -64,6 +64,16 @@ class AppTheme {
64
 
65
  return base.copyWith(
66
  scaffoldBackgroundColor: ground,
 
 
 
 
 
 
 
 
 
 
67
  appBarTheme: AppBarTheme(
68
  backgroundColor: Colors.transparent,
69
  foregroundColor: onGround,
@@ -126,13 +136,6 @@ class AppTheme {
126
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
127
  insetPadding: const EdgeInsets.all(16),
128
  ),
129
- pageTransitionsTheme: const PageTransitionsTheme(
130
- builders: {
131
- TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
132
- TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
133
- TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
134
- },
135
- ),
136
  );
137
  }
138
  }
@@ -154,3 +157,60 @@ class Insets {
154
  static const radius = 14.0;
155
  static const readingColumn = 680.0;
156
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  return base.copyWith(
66
  scaffoldBackgroundColor: ground,
67
+ splashFactory: NoSplash.splashFactory,
68
+ pageTransitionsTheme: const PageTransitionsTheme(
69
+ builders: {
70
+ TargetPlatform.android: IOSPageTransitionsBuilder(),
71
+ TargetPlatform.iOS: IOSPageTransitionsBuilder(),
72
+ TargetPlatform.macOS: IOSPageTransitionsBuilder(),
73
+ TargetPlatform.windows: IOSPageTransitionsBuilder(),
74
+ TargetPlatform.linux: IOSPageTransitionsBuilder(),
75
+ },
76
+ ),
77
  appBarTheme: AppBarTheme(
78
  backgroundColor: Colors.transparent,
79
  foregroundColor: onGround,
 
136
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
137
  insetPadding: const EdgeInsets.all(16),
138
  ),
 
 
 
 
 
 
 
139
  );
140
  }
141
  }
 
157
  static const radius = 14.0;
158
  static const readingColumn = 680.0;
159
  }
160
+
161
+ /// Custom transition builder allowing swipe-right-to-go-back from ANYWHERE
162
+ /// on the screen, while preserving iOS slide transitions.
163
+ class IOSPageTransitionsBuilder extends PageTransitionsBuilder {
164
+ const IOSPageTransitionsBuilder();
165
+
166
+ @override
167
+ Widget buildTransitions<T>(
168
+ PageRoute<T> route,
169
+ BuildContext context,
170
+ Animation<double> animation,
171
+ Animation<double> secondaryAnimation,
172
+ Widget child,
173
+ ) {
174
+ return const CupertinoPageTransitionsBuilder().buildTransitions<T>(
175
+ route,
176
+ context,
177
+ animation,
178
+ secondaryAnimation,
179
+ _SwipeBackGate(child: child),
180
+ );
181
+ }
182
+ }
183
+
184
+ class _SwipeBackGate extends StatefulWidget {
185
+ const _SwipeBackGate({required this.child});
186
+ final Widget child;
187
+
188
+ @override
189
+ State<_SwipeBackGate> createState() => _SwipeBackGateState();
190
+ }
191
+
192
+ class _SwipeBackGateState extends State<_SwipeBackGate> {
193
+ double _dragDistance = 0;
194
+
195
+ @override
196
+ Widget build(BuildContext context) {
197
+ return GestureDetector(
198
+ behavior: HitTestBehavior.translucent,
199
+ onHorizontalDragStart: (_) => _dragDistance = 0,
200
+ onHorizontalDragUpdate: (details) {
201
+ _dragDistance += details.delta.dx;
202
+ },
203
+ onHorizontalDragEnd: (details) {
204
+ final velocity = details.primaryVelocity ?? 0;
205
+ if ((velocity > 300 || _dragDistance > 70) && _dragDistance > 0) {
206
+ final nav = Navigator.of(context);
207
+ if (nav.canPop()) {
208
+ nav.pop();
209
+ }
210
+ }
211
+ _dragDistance = 0;
212
+ },
213
+ child: widget.child,
214
+ );
215
+ }
216
+ }
app/lib/ui/core/widgets/spot_art.dart CHANGED
@@ -292,7 +292,7 @@ class _StructurePainter extends CustomPainter {
292
  // ── Capture: a link dropping into a card ─────────────────────────────────────
293
 
294
  class CaptureSpot extends StatelessWidget {
295
- const CaptureSpot({super.key, this.color, this.size = const Size(116, 70)});
296
  final Color? color;
297
  final Size size;
298
  @override
@@ -306,26 +306,62 @@ class _CapturePainter extends CustomPainter {
306
  @override
307
  void paint(Canvas canvas, Size s) {
308
  final pen = _pen(c);
 
309
  final cx = s.width / 2;
310
  final cy = s.height / 2;
311
- // a card
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  canvas.drawRRect(
313
  RRect.fromRectAndRadius(
314
- Rect.fromLTWH(cx - 4, cy - 18, 52, 44), const Radius.circular(8)),
 
315
  pen,
316
  );
317
- canvas.drawLine(Offset(cx + 6, cy + 4), Offset(cx + 38, cy + 4), pen);
318
- canvas.drawLine(Offset(cx + 6, cy + 14), Offset(cx + 26, cy + 14), pen);
319
- // two chain links arcing in from the left
320
- for (var i = 0; i < 2; i++) {
321
- final ox = cx - 40 + i * 18.0;
322
- final oy = cy - 14 + i * 12.0;
323
- canvas.drawRRect(
324
- RRect.fromRectAndRadius(
325
- Rect.fromLTWH(ox, oy, 22, 12), const Radius.circular(6)),
326
- pen,
327
- );
328
- }
 
 
 
 
 
 
 
 
329
  }
330
 
331
  @override
 
292
  // ── Capture: a link dropping into a card ─────────────────────────────────────
293
 
294
  class CaptureSpot extends StatelessWidget {
295
+ const CaptureSpot({super.key, this.color, this.size = const Size(120, 88)});
296
  final Color? color;
297
  final Size size;
298
  @override
 
306
  @override
307
  void paint(Canvas canvas, Size s) {
308
  final pen = _pen(c);
309
+ final fill = Paint()..color = c.withValues(alpha: 0.18);
310
  final cx = s.width / 2;
311
  final cy = s.height / 2;
312
+
313
+ // A destination structured card on the right
314
+ final cardCenter = Offset(cx + 14, cy + 4);
315
+ final card = RRect.fromRectAndRadius(
316
+ Rect.fromCenter(center: cardCenter, width: 60, height: 70),
317
+ const Radius.circular(8));
318
+ canvas.drawRRect(card, pen);
319
+
320
+ // Media/header preview box inside card
321
+ canvas.drawRRect(
322
+ RRect.fromRectAndRadius(
323
+ Rect.fromLTWH(cx - 10, cy - 23, 48, 22), const Radius.circular(5)),
324
+ fill,
325
+ );
326
+ // Summary text lines inside card
327
+ canvas.drawLine(Offset(cx - 10, cy + 9), Offset(cx + 24, cy + 9), pen);
328
+ canvas.drawLine(Offset(cx - 10, cy + 21), Offset(cx + 10, cy + 21), pen);
329
+
330
+ // Link source badge floating cleanly on the left
331
+ final lx = cx - 34;
332
+ final ly = cy - 8;
333
+ canvas.drawRRect(
334
+ RRect.fromRectAndRadius(
335
+ Rect.fromCenter(center: Offset(lx, ly), width: 36, height: 36),
336
+ const Radius.circular(12)),
337
+ fill,
338
+ );
339
  canvas.drawRRect(
340
  RRect.fromRectAndRadius(
341
+ Rect.fromCenter(center: Offset(lx, ly), width: 36, height: 36),
342
+ const Radius.circular(12)),
343
  pen,
344
  );
345
+
346
+ // Interlocking chain-link icon inside badge
347
+ canvas.save();
348
+ canvas.translate(lx, ly);
349
+ canvas.rotate(-0.785);
350
+ canvas.drawRRect(
351
+ RRect.fromRectAndRadius(
352
+ const Rect.fromLTWH(-13, -5, 15, 10), const Radius.circular(5)),
353
+ pen,
354
+ );
355
+ canvas.drawRRect(
356
+ RRect.fromRectAndRadius(
357
+ const Rect.fromLTWH(-2, -5, 15, 10), const Radius.circular(5)),
358
+ pen,
359
+ );
360
+ canvas.restore();
361
+
362
+ // Hand-crafted sparkle accents indicating seamless capture
363
+ canvas.drawCircle(Offset(cx - 14, cy - 24), 2.0, Paint()..color = c);
364
+ canvas.drawCircle(Offset(cx - 48, cy + 16), 1.5, Paint()..color = c);
365
  }
366
 
367
  @override
app/lib/ui/features/concepts/view_models/concepts_view_model.dart CHANGED
@@ -23,12 +23,20 @@ class ConceptsViewModel extends ChangeNotifier {
23
  }
24
  }
25
 
 
 
26
  @override
27
  void dispose() {
 
28
  _repository.removeListener(_onRepoChange);
29
  super.dispose();
30
  }
31
 
 
 
 
 
 
32
  ConceptsStatus _status = ConceptsStatus.idle;
33
  ConceptsStatus get status => _status;
34
 
 
23
  }
24
  }
25
 
26
+ bool _disposed = false;
27
+
28
  @override
29
  void dispose() {
30
+ _disposed = true;
31
  _repository.removeListener(_onRepoChange);
32
  super.dispose();
33
  }
34
 
35
+ @override
36
+ void notifyListeners() {
37
+ if (!_disposed) super.notifyListeners();
38
+ }
39
+
40
  ConceptsStatus _status = ConceptsStatus.idle;
41
  ConceptsStatus get status => _status;
42
 
app/lib/ui/features/graph/views/graph_screen.dart CHANGED
@@ -1,11 +1,15 @@
1
- /// Obsidian-style knowledge graph: a live force-directed physics simulation that
2
  /// runs on every open, letting nodes wiggle and settle into emergent clusters.
3
  ///
4
- /// Four configurable forces:
5
- /// 1. **Repulsion** — every node pushes every other node away (Coulomb's law).
6
- /// 2. **Link/spring** — connected nodes pull together (Hooke's law).
7
- /// 3. **Center** — gentle gravity toward the origin keeps the graph compact.
8
- /// 4. **Link distance** — ideal spring rest length.
 
 
 
 
9
  ///
10
  /// Drag pins a node while physics recalculates around it (rubber-band effect).
11
  /// Two node types (card = circle, catalog = rounded square), three edge styles,
@@ -36,16 +40,59 @@ import '../../reader/views/reader_screen.dart';
36
  // ========================================================================== //
37
 
38
  class _PhysicsConfig {
39
- double repelForce;
40
- double linkForce;
41
- double centerForce;
 
 
 
 
42
  double linkDistance;
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  _PhysicsConfig({
45
- this.repelForce = 1.6,
46
- this.linkForce = 1.3,
47
- this.centerForce = 0.025,
48
- this.linkDistance = 110,
 
 
 
 
 
 
 
 
 
49
  });
50
  }
51
 
@@ -76,14 +123,10 @@ class _GraphScreenState extends State<GraphScreen>
76
  final Map<String, List<String>> _adj = {};
77
  final Map<String, double> _edgeWeights = {};
78
 
79
- // Star layout anchors the ideal position each node should occupy.
80
- final Map<String, Offset> _starPos = {};
81
- final Map<String, Offset> _starRel = {};
82
- final Map<String, String> _hubFor = {};
83
- static const double _starRadius = 125.0;
84
- // Loose strength so the spring-back feels natural, not snappy.
85
- static const double _starRestoreStrength = 0.04;
86
- double _temperature = 0;
87
 
88
  // View transform.
89
  Offset _pan = Offset.zero;
@@ -96,7 +139,21 @@ class _GraphScreenState extends State<GraphScreen>
96
  int? _activeCluster;
97
 
98
  // Physics config.
99
- final _physics = _PhysicsConfig();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  // Local graph mode.
102
  bool _localMode = false;
@@ -147,9 +204,6 @@ class _GraphScreenState extends State<GraphScreen>
147
  _vel.clear();
148
  _adj.clear();
149
  _edgeWeights.clear();
150
- _starPos.clear();
151
- _starRel.clear();
152
- _hubFor.clear();
153
 
154
  // Initialise adjacency lists and zero velocities.
155
  for (final node in data.nodes) {
@@ -165,61 +219,26 @@ class _GraphScreenState extends State<GraphScreen>
165
  _edgeWeights[key] = e.weight;
166
  }
167
 
168
- // Compute star positions and seed nodes directly into them so the graph
169
- // opens already in star form no physics convergence needed.
170
- _starPos.addAll(_computeStarLayout(data, data.nodes));
171
- for (final node in data.nodes) {
172
- _pos[node.id] = _starPos[node.id] ?? Offset.zero;
173
- }
174
-
175
- _temperature = 45;
176
- if (!_ticker.isActive && data.nodes.isNotEmpty) _ticker.start();
177
- }
178
-
179
- // --------------------------------------------------------------------------
180
- // Star layout computation
181
- // --------------------------------------------------------------------------
182
-
183
- /// Returns the ideal star position for each node in [visible].
184
- ///
185
- /// Each connected cluster gets one **hub** (highest-degree node) placed on a
186
- /// coarse ring, with all other cluster members as **spokes** radiating
187
- /// outward at equal angular intervals.
188
- ///
189
- /// Cluster-hub separation = `_starRadius * 2.4` so adjacent clusters' spoke
190
- /// disks never spatially overlap, preventing cross-cluster edge crossings.
191
- Map<String, Offset> _computeStarLayout(
192
- GraphData data, List<GraphNode> visible) {
193
- final result = <String, Offset>{};
194
-
195
- // Group nodes by cluster ID.
196
  final clusterMap = <int, List<GraphNode>>{};
197
- for (final node in visible) {
198
  clusterMap.putIfAbsent(node.clusterId, () => []).add(node);
199
  }
200
-
201
- // Isolated nodes (-1) live on an outer ring, handled separately.
202
  final isolated = clusterMap.remove(-1) ?? <GraphNode>[];
203
  final clusterIds = clusterMap.keys.toList()..sort();
204
  final numClusters = clusterIds.length;
 
205
 
206
- // Minimum separation between adjacent cluster hubs so that no spoke from
207
- // cluster A can reach any spoke of cluster B.
208
- const clusterSep = _starRadius * 2.4;
209
- // Chord formula: for n equally spaced points on a ring, the chord between
210
- // adjacent points = 2 * R * sin(π / n). Solve for R given chord = clusterSep.
211
  final hubRingRadius = numClusters <= 1
212
  ? 0.0
213
- : clusterSep / (2 * math.sin(math.pi / numClusters));
214
 
215
  for (var ci = 0; ci < numClusters; ci++) {
216
  final cid = clusterIds[ci];
217
- final nodes = List<GraphNode>.from(clusterMap[cid]!);
218
-
219
- // Highest-degree node becomes the star's hub.
220
- nodes.sort((a, b) => b.degree.compareTo(a.degree));
221
- final hub = nodes.first;
222
-
223
  final hubAngle =
224
  (ci / math.max(1, numClusters)) * 2 * math.pi - math.pi / 2;
225
  final hubPos = numClusters <= 1
@@ -228,52 +247,42 @@ class _GraphScreenState extends State<GraphScreen>
228
  math.cos(hubAngle) * hubRingRadius,
229
  math.sin(hubAngle) * hubRingRadius,
230
  );
231
- result[hub.id] = hubPos;
232
- _hubFor[hub.id] = hub.id;
233
- _starRel[hub.id] = Offset.zero;
234
-
235
- // Place spoke nodes at equal angular intervals around the hub.
236
- final spokes = nodes.skip(1).toList();
237
- for (var si = 0; si < spokes.length; si++) {
238
- final spokeAngle =
239
- (si / math.max(1, spokes.length)) * 2 * math.pi - math.pi / 2;
240
- final rel = Offset(
241
- math.cos(spokeAngle) * _starRadius,
242
- math.sin(spokeAngle) * _starRadius,
243
- );
244
- result[spokes[si].id] = hubPos + rel;
245
- _hubFor[spokes[si].id] = hub.id;
246
- _starRel[spokes[si].id] = rel;
247
  }
248
  }
249
 
250
- // Isolated nodes on a ring well beyond all star clusters.
251
- final outerRadius = hubRingRadius + _starRadius * 1.6;
252
  for (var ii = 0; ii < isolated.length; ii++) {
253
  final angle =
254
  (ii / math.max(1, isolated.length)) * 2 * math.pi - math.pi / 2;
255
- result[isolated[ii].id] = Offset(
256
  math.cos(angle) * outerRadius,
257
  math.sin(angle) * outerRadius,
258
  );
259
- _hubFor[isolated[ii].id] = isolated[ii].id;
260
- _starRel[isolated[ii].id] = Offset.zero;
261
  }
262
 
263
- return result;
264
- }
265
-
266
- /// Recomputes star target positions for the currently visible node set and
267
- /// updates [_starPos]. Call whenever local-mode or depth changes.
268
- void _recomputeStar() {
269
- final data = _data;
270
- if (data == null) return;
271
  final visible = _visibleNodes(data);
272
- _starRel.clear();
273
- _hubFor.clear();
274
- _starPos
275
- ..clear()
276
- ..addAll(_computeStarLayout(data, visible));
 
 
 
 
277
  }
278
 
279
  // --------------------------------------------------------------------------
@@ -289,43 +298,31 @@ class _GraphScreenState extends State<GraphScreen>
289
 
290
  _step(data, visible, visibleIds);
291
 
292
- if (_temperature < 0.5) {
293
  _ticker.stop();
294
  }
295
  if (mounted) setState(() {});
296
  }
297
 
 
 
 
 
 
 
 
 
298
  void _step(GraphData data, List<GraphNode> visible, Set<String> visibleIds) {
299
  final ids = visible.map((n) => n.id).toList();
300
  final n = ids.length;
301
  if (n == 0) return;
302
 
303
- final k = _physics.linkDistance;
304
- final disp = <String, Offset>{for (final id in ids) id: Offset.zero};
305
-
306
- // Group visible nodes into connected components (families)
307
- final family = <String, int>{};
308
- var nextFamily = 0;
309
- for (final id in ids) {
310
- if (family.containsKey(id)) continue;
311
- nextFamily++;
312
- final queue = [id];
313
- family[id] = nextFamily;
314
- var head = 0;
315
- while (head < queue.length) {
316
- final curr = queue[head++];
317
- for (final nbr in (_adj[curr] ?? <String>[])) {
318
- if (visibleIds.contains(nbr) && !family.containsKey(nbr)) {
319
- family[nbr] = nextFamily;
320
- queue.add(nbr);
321
- }
322
- }
323
- }
324
- }
325
 
326
- // --- Force 1: Repulsion (Coulomb's law) ---
327
- // Every node pushes every other node away. Separate families repel 8x harder.
328
- final repelK = k * k * _physics.repelForce;
329
  for (var i = 0; i < n; i++) {
330
  for (var j = i + 1; j < n; j++) {
331
  final pa = _pos[ids[i]];
@@ -333,107 +330,130 @@ class _GraphScreenState extends State<GraphScreen>
333
  if (pa == null || pb == null) continue;
334
  var delta = pa - pb;
335
  var d = delta.distance;
336
- if (d < 0.01) {
337
- delta = const Offset(0.5, 0.3);
 
 
 
 
338
  d = delta.distance;
339
  }
340
- final sameFamily = family[ids[i]] == family[ids[j]];
341
- final mult = sameFamily ? 3.5 : 15.0;
342
- final force = (repelK * mult) / (d * d);
343
- final push = delta / d * force;
344
- disp[ids[i]] = disp[ids[i]]! + push;
345
- disp[ids[j]] = disp[ids[j]]! - push;
 
 
346
  }
347
  }
348
 
349
- // --- Force 2: Link/spring (Hooke's law) ---
350
- // Connected nodes attract each other toward the ideal link distance.
351
- for (final e in data.edges) {
352
- if (!visibleIds.contains(e.source) || !visibleIds.contains(e.target)) {
353
- continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  }
355
- final pa = _pos[e.source];
356
- final pb = _pos[e.target];
357
- if (pa == null || pb == null) continue;
358
- var delta = pa - pb;
359
- var d = delta.distance;
360
- if (d < 0.01) d = 0.01;
361
- // Spring: pulls when distance > k, pushes when < k.
362
- final force = (d - k) / d * _physics.linkForce * (0.4 + e.weight);
363
- final pull = delta / d * force;
364
- final degA = (_adj[e.source]?.length ?? 1).toDouble();
365
- final degB = (_adj[e.target]?.length ?? 1).toDouble();
366
- final totalDeg = degA + degB;
367
- final biasA = degB / totalDeg; // hub anchor stability
368
- final biasB = degA / totalDeg; // leaf node pull
369
-
370
- disp[e.source] = disp[e.source]! - pull * (biasA * 2);
371
- disp[e.target] = disp[e.target]! + pull * (biasB * 2);
372
  }
373
 
374
- // --- Force 3: Center gravity ---
375
- // Gentle pull toward origin keeps connected items centered inside.
376
- for (final id in ids) {
377
- if ((_adj[id]?.isEmpty ?? true)) continue; // empty nodes bypass inward gravity
378
- final p = _pos[id];
379
- if (p == null) continue;
380
- disp[id] = disp[id]! - p * _physics.centerForce;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  }
382
 
383
- // --- Force 4: Relative Star Schema Restoring Spring ---
384
- // Pulls every spoke node gently toward its ideal star offset relative to its CURRENT LIVE HUB POSITION.
385
- for (final id in ids) {
386
- if (id == _draggedNode) continue;
387
- final hubId = _hubFor[id];
388
- final rel = _starRel[id];
389
- if (hubId == null || rel == null || hubId == id) continue; // hub itself is free
390
- final hubPos = _pos[hubId];
391
- final curr = _pos[id];
392
- if (hubPos == null || curr == null) continue;
393
- final target = hubPos + rel;
394
- disp[id] = disp[id]! + (target - curr) * 0.22;
395
  }
396
 
397
- // --- Force 5: Segregated Circle Containment Wall ---
398
- // Connected clusters stay centered (< 130px). Empty nodes orbit far outer rim (220 - 260px).
399
- for (final id in ids) {
400
- if (id == _draggedNode) continue;
401
- final p = _pos[id];
402
- if (p == null) continue;
403
- final dist = p.distance;
404
- if (dist < 0.01) continue;
405
- if ((_adj[id]?.isEmpty ?? true)) {
406
- // Orbit rim halo for empty nodes.
407
- if (dist > 280.0) {
408
- disp[id] = disp[id]! - (p / dist) * (dist - 280.0) * 0.25;
409
- } else if (dist < 240.0) {
410
- disp[id] = disp[id]! + (p / dist) * (240.0 - dist) * 0.15;
411
- }
412
- } else {
413
- // Interior containment for connected nodes.
414
- if (dist > 190.0) {
415
- disp[id] = disp[id]! - (p / dist) * (dist - 190.0) * 0.25;
416
  }
417
  }
418
  }
419
 
420
- // --- Apply forces with temperature clamping + velocity damping ---
421
- const damping = 0.80; // less drag = bouncier spring oscillation
422
  for (final node in visible) {
423
- if (node.id == _draggedNode) continue;
424
- var dd = disp[node.id]!;
425
- // Add velocity (momentum).
426
- final oldVel = _vel[node.id] ?? Offset.zero;
427
- dd = dd + oldVel * damping;
428
- final len = dd.distance;
429
- if (len > 0) {
430
- dd = dd / len * math.min(len, _temperature);
431
  }
432
- _pos[node.id] = _pos[node.id]! + dd;
433
- _vel[node.id] = dd * 0.65; // carry 65% kinetic energy for snappy snapback
 
 
 
434
  }
435
-
436
- _temperature = _temperature * 0.96; // allow decaying below 0.5 so ticker stops naturally
437
  }
438
 
439
  // --------------------------------------------------------------------------
@@ -473,19 +493,29 @@ class _GraphScreenState extends State<GraphScreen>
473
  if (hit != null && d.pointerCount == 1) {
474
  _draggedNode = hit;
475
  _selected = hit;
476
- // Reheat physics so neighbors react to the drag (rubber-band effect).
477
- _temperature = math.max(_temperature, 8.0);
 
 
478
  if (!_ticker.isActive) _ticker.start();
479
  }
480
  }
481
 
 
 
482
  void _onScaleUpdate(ScaleUpdateDetails d, Size size) {
483
  if (_draggedNode != null && d.pointerCount == 1) {
484
  final center = Offset(size.width / 2, size.height / 2);
485
  final world = (d.localFocalPoint - center - _pan) / _zoom;
 
 
486
  _pos[_draggedNode!] = world;
487
- // High heat while dragging so the constellation tracks smoothly.
488
- _temperature = math.max(_temperature, 35.0);
 
 
 
 
489
  if (!_ticker.isActive) _ticker.start();
490
  setState(() {});
491
  return;
@@ -500,9 +530,11 @@ class _GraphScreenState extends State<GraphScreen>
500
 
501
  void _onScaleEnd(ScaleEndDetails d) {
502
  if (_draggedNode != null) {
503
- // Reheat enough for the restoring force to animate the snap-back
504
- // visibly, but not so hot that distant nodes thrash.
505
- _temperature = math.max(_temperature, 45.0);
 
 
506
  if (!_ticker.isActive) _ticker.start();
507
  }
508
  _draggedNode = null;
@@ -512,9 +544,9 @@ class _GraphScreenState extends State<GraphScreen>
512
  final visited = <String>{rootId};
513
  var frontier = <String>{rootId};
514
  var depth = 1;
515
- while (frontier.isNotEmpty && depth <= 25) {
516
  final next = <String>{};
517
- final factor = math.pow(0.96, depth).toDouble();
518
  for (final id in frontier) {
519
  for (final neighbor in (_adj[id] ?? <String>[])) {
520
  if (visited.add(neighbor)) {
@@ -598,10 +630,10 @@ class _GraphScreenState extends State<GraphScreen>
598
  setState(() {
599
  _localMode = true;
600
  _localRoot = node.id;
601
- _temperature = 60;
 
602
  if (!_ticker.isActive) _ticker.start();
603
  });
604
- _recomputeStar();
605
  },
606
  ),
607
  );
@@ -622,7 +654,8 @@ class _GraphScreenState extends State<GraphScreen>
622
  onChanged: () {
623
  setState(() {
624
  // Reheat so graph reacts to new forces.
625
- _temperature = 60;
 
626
  if (!_ticker.isActive) _ticker.start();
627
  });
628
  // Update the bottom sheet's own state.
@@ -632,20 +665,20 @@ class _GraphScreenState extends State<GraphScreen>
632
  setState(() {
633
  _localMode = v;
634
  if (!v) _localRoot = null;
635
- _temperature = 60;
 
636
  if (!_ticker.isActive) _ticker.start();
637
  });
638
- _recomputeStar();
639
  // Update the bottom sheet's own state.
640
  (ctx as Element).markNeedsBuild();
641
  },
642
  onLocalDepthChanged: (v) {
643
  setState(() {
644
  _localDepth = v;
645
- _temperature = 60;
 
646
  if (!_ticker.isActive) _ticker.start();
647
  });
648
- _recomputeStar();
649
  (ctx as Element).markNeedsBuild();
650
  },
651
  ),
@@ -731,10 +764,10 @@ class _GraphScreenState extends State<GraphScreen>
731
  setState(() {
732
  _localMode = false;
733
  _localRoot = null;
734
- _temperature = 60;
 
735
  if (!_ticker.isActive) _ticker.start();
736
  });
737
- _recomputeStar();
738
  },
739
  ),
740
  // Cluster filter chips.
@@ -774,10 +807,10 @@ class _GraphScreenState extends State<GraphScreen>
774
  onSelected: (v) {
775
  setState(() {
776
  _showConcepts = v;
777
- _temperature = 60;
 
778
  if (!_ticker.isActive) _ticker.start();
779
  });
780
- _recomputeStar();
781
  },
782
  showCheckmark: false,
783
  ),
@@ -801,7 +834,6 @@ class _GraphScreenState extends State<GraphScreen>
801
  data: data,
802
  visibleIds: visibleIds,
803
  positions: _pos,
804
- hubMap: _hubFor,
805
  pan: _pan,
806
  zoom: _zoom,
807
  selected: _selected,
@@ -1136,42 +1168,72 @@ class _SettingsSheet extends StatelessWidget {
1136
  color: scheme.onSurfaceVariant)),
1137
  const SizedBox(height: 8),
1138
  _SliderRow(
1139
- label: 'Repel force',
1140
- value: physics.repelForce,
 
 
 
 
 
 
 
 
 
 
1141
  min: 0.0,
1142
- max: 3.0,
1143
  onChanged: (v) {
1144
- physics.repelForce = v;
1145
  onChanged();
1146
  },
1147
  ),
1148
  _SliderRow(
1149
- label: 'Link force',
1150
- value: physics.linkForce,
 
 
 
 
 
 
 
 
 
 
1151
  min: 0.0,
1152
- max: 3.0,
1153
  onChanged: (v) {
1154
- physics.linkForce = v;
1155
  onChanged();
1156
  },
1157
  ),
1158
  _SliderRow(
1159
  label: 'Center force',
1160
- value: physics.centerForce,
1161
  min: 0.0,
1162
- max: 1.0,
1163
  onChanged: (v) {
1164
- physics.centerForce = v;
1165
  onChanged();
1166
  },
1167
  ),
1168
  _SliderRow(
1169
- label: 'Link distance',
1170
- value: physics.linkDistance,
1171
- min: 30,
1172
- max: 200,
1173
  onChanged: (v) {
1174
- physics.linkDistance = v;
 
 
 
 
 
 
 
 
 
 
1175
  onChanged();
1176
  },
1177
  ),
@@ -1266,7 +1328,6 @@ class _GraphPainter extends CustomPainter {
1266
  required this.data,
1267
  required this.visibleIds,
1268
  required this.positions,
1269
- required this.hubMap,
1270
  required this.pan,
1271
  required this.zoom,
1272
  required this.selected,
@@ -1278,7 +1339,6 @@ class _GraphPainter extends CustomPainter {
1278
  final GraphData data;
1279
  final Set<String> visibleIds;
1280
  final Map<String, Offset> positions;
1281
- final Map<String, String> hubMap;
1282
  final Offset pan;
1283
  final double zoom;
1284
  final String? selected;
@@ -1388,7 +1448,6 @@ class _GraphPainter extends CustomPainter {
1388
  }
1389
 
1390
  // --- Nodes ---
1391
- final dim = selected != null || activeCluster != null;
1392
  for (final node in data.nodes) {
1393
  if (!visibleIds.contains(node.id)) continue;
1394
  final p = positions[node.id];
@@ -1480,19 +1539,8 @@ class _GraphPainter extends CustomPainter {
1480
  ellipsis: '…',
1481
  )..layout(maxWidth: 105);
1482
  Offset labelPos;
1483
- final hId = hubMap[node.id];
1484
- final hPos = hId != null ? positions[hId] : null;
1485
- final cPos = positions[node.id];
1486
- if (hPos != null && cPos != null && hId != node.id) {
1487
- final d = cPos - hPos;
1488
- final ang = math.atan2(d.dy, d.dx);
1489
- final out = r + 4.0;
1490
- final cx = s.dx + math.cos(ang) * (out + tp.width / 2);
1491
- final cy = s.dy + math.sin(ang) * (out + tp.height / 2);
1492
- labelPos = Offset(cx - tp.width / 2, cy - tp.height / 2);
1493
- } else {
1494
- labelPos = Offset(s.dx - tp.width / 2, s.dy + r + 3);
1495
- }
1496
  tp.paint(canvas, labelPos);
1497
  }
1498
 
@@ -1580,8 +1628,11 @@ class _GraphPainter extends CustomPainter {
1580
  final angle = (i * 60 - 30) * math.pi / 180;
1581
  final x = center.dx + r * math.cos(angle);
1582
  final y = center.dy + r * math.sin(angle);
1583
- if (i == 0) path.moveTo(x, y);
1584
- else path.lineTo(x, y);
 
 
 
1585
  }
1586
  return path..close();
1587
  }
 
1
+ /// d3-force style knowledge graph: a live force-directed physics simulation that
2
  /// runs on every open, letting nodes wiggle and settle into emergent clusters.
3
  ///
4
+ /// Physics model ported from Org Roam UI's d3-force engine:
5
+ /// 1. **Charge** — every node repels every other (forceManyBody).
6
+ /// 2. **Link** — connected nodes spring toward ideal distance (forceLink).
7
+ /// 3. **Center** — gentle pull toward origin (forceCenter).
8
+ /// 4. **Gravity** — forceX/Y toward origin.
9
+ /// 5. **Collision** — prevents node overlap (forceCollide).
10
+ ///
11
+ /// Uses alpha-decay cooling: forces scale by alpha (0→1), which decays
12
+ /// exponentially each tick. This gives smooth "settling into equilibrium" feel.
13
  ///
14
  /// Drag pins a node while physics recalculates around it (rubber-band effect).
15
  /// Two node types (card = circle, catalog = rounded square), three edge styles,
 
40
  // ========================================================================== //
41
 
42
  class _PhysicsConfig {
43
+ /// forceManyBody strength (negative = repulsive). Org Roam UI default: -700.
44
+ double charge;
45
+
46
+ /// forceLink strength. Org Roam UI default: 0.3.
47
+ double linkStrength;
48
+
49
+ /// forceLink ideal rest length.
50
  double linkDistance;
51
 
52
+ /// forceLink constraint iterations per tick.
53
+ int linkIterations;
54
+
55
+ /// forceX/Y gravity strength. Org Roam UI default: 0.3.
56
+ double gravity;
57
+
58
+ /// Whether gravity forces are active.
59
+ bool gravityOn;
60
+
61
+ /// forceCenter strength. Org Roam UI default: 0.2.
62
+ double centerStrength;
63
+
64
+ /// Whether centering force is active.
65
+ bool centerOn;
66
+
67
+ /// Whether collision detection is active.
68
+ bool collision;
69
+
70
+ /// forceCollide radius.
71
+ double collisionRadius;
72
+
73
+ /// Alpha decay rate per tick (lower = slower settling). Org Roam UI: 0.0228.
74
+ double alphaDecay;
75
+
76
+ /// Velocity damping per tick (0–1, higher = more damping). Org Roam UI: 0.25.
77
+ double velocityDecay;
78
+
79
+ /// Minimum alpha before simulation stops.
80
+ double alphaMin;
81
+
82
  _PhysicsConfig({
83
+ this.charge = -900,
84
+ this.linkStrength = 0.6,
85
+ this.linkDistance = 115,
86
+ this.linkIterations = 1,
87
+ this.gravity = 0.04,
88
+ this.gravityOn = true,
89
+ this.centerStrength = 0.08,
90
+ this.centerOn = true,
91
+ this.collision = true,
92
+ this.collisionRadius = 50,
93
+ this.alphaDecay = 0.035,
94
+ this.velocityDecay = 0.28,
95
+ this.alphaMin = 0.001,
96
  });
97
  }
98
 
 
123
  final Map<String, List<String>> _adj = {};
124
  final Map<String, double> _edgeWeights = {};
125
 
126
+ // d3-force alphacontrols how much force is applied each tick.
127
+ // Starts at 1.0 on reheat, decays toward alphaMin.
128
+ double _alpha = 1.0;
129
+ double _alphaTarget = 0.0;
 
 
 
 
130
 
131
  // View transform.
132
  Offset _pan = Offset.zero;
 
139
  int? _activeCluster;
140
 
141
  // Physics config.
142
+ final _physics = _PhysicsConfig(
143
+ charge: -900,
144
+ linkStrength: 0.6,
145
+ linkDistance: 115,
146
+ linkIterations: 1,
147
+ gravity: 0.04,
148
+ gravityOn: true,
149
+ centerStrength: 0.08,
150
+ centerOn: true,
151
+ collision: true,
152
+ collisionRadius: 50,
153
+ alphaDecay: 0.035,
154
+ velocityDecay: 0.28,
155
+ alphaMin: 0.001,
156
+ );
157
 
158
  // Local graph mode.
159
  bool _localMode = false;
 
204
  _vel.clear();
205
  _adj.clear();
206
  _edgeWeights.clear();
 
 
 
207
 
208
  // Initialise adjacency lists and zero velocities.
209
  for (final node in data.nodes) {
 
219
  _edgeWeights[key] = e.weight;
220
  }
221
 
222
+ // Cluster-ring seeding: place clusters on a ring, jitter nodes within.
223
+ // Physics will find the organic layout from here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  final clusterMap = <int, List<GraphNode>>{};
225
+ for (final node in data.nodes) {
226
  clusterMap.putIfAbsent(node.clusterId, () => []).add(node);
227
  }
 
 
228
  final isolated = clusterMap.remove(-1) ?? <GraphNode>[];
229
  final clusterIds = clusterMap.keys.toList()..sort();
230
  final numClusters = clusterIds.length;
231
+ final rng = math.Random(42);
232
 
233
+ // Cluster hubs on a ring.
234
+ const clusterSep = 200.0;
 
 
 
235
  final hubRingRadius = numClusters <= 1
236
  ? 0.0
237
+ : clusterSep / (2 * math.sin(math.pi / math.max(2, numClusters)));
238
 
239
  for (var ci = 0; ci < numClusters; ci++) {
240
  final cid = clusterIds[ci];
241
+ final nodes = clusterMap[cid]!;
 
 
 
 
 
242
  final hubAngle =
243
  (ci / math.max(1, numClusters)) * 2 * math.pi - math.pi / 2;
244
  final hubPos = numClusters <= 1
 
247
  math.cos(hubAngle) * hubRingRadius,
248
  math.sin(hubAngle) * hubRingRadius,
249
  );
250
+
251
+ // Jitter each node around the cluster center.
252
+ for (final node in nodes) {
253
+ _pos[node.id] = hubPos +
254
+ Offset(
255
+ (rng.nextDouble() - 0.5) * 80,
256
+ (rng.nextDouble() - 0.5) * 80,
257
+ );
 
 
 
 
 
 
 
 
258
  }
259
  }
260
 
261
+ // Isolated nodes in a ring beyond clusters.
262
+ final outerRadius = hubRingRadius + 180;
263
  for (var ii = 0; ii < isolated.length; ii++) {
264
  final angle =
265
  (ii / math.max(1, isolated.length)) * 2 * math.pi - math.pi / 2;
266
+ _pos[isolated[ii].id] = Offset(
267
  math.cos(angle) * outerRadius,
268
  math.sin(angle) * outerRadius,
269
  );
 
 
270
  }
271
 
272
+ // Run 80 warmup ticks silently so the graph opens in a settled state
273
+ // rather than showing chaotic initial animation.
274
+ _alpha = 1.0;
275
+ _alphaTarget = 0.0;
 
 
 
 
276
  final visible = _visibleNodes(data);
277
+ final visibleIds = visible.map((n) => n.id).toSet();
278
+ for (var i = 0; i < 80; i++) {
279
+ _step(data, visible, visibleIds);
280
+ if (_alpha < _physics.alphaMin) break;
281
+ }
282
+
283
+ // Start the ticker for remaining settling.
284
+ _alpha = math.max(_alpha, 0.1);
285
+ if (!_ticker.isActive && data.nodes.isNotEmpty) _ticker.start();
286
  }
287
 
288
  // --------------------------------------------------------------------------
 
298
 
299
  _step(data, visible, visibleIds);
300
 
301
+ if (_alpha < _physics.alphaMin) {
302
  _ticker.stop();
303
  }
304
  if (mounted) setState(() {});
305
  }
306
 
307
+ /// d3-force simulation step.
308
+ ///
309
+ /// Applies five forces scaled by [_alpha], matching Org Roam UI's d3-force:
310
+ /// 1. Charge (forceManyBody) — repulsion between all node pairs.
311
+ /// 2. Link (forceLink) — spring toward ideal distance.
312
+ /// 3. Center (forceCenter) — pull toward origin.
313
+ /// 4. Gravity (forceX/Y) — directional pull toward origin.
314
+ /// 5. Collision (forceCollide) — prevents node overlap.
315
  void _step(GraphData data, List<GraphNode> visible, Set<String> visibleIds) {
316
  final ids = visible.map((n) => n.id).toList();
317
  final n = ids.length;
318
  if (n == 0) return;
319
 
320
+ // --- Alpha decay (d3's cooling schedule) ---
321
+ _alpha += (_alphaTarget - _alpha) * _physics.alphaDecay;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
+ // --- Force 1: Charge / Many-Body (d3.forceManyBody) ---
324
+ // Every node repels every other node.
325
+ // d3 formula: vel += delta * strength * alpha / d² (raw delta, NOT unit vector)
326
  for (var i = 0; i < n; i++) {
327
  for (var j = i + 1; j < n; j++) {
328
  final pa = _pos[ids[i]];
 
330
  if (pa == null || pb == null) continue;
331
  var delta = pa - pb;
332
  var d = delta.distance;
333
+ if (d < 1.0) {
334
+ // Jitter to break degeneracy (d3 does this too).
335
+ delta = Offset(
336
+ (ids[i].hashCode.isEven ? 1 : -1) * 0.5,
337
+ (ids[j].hashCode.isEven ? 1 : -1) * 0.3,
338
+ );
339
  d = delta.distance;
340
  }
341
+ // d3's forceManyBody: delta * strength * alpha / d²
342
+ // Using raw delta (not normalized) this is critical.
343
+ // Makes force fall off as 1/d (not 1/d²), giving long-range repulsion.
344
+ final w = _physics.charge * _alpha / (d * d);
345
+ final push = delta * w;
346
+ // Negative charge push is opposite to delta → repulsive.
347
+ _vel[ids[i]] = (_vel[ids[i]] ?? Offset.zero) - push;
348
+ _vel[ids[j]] = (_vel[ids[j]] ?? Offset.zero) + push;
349
  }
350
  }
351
 
352
+ // --- Force 2: Link / Spring (d3.forceLink) ---
353
+ // Multiple iterations for constraint convergence (d3's linkIterations).
354
+ for (var iter = 0; iter < _physics.linkIterations; iter++) {
355
+ for (final e in data.edges) {
356
+ if (!visibleIds.contains(e.source) ||
357
+ !visibleIds.contains(e.target)) {
358
+ continue;
359
+ }
360
+ final pa = _pos[e.source];
361
+ final pb = _pos[e.target];
362
+ if (pa == null || pb == null) continue;
363
+ var delta = pb - pa;
364
+ var d = delta.distance;
365
+ if (d < 0.01) d = 0.01;
366
+
367
+ // d3-style bias: distribute force inversely by degree.
368
+ final degA = math.max(1.0, (_adj[e.source]?.length ?? 1).toDouble());
369
+ final degB = math.max(1.0, (_adj[e.target]?.length ?? 1).toDouble());
370
+ final bias = degA / (degA + degB);
371
+
372
+ // Spring force: pull toward ideal link distance.
373
+ // D3 degree scaling: divide strength by minDeg so densely connected hubs don't crush together.
374
+ final minDeg = math.min(degA, degB);
375
+ final k = _physics.linkDistance;
376
+ final strength = (_physics.linkStrength / minDeg) * _alpha;
377
+ final force = (d - k) / d * strength;
378
+ final fx = delta.dx * force;
379
+ final fy = delta.dy * force;
380
+
381
+ // Apply with degree-bias (lighter nodes move more).
382
+ _vel[e.target] = (_vel[e.target] ?? Offset.zero) -
383
+ Offset(fx * bias, fy * bias);
384
+ _vel[e.source] = (_vel[e.source] ?? Offset.zero) +
385
+ Offset(fx * (1 - bias), fy * (1 - bias));
386
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  }
388
 
389
+ // --- Force 3: Center (d3.forceCenter) ---
390
+ // Shifts the center of mass toward origin.
391
+ if (_physics.centerOn) {
392
+ var cx = 0.0, cy = 0.0;
393
+ var count = 0;
394
+ for (final id in ids) {
395
+ final p = _pos[id];
396
+ if (p == null) continue;
397
+ cx += p.dx;
398
+ cy += p.dy;
399
+ count++;
400
+ }
401
+ if (count > 0) {
402
+ cx = cx / count * _physics.centerStrength;
403
+ cy = cy / count * _physics.centerStrength;
404
+ for (final id in ids) {
405
+ final p = _pos[id];
406
+ if (p == null) continue;
407
+ _pos[id] = Offset(p.dx - cx, p.dy - cy);
408
+ }
409
+ }
410
  }
411
 
412
+ // --- Force 4: Gravity (d3.forceX / d3.forceY) ---
413
+ // Individual pull toward origin (unlike center which shifts the mean).
414
+ if (_physics.gravityOn) {
415
+ for (final id in ids) {
416
+ final p = _pos[id];
417
+ if (p == null) continue;
418
+ _vel[id] = (_vel[id] ?? Offset.zero) +
419
+ Offset(-p.dx * _physics.gravity * _alpha,
420
+ -p.dy * _physics.gravity * _alpha);
421
+ }
 
 
422
  }
423
 
424
+ // --- Force 5: Collision (d3.forceCollide) ---
425
+ if (_physics.collision) {
426
+ final r = _physics.collisionRadius;
427
+ for (var i = 0; i < n; i++) {
428
+ for (var j = i + 1; j < n; j++) {
429
+ final pa = _pos[ids[i]];
430
+ final pb = _pos[ids[j]];
431
+ if (pa == null || pb == null) continue;
432
+ var delta = pa - pb;
433
+ var d = delta.distance;
434
+ final minDist = r * 2; // each node has radius r
435
+ if (d < minDist && d > 0.01) {
436
+ final overlap = (minDist - d) / d * 0.5;
437
+ final push = delta * overlap;
438
+ _pos[ids[i]] = pa + push;
439
+ _pos[ids[j]] = pb - push;
440
+ }
 
 
441
  }
442
  }
443
  }
444
 
445
+ // --- Apply velocity + damping (d3's velocityDecay) ---
 
446
  for (final node in visible) {
447
+ if (node.id == _draggedNode) {
448
+ _vel[node.id] = Offset.zero;
449
+ continue;
 
 
 
 
 
450
  }
451
+ final vel = _vel[node.id] ?? Offset.zero;
452
+ // d3 velocity decay: vel *= (1 - velocityDecay)
453
+ final dampedVel = vel * (1.0 - _physics.velocityDecay);
454
+ _vel[node.id] = dampedVel;
455
+ _pos[node.id] = (_pos[node.id] ?? Offset.zero) + dampedVel;
456
  }
 
 
457
  }
458
 
459
  // --------------------------------------------------------------------------
 
493
  if (hit != null && d.pointerCount == 1) {
494
  _draggedNode = hit;
495
  _selected = hit;
496
+ _lastDragWorld = null;
497
+ // Reheat strongly so neighbors react to the drag.
498
+ _alphaTarget = 0.3;
499
+ _alpha = math.max(_alpha, 0.5);
500
  if (!_ticker.isActive) _ticker.start();
501
  }
502
  }
503
 
504
+ Offset? _lastDragWorld;
505
+
506
  void _onScaleUpdate(ScaleUpdateDetails d, Size size) {
507
  if (_draggedNode != null && d.pointerCount == 1) {
508
  final center = Offset(size.width / 2, size.height / 2);
509
  final world = (d.localFocalPoint - center - _pan) / _zoom;
510
+ final prev = _lastDragWorld ?? _pos[_draggedNode!] ?? world;
511
+ final worldDelta = world - prev;
512
  _pos[_draggedNode!] = world;
513
+ _vel[_draggedNode!] = Offset.zero;
514
+ _lastDragWorld = world;
515
+ // Elastic drag: pull neighbors along with depth-decaying strength.
516
+ _dragFamily(_draggedNode!, worldDelta);
517
+ // Keep alpha warm while dragging.
518
+ _alpha = math.max(_alpha, 0.3);
519
  if (!_ticker.isActive) _ticker.start();
520
  setState(() {});
521
  return;
 
530
 
531
  void _onScaleEnd(ScaleEndDetails d) {
532
  if (_draggedNode != null) {
533
+ _vel[_draggedNode!] = Offset.zero;
534
+ // Let it settle naturally from a moderate alpha.
535
+ _alphaTarget = 0.0;
536
+ _alpha = math.max(_alpha, 0.3);
537
+ _lastDragWorld = null;
538
  if (!_ticker.isActive) _ticker.start();
539
  }
540
  _draggedNode = null;
 
544
  final visited = <String>{rootId};
545
  var frontier = <String>{rootId};
546
  var depth = 1;
547
+ while (frontier.isNotEmpty && depth <= 6) {
548
  final next = <String>{};
549
+ final factor = math.pow(0.7, depth).toDouble();
550
  for (final id in frontier) {
551
  for (final neighbor in (_adj[id] ?? <String>[])) {
552
  if (visited.add(neighbor)) {
 
630
  setState(() {
631
  _localMode = true;
632
  _localRoot = node.id;
633
+ _alpha = 1.0;
634
+ _alphaTarget = 0.0;
635
  if (!_ticker.isActive) _ticker.start();
636
  });
 
637
  },
638
  ),
639
  );
 
654
  onChanged: () {
655
  setState(() {
656
  // Reheat so graph reacts to new forces.
657
+ _alpha = 1.0;
658
+ _alphaTarget = 0.0;
659
  if (!_ticker.isActive) _ticker.start();
660
  });
661
  // Update the bottom sheet's own state.
 
665
  setState(() {
666
  _localMode = v;
667
  if (!v) _localRoot = null;
668
+ _alpha = 1.0;
669
+ _alphaTarget = 0.0;
670
  if (!_ticker.isActive) _ticker.start();
671
  });
 
672
  // Update the bottom sheet's own state.
673
  (ctx as Element).markNeedsBuild();
674
  },
675
  onLocalDepthChanged: (v) {
676
  setState(() {
677
  _localDepth = v;
678
+ _alpha = 1.0;
679
+ _alphaTarget = 0.0;
680
  if (!_ticker.isActive) _ticker.start();
681
  });
 
682
  (ctx as Element).markNeedsBuild();
683
  },
684
  ),
 
764
  setState(() {
765
  _localMode = false;
766
  _localRoot = null;
767
+ _alpha = 1.0;
768
+ _alphaTarget = 0.0;
769
  if (!_ticker.isActive) _ticker.start();
770
  });
 
771
  },
772
  ),
773
  // Cluster filter chips.
 
807
  onSelected: (v) {
808
  setState(() {
809
  _showConcepts = v;
810
+ _alpha = 1.0;
811
+ _alphaTarget = 0.0;
812
  if (!_ticker.isActive) _ticker.start();
813
  });
 
814
  },
815
  showCheckmark: false,
816
  ),
 
834
  data: data,
835
  visibleIds: visibleIds,
836
  positions: _pos,
 
837
  pan: _pan,
838
  zoom: _zoom,
839
  selected: _selected,
 
1168
  color: scheme.onSurfaceVariant)),
1169
  const SizedBox(height: 8),
1170
  _SliderRow(
1171
+ label: 'Charge',
1172
+ value: -physics.charge / 100,
1173
+ min: 0.0,
1174
+ max: 20.0,
1175
+ onChanged: (v) {
1176
+ physics.charge = -v * 100;
1177
+ onChanged();
1178
+ },
1179
+ ),
1180
+ _SliderRow(
1181
+ label: 'Link strength',
1182
+ value: physics.linkStrength * 5,
1183
  min: 0.0,
1184
+ max: 5.0,
1185
  onChanged: (v) {
1186
+ physics.linkStrength = v / 5;
1187
  onChanged();
1188
  },
1189
  ),
1190
  _SliderRow(
1191
+ label: 'Link distance',
1192
+ value: physics.linkDistance,
1193
+ min: 30,
1194
+ max: 300,
1195
+ onChanged: (v) {
1196
+ physics.linkDistance = v;
1197
+ onChanged();
1198
+ },
1199
+ ),
1200
+ _SliderRow(
1201
+ label: 'Gravity',
1202
+ value: physics.gravity * 10,
1203
  min: 0.0,
1204
+ max: 10.0,
1205
  onChanged: (v) {
1206
+ physics.gravity = v / 10;
1207
  onChanged();
1208
  },
1209
  ),
1210
  _SliderRow(
1211
  label: 'Center force',
1212
+ value: physics.centerStrength,
1213
  min: 0.0,
1214
+ max: 2.0,
1215
  onChanged: (v) {
1216
+ physics.centerStrength = v;
1217
  onChanged();
1218
  },
1219
  ),
1220
  _SliderRow(
1221
+ label: 'Damping',
1222
+ value: physics.velocityDecay * 10,
1223
+ min: 0.0,
1224
+ max: 10.0,
1225
  onChanged: (v) {
1226
+ physics.velocityDecay = v / 10;
1227
+ onChanged();
1228
+ },
1229
+ ),
1230
+ _SliderRow(
1231
+ label: 'Settle rate',
1232
+ value: physics.alphaDecay * 50,
1233
+ min: 0.0,
1234
+ max: 5.0,
1235
+ onChanged: (v) {
1236
+ physics.alphaDecay = v / 50;
1237
  onChanged();
1238
  },
1239
  ),
 
1328
  required this.data,
1329
  required this.visibleIds,
1330
  required this.positions,
 
1331
  required this.pan,
1332
  required this.zoom,
1333
  required this.selected,
 
1339
  final GraphData data;
1340
  final Set<String> visibleIds;
1341
  final Map<String, Offset> positions;
 
1342
  final Offset pan;
1343
  final double zoom;
1344
  final String? selected;
 
1448
  }
1449
 
1450
  // --- Nodes ---
 
1451
  for (final node in data.nodes) {
1452
  if (!visibleIds.contains(node.id)) continue;
1453
  final p = positions[node.id];
 
1539
  ellipsis: '…',
1540
  )..layout(maxWidth: 105);
1541
  Offset labelPos;
1542
+ // Place labels below nodes (no hub-based placement needed).
1543
+ labelPos = Offset(s.dx - tp.width / 2, s.dy + r + 3);
 
 
 
 
 
 
 
 
 
 
 
1544
  tp.paint(canvas, labelPos);
1545
  }
1546
 
 
1628
  final angle = (i * 60 - 30) * math.pi / 180;
1629
  final x = center.dx + r * math.cos(angle);
1630
  final y = center.dy + r * math.sin(angle);
1631
+ if (i == 0) {
1632
+ path.moveTo(x, y);
1633
+ } else {
1634
+ path.lineTo(x, y);
1635
+ }
1636
  }
1637
  return path..close();
1638
  }
app/lib/ui/features/onboarding/views/onboarding_screen.dart CHANGED
@@ -246,7 +246,7 @@ class _PageHook extends StatelessWidget {
246
  ),
247
  ),
248
  const SizedBox(height: 28),
249
- CaptureSpot(color: scheme.primary.withValues(alpha: 0.7)),
250
  const SizedBox(height: 32),
251
  Text(
252
  'Videos, articles, newsletters, Wikipedia — paste any link and Cachy distills the key takeaways into a browsable card.',
 
246
  ),
247
  ),
248
  const SizedBox(height: 28),
249
+ CaptureSpot(color: scheme.primary.withValues(alpha: 0.75), size: const Size(120, 88)),
250
  const SizedBox(height: 32),
251
  Text(
252
  'Videos, articles, newsletters, Wikipedia — paste any link and Cachy distills the key takeaways into a browsable card.',
app/lib/ui/features/onboarding/views/splash_screen.dart CHANGED
@@ -4,7 +4,6 @@
4
  library;
5
 
6
  import 'package:flutter/material.dart';
7
- import 'package:phosphor_flutter/phosphor_flutter.dart';
8
 
9
  import '../../../core/brand.dart';
10
 
@@ -42,7 +41,7 @@ class _SplashScreenState extends State<SplashScreen>
42
  }
43
 
44
  void _finish() {
45
- if (_done) return;
46
  _done = true;
47
  widget.onDone();
48
  }
@@ -55,10 +54,15 @@ class _SplashScreenState extends State<SplashScreen>
55
 
56
  @override
57
  Widget build(BuildContext context) {
 
 
 
 
 
58
  return GestureDetector(
59
  onTap: _finish,
60
  child: Scaffold(
61
- backgroundColor: Brand.paperGround,
62
  body: Center(
63
  child: AnimatedBuilder(
64
  animation: _c,
@@ -67,8 +71,8 @@ class _SplashScreenState extends State<SplashScreen>
67
  children: [
68
  CachyGlyph(
69
  size: 92,
70
- color: Brand.ink,
71
- reelColor: Brand.sage,
72
  reelDrop: _drop.value,
73
  ),
74
  const SizedBox(height: 20),
@@ -78,22 +82,10 @@ class _SplashScreenState extends State<SplashScreen>
78
  offset: Offset(0, (1 - _wordmark.value) * 10),
79
  child: Text(
80
  'cachy',
81
- style: Brand.wordmarkStyle(44, color: Brand.ink),
82
  ),
83
  ),
84
  ),
85
- const SizedBox(height: 28),
86
- _FloatingChip(
87
- icon: PhosphorIconsRegular.videoCamera,
88
- label: 'Short-form videos',
89
- t: _wordmark.value,
90
- ),
91
- const SizedBox(height: 12),
92
- _FloatingChip(
93
- icon: PhosphorIconsRegular.sparkle,
94
- label: 'AI-powered recall',
95
- t: (_wordmark.value * 1.25 - 0.25).clamp(0.0, 1.0),
96
- ),
97
  ],
98
  ),
99
  ),
@@ -103,56 +95,21 @@ class _SplashScreenState extends State<SplashScreen>
103
  }
104
  }
105
 
106
- class _FloatingChip extends StatelessWidget {
107
- const _FloatingChip({required this.icon, required this.label, required this.t});
108
- final PhosphorIconData icon;
109
- final String label;
110
- final double t;
111
-
112
- @override
113
- Widget build(BuildContext context) {
114
- return Opacity(
115
- opacity: t.clamp(0.0, 1.0),
116
- child: Transform.translate(
117
- offset: Offset(0, (1 - t) * 16),
118
- child: Container(
119
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
120
- decoration: BoxDecoration(
121
- color: Brand.paperRaised,
122
- borderRadius: BorderRadius.circular(999),
123
- border: Border.all(color: Brand.ink.withValues(alpha: 0.08)),
124
- ),
125
- child: Row(
126
- mainAxisSize: MainAxisSize.min,
127
- children: [
128
- PhosphorIcon(icon, size: 15, color: Brand.sage),
129
- const SizedBox(width: 8),
130
- Text(
131
- label,
132
- style: Brand.label(
133
- size: 12,
134
- color: Brand.ink,
135
- weight: FontWeight.w600,
136
- ),
137
- ),
138
- ],
139
- ),
140
- ),
141
- ),
142
- );
143
- }
144
- }
145
-
146
  /// Static variant used while the app boots (no animation).
147
  class SplashStatic extends StatelessWidget {
148
  const SplashStatic({super.key});
149
 
150
  @override
151
  Widget build(BuildContext context) {
 
152
  return Scaffold(
153
- backgroundColor: Brand.paperGround,
154
- body: const Center(
155
- child: CachyGlyph(size: 92, color: Brand.ink, reelColor: Brand.sage),
 
 
 
 
156
  ),
157
  );
158
  }
 
4
  library;
5
 
6
  import 'package:flutter/material.dart';
 
7
 
8
  import '../../../core/brand.dart';
9
 
 
41
  }
42
 
43
  void _finish() {
44
+ if (!mounted || _done) return;
45
  _done = true;
46
  widget.onDone();
47
  }
 
54
 
55
  @override
56
  Widget build(BuildContext context) {
57
+ final isDark = Theme.of(context).brightness == Brightness.dark;
58
+ final groundColor = isDark ? Brand.charcoalGround : Brand.paperGround;
59
+ final textColor = isDark ? Brand.cream : Brand.ink;
60
+ final accentColor = Brand.accentFor(Theme.of(context).brightness);
61
+
62
  return GestureDetector(
63
  onTap: _finish,
64
  child: Scaffold(
65
+ backgroundColor: groundColor,
66
  body: Center(
67
  child: AnimatedBuilder(
68
  animation: _c,
 
71
  children: [
72
  CachyGlyph(
73
  size: 92,
74
+ color: textColor,
75
+ reelColor: accentColor,
76
  reelDrop: _drop.value,
77
  ),
78
  const SizedBox(height: 20),
 
82
  offset: Offset(0, (1 - _wordmark.value) * 10),
83
  child: Text(
84
  'cachy',
85
+ style: Brand.wordmarkStyle(44, color: textColor),
86
  ),
87
  ),
88
  ),
 
 
 
 
 
 
 
 
 
 
 
 
89
  ],
90
  ),
91
  ),
 
95
  }
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  /// Static variant used while the app boots (no animation).
99
  class SplashStatic extends StatelessWidget {
100
  const SplashStatic({super.key});
101
 
102
  @override
103
  Widget build(BuildContext context) {
104
+ final isDark = Theme.of(context).brightness == Brightness.dark;
105
  return Scaffold(
106
+ backgroundColor: isDark ? Brand.charcoalGround : Brand.paperGround,
107
+ body: Center(
108
+ child: CachyGlyph(
109
+ size: 92,
110
+ color: isDark ? Brand.cream : Brand.ink,
111
+ reelColor: Brand.accentFor(Theme.of(context).brightness),
112
+ ),
113
  ),
114
  );
115
  }
app/pubspec.yaml CHANGED
@@ -94,15 +94,23 @@ flutter_launcher_icons:
94
  android: true
95
  ios: false # iOS Runner not present in this repo; enable when it's scaffolded
96
  image_path: "assets/brand/icon.png"
97
- adaptive_icon_background: "#5B5BD6"
98
- adaptive_icon_foreground: "assets/brand/icon.png"
 
 
 
 
 
 
99
 
100
  # Native splash shown before Flutter boots, continuing into SplashScreen.
101
  flutter_native_splash:
102
- color: "#5B5BD6"
 
103
  image: "assets/brand/splash_logo.png"
104
  android_12:
105
- color: "#5B5BD6"
 
106
  image: "assets/brand/splash_logo.png"
107
 
108
  # To add assets to your application, add an assets section, like this:
 
94
  android: true
95
  ios: false # iOS Runner not present in this repo; enable when it's scaffolded
96
  image_path: "assets/brand/icon.png"
97
+ adaptive_icon_background: "#F5F0E8"
98
+ adaptive_icon_foreground: "assets/brand/adaptive_foreground.png"
99
+ adaptive_icon_monochrome: "assets/brand/adaptive_monochrome.png"
100
+ web:
101
+ generate: true
102
+ image_path: "assets/brand/icon.png"
103
+ background_color: "#F5F0E8"
104
+ theme_color: "#181818"
105
 
106
  # Native splash shown before Flutter boots, continuing into SplashScreen.
107
  flutter_native_splash:
108
+ color: "#F5F0E8"
109
+ color_dark: "#181818"
110
  image: "assets/brand/splash_logo.png"
111
  android_12:
112
+ color: "#F5F0E8"
113
+ color_dark: "#181818"
114
  image: "assets/brand/splash_logo.png"
115
 
116
  # To add assets to your application, add an assets section, like this:
app/test/obsidian_export_test.dart CHANGED
@@ -4,7 +4,6 @@ import 'package:archive/archive.dart';
4
  import 'package:cachy/data/services/obsidian_export.dart';
5
  import 'package:cachy/domain/models/block.dart';
6
  import 'package:cachy/domain/models/card.dart';
7
- import 'package:cachy/domain/models/enums.dart';
8
  import 'package:flutter_test/flutter_test.dart';
9
 
10
  Card _card(String id, String oneLiner, {List<Block> blocks = const []}) => Card(
 
4
  import 'package:cachy/data/services/obsidian_export.dart';
5
  import 'package:cachy/domain/models/block.dart';
6
  import 'package:cachy/domain/models/card.dart';
 
7
  import 'package:flutter_test/flutter_test.dart';
8
 
9
  Card _card(String id, String oneLiner, {List<Block> blocks = const []}) => Card(
app/web/favicon.png CHANGED
app/web/icons/Icon-192.png CHANGED
app/web/icons/Icon-512.png CHANGED
app/web/icons/Icon-maskable-192.png CHANGED
app/web/icons/Icon-maskable-512.png CHANGED
app/web/index.html CHANGED
@@ -29,87 +29,144 @@
29
 
30
  <title>cachy</title>
31
  <link rel="manifest" href="manifest.json">
 
 
 
32
  <style id="splash-screen-style">
33
- html {
34
- height: 100%
 
 
 
 
 
 
35
  }
36
-
37
  body {
38
- margin: 0;
39
- min-height: 100%;
40
- background-color: #5B5BD6;
41
- background-size: 100% 100%;
42
  }
43
-
44
- .center {
45
- margin: 0;
46
- position: absolute;
47
- top: 50%;
48
- left: 50%;
49
- -ms-transform: translate(-50%, -50%);
50
- transform: translate(-50%, -50%);
51
  }
52
-
53
- .contain {
54
- display:block;
55
- width:100%; height:100%;
56
- object-fit: contain;
 
 
57
  }
58
-
59
- .stretch {
60
- display:block;
61
- width:100%; height:100%;
62
  }
63
-
64
- .cover {
65
- display:block;
66
- width:100%; height:100%;
67
- object-fit: cover;
68
  }
69
-
70
- .bottom {
71
- position: absolute;
72
- bottom: 0;
73
- left: 50%;
74
- -ms-transform: translate(-50%, 0);
75
- transform: translate(-50%, 0);
76
  }
77
-
78
- .bottomLeft {
79
- position: absolute;
80
- bottom: 0;
81
- left: 0;
82
  }
83
-
84
- .bottomRight {
85
- position: absolute;
86
- bottom: 0;
87
- right: 0;
88
  }
89
- #splash {
90
- pointer-events: none !important;
91
- z-index: -100;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
93
  </style>
94
  <script id="splash-screen-script">
 
 
 
95
  function removeSplashFromWeb() {
96
- document.getElementById("splash")?.remove();
97
- document.getElementById("splash-branding")?.remove();
 
 
 
 
 
98
  document.body.style.background = "transparent";
99
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  window.addEventListener('load', function() {
101
- setTimeout(removeSplashFromWeb, 1200);
 
 
 
102
  });
103
  window.addEventListener('removeSplashFromWeb', removeSplashFromWeb);
104
  </script>
105
  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
106
  </head>
107
  <body>
108
- <picture id="splash">
109
- <source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
110
- <source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
111
- <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
112
- </picture>
 
 
 
 
113
  <!--
114
  You can customize the "flutter_bootstrap.js" script.
115
  This is useful to provide a custom configuration to the Flutter loader
 
29
 
30
  <title>cachy</title>
31
  <link rel="manifest" href="manifest.json">
32
+ <link rel="preconnect" href="https://fonts.googleapis.com">
33
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
34
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&display=swap" rel="stylesheet">
35
  <style id="splash-screen-style">
36
+ html, body {
37
+ width: 100%;
38
+ height: 100%;
39
+ margin: 0;
40
+ padding: 0;
41
+ background-color: #F5F0E8;
42
+ overflow: hidden;
43
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
44
  }
 
45
  body {
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
 
49
  }
50
+ @media (prefers-color-scheme: dark) {
51
+ html, body {
52
+ background-color: #181818;
53
+ }
 
 
 
 
54
  }
55
+ #splash {
56
+ display: flex;
57
+ flex-direction: column;
58
+ align-items: center;
59
+ justify-content: center;
60
+ pointer-events: none !important;
61
+ z-index: 9999;
62
  }
63
+ .cachy-logo-svg {
64
+ width: 92px;
65
+ height: 92px;
66
+ margin-bottom: 20px;
67
  }
68
+ .glyph-bracket {
69
+ stroke: #1C1917;
 
 
 
70
  }
71
+ .glyph-reel-group {
72
+ animation: reelDrop 0.72s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
 
 
 
 
 
73
  }
74
+ @keyframes reelDrop {
75
+ 0% { transform: translateY(-44px); }
76
+ 100% { transform: translateY(0px); }
 
 
77
  }
78
+ .glyph-reel {
79
+ fill: #7D8472;
80
+ opacity: 0.55;
 
 
81
  }
82
+ @media (prefers-color-scheme: dark) {
83
+ .glyph-bracket {
84
+ stroke: #EDE8DF;
85
+ }
86
+ .glyph-reel {
87
+ fill: #96A885;
88
+ }
89
+ }
90
+ .cachy-wordmark {
91
+ font-size: 44px;
92
+ font-weight: 600;
93
+ letter-spacing: -0.8px;
94
+ color: #1C1917;
95
+ margin: 0;
96
+ font-family: "Fraunces", "Georgia", "Times New Roman", serif;
97
+ opacity: 0;
98
+ animation: wordmarkReveal 0.65s ease-out 0.65s forwards;
99
+ }
100
+ @keyframes wordmarkReveal {
101
+ 0% { opacity: 0; transform: translateY(10px); }
102
+ 100% { opacity: 1; transform: translateY(0px); }
103
+ }
104
+ @media (prefers-color-scheme: dark) {
105
+ .cachy-wordmark {
106
+ color: #EDE8DF;
107
+ }
108
  }
109
  </style>
110
  <script id="splash-screen-script">
111
+ let minTimeElapsed = false;
112
+ let flutterReady = false;
113
+
114
  function removeSplashFromWeb() {
115
+ const splash = document.getElementById("splash");
116
+ if (splash && !splash.classList.contains("removing")) {
117
+ splash.classList.add("removing");
118
+ splash.style.transition = "opacity 0.25s ease-out";
119
+ splash.style.opacity = "0";
120
+ setTimeout(() => splash.remove(), 250);
121
+ }
122
  document.body.style.background = "transparent";
123
  }
124
+
125
+ function tryRemoveSplash() {
126
+ if (minTimeElapsed && flutterReady) {
127
+ removeSplashFromWeb();
128
+ }
129
+ }
130
+
131
+ setTimeout(() => {
132
+ minTimeElapsed = true;
133
+ tryRemoveSplash();
134
+ }, 1300);
135
+
136
+ const observer = new MutationObserver((mutations) => {
137
+ for (const mutation of mutations) {
138
+ for (const node of mutation.addedNodes) {
139
+ if (node.nodeName && (node.nodeName.toLowerCase() === 'flutter-view' || node.nodeName.toLowerCase() === 'flt-glass-pane' || node.nodeName.toLowerCase() === 'canvas')) {
140
+ flutterReady = true;
141
+ tryRemoveSplash();
142
+ observer.disconnect();
143
+ return;
144
+ }
145
+ }
146
+ }
147
+ });
148
+ observer.observe(document.body, { childList: true, subtree: true });
149
+
150
  window.addEventListener('load', function() {
151
+ setTimeout(() => {
152
+ flutterReady = true;
153
+ tryRemoveSplash();
154
+ }, 3500);
155
  });
156
  window.addEventListener('removeSplashFromWeb', removeSplashFromWeb);
157
  </script>
158
  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
159
  </head>
160
  <body>
161
+ <div id="splash">
162
+ <svg class="cachy-logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
163
+ <path class="glyph-bracket" d="M 18 30 L 18 66 A 16 16 0 0 0 34 82 L 66 82 A 16 16 0 0 0 82 66 L 82 30" stroke-width="13" stroke-linecap="round" stroke-linejoin="round"/>
164
+ <g class="glyph-reel-group">
165
+ <rect class="glyph-reel" x="35" y="35" width="30" height="30" rx="9.6"/>
166
+ </g>
167
+ </svg>
168
+ <div class="cachy-wordmark">cachy</div>
169
+ </div>
170
  <!--
171
  You can customize the "flutter_bootstrap.js" script.
172
  This is useful to provide a custom configuration to the Flutter loader
app/web/manifest.json CHANGED
@@ -3,8 +3,8 @@
3
  "short_name": "cachy",
4
  "start_url": ".",
5
  "display": "standalone",
6
- "background_color": "#0175C2",
7
- "theme_color": "#0175C2",
8
  "description": "A new Flutter project.",
9
  "orientation": "portrait-primary",
10
  "prefer_related_applications": false,
@@ -32,4 +32,4 @@
32
  "purpose": "maskable"
33
  }
34
  ]
35
- }
 
3
  "short_name": "cachy",
4
  "start_url": ".",
5
  "display": "standalone",
6
+ "background_color": "#F5F0E8",
7
+ "theme_color": "#181818",
8
  "description": "A new Flutter project.",
9
  "orientation": "portrait-primary",
10
  "prefer_related_applications": false,
 
32
  "purpose": "maskable"
33
  }
34
  ]
35
+ }
backend/app/store/db.py CHANGED
@@ -282,7 +282,6 @@ async def init_db() -> None:
282
  "description": "TEXT",
283
  },
284
  )
285
- await conn.execute(text("UPDATE artifacts SET saved = 0 WHERE saved = 1"))
286
  await _add_missing_columns(
287
  conn,
288
  "cards",
 
282
  "description": "TEXT",
283
  },
284
  )
 
285
  await _add_missing_columns(
286
  conn,
287
  "cards",