36 files changed
+2165
-576
.agents/skills/cue-animations/SKILL.md
new
+787
@@ -0,0 +1,787 @@
1
+---
2
+name: cue-animations
3
+description: 'Flutter animation using the Cue package. Use for: adding animations, transitions, motion, enter/exit effects, hover effects, scroll animations, toggle animations, page transitions, draggable scrubbing, modal transitions, animated widgets, keyframe animations, spring physics, fade, slide, scale, rotate, blur, clip, decorate, parallax, stagger, delay, CueMotion presets. DO NOT USE FOR: non-animation UI work, state management, or layout without motion.'
4
+---
5
+
6
+# Cue Animation Skill
7
+<!-- skill-version: 3 -->
8
+
9
+## Core Mental Model
10
+
11
+Cue separates animation into four concerns:
12
+
13
+| Piece | Role |
14
+|-------|------|
15
+| `Cue` | **When** to animate (the trigger) |
16
+| `Actor` | **What** is animated (wraps the child) |
17
+| `Act` | **Which property** changes (fade, slide, scale, etc.) |
18
+| `CueMotion` | **How** it moves (spring preset or timed curve) |
19
+
20
+## General Flutter Conventions (Always Apply)
21
+
22
+- **Always create `Widget` classes, never helper functions that return widgets.** Use `StatelessWidget` or `StatefulWidget` for any reusable or extracted UI — not `Widget _buildFoo()` methods or top-level functions.
23
+- **Use shorthand constructors everywhere**, not fully qualified class constructors:
24
+
25
+```dart
26
+// Padding / EdgeInsets
27
+.all(16) not EdgeInsets.all(16)
28
+.only(top: 8) not EdgeInsets.only(top: 8)
29
+.symmetric(h: 16) not EdgeInsets.symmetric(horizontal: 16)
30
+.fromLTRB(...) not EdgeInsets.fromLTRB(...)
31
+
32
+// BorderRadius
33
+.circular(12) not BorderRadius.circular(12)
34
+.vertical(top: .circular(16)) not BorderRadius.vertical(...)
35
+
36
+// Alignment
37
+.topLeft / .center / .bottomRight not Alignment.topLeft
38
+
39
+// MainAxisAlignment / CrossAxisAlignment / MainAxisSize
40
+.center / .spaceBetween / .min not MainAxisAlignment.center
41
+
42
+// BoxFit / Clip / TextOverflow
43
+.cover / .antiAlias / .fade not BoxFit.cover
44
+
45
+// Colors
46
+Colors.transparent / Colors.white (unchanged — no shorthand available)
47
+```
48
+
49
+## Step-by-Step Procedure
50
+
51
+1. **Pick the trigger** — choose the right `Cue` factory for the use case.
52
+2. **Set the motion** — always provide `motion:` on `Cue` (or on `Actor`/`Act` for overrides).
53
+3. **Wrap children in `Actor`** — or use `Cue(acts: [...])` for a single child.
54
+4. **Choose acts** — use shorthand factories (`.fadeIn()`, `.slideY()`, `.scale()`, etc.).
55
+5. **Add stagger if needed** — use `delay:` on `Actor` to offset multiple children.
56
+
57
+## Trigger Reference
58
+
59
+```dart
60
+// One-shot entrance when widget mounts
61
+// Supports: repeat, reverseOnRepeat, repeatCount, onEnd
62
+Cue.onMount(
63
+ motion: .smooth(),
64
+ repeat: false, // loop the animation
65
+ reverseOnRepeat: false, // ping-pong when looping
66
+ repeatCount: 3, // finite loop count (null = infinite)
67
+ onEnd: (forward) { ... }, // fires on completion in either direction
68
+ acts: [...],
69
+ child: child,
70
+)
71
+
72
+// Toggled by a boolean — forward on true, reverse on false
73
+Cue.onToggle(toggled: isExpanded, motion: .smooth(), child: child)
74
+
75
+// Restarts forward every time value changes
76
+Cue.onChange(value: currentTab, motion: .smooth(), fromCurrentValue: true, child: child)
77
+
78
+// Driven by pointer hover
79
+Cue.onHover(motion: .interactive(), child: child)
80
+
81
+// Driven by focus
82
+Cue.onFocus(motion: .smooth(), child: child)
83
+
84
+// Scrubbed continuously as the widget travels through the viewport (parallax-style).
85
+// Progress = 0 when leading edge enters bottom of viewport, 1 when it reaches the top.
86
+// Prefer non-overshoot motions (.smooth, .linear, .easeOut) — springs with bounce
87
+// look jarring when scrubbed.
88
+Cue.onScroll(child: child)
89
+
90
+// Plays once when widget scrolls into view; stays complete. Does NOT reverse on scroll-out.
91
+// Until revealed it scrubs like onScroll; once visible it plays forward with its motion.
92
+// Use enabled: false to skip the animation and jump to completed state.
93
+Cue.onScrollVisible(enabled: true, child: child)
94
+
95
+// Scrubbed by external progress (e.g. drag)
96
+Cue.onProgress(progress: dragNotifier, child: child)
97
+
98
+// Index-based (page carousels, staggered lists)
99
+// Works with CuePageController, CueTabController, CueIndexController
100
+Cue.indexed(controller: cuePageController, index: index, child: child)
101
+
102
+// Fully imperative
103
+Cue(controller: myController, child: child)
104
+```
105
+
106
+**`debugLabel`** — add a label to any `Cue` to identify it in the DevTools scrubber:
107
+```dart
108
+Cue.onMount(motion: .smooth(), debugLabel: 'HeroCard', child: child)
109
+```
110
+
111
+## Actor and Acts
112
+
113
+```dart
114
+// Multiple acts, same child — compose freely
115
+Actor(
116
+ delay: 60.ms,
117
+ acts: [
118
+ .fadeIn(),
119
+ .slideY(from: 0.15),
120
+ .scale(from: 0.96),
121
+ ],
122
+ child: child,
123
+)
124
+
125
+// Extension shorthand for single child — skips explicit Actor
126
+child.act([.fadeIn(), .slideUp()])
127
+
128
+// Inline inside Cue (single animated child)
129
+Cue.onMount(
130
+ motion: .smooth(),
131
+ acts: [.fadeIn(), .slideY(from: 0.2)],
132
+ child: child,
133
+)
134
+```
135
+
136
+**Act defaults — only set what differs from identity:**
137
+Every act defaults both `from` and `to` to the resting/identity value for that property. You only provide the parameter that deviates. Common identity defaults:
138
+
139
+| Act | `from` default | `to` default |
140
+|-----|---------------|-------------|
141
+| `scale` | `1.0` | `1.0` |
142
+| `opacity` | `1.0` | `1.0` |
143
+| `slideX/Y/Up/Down` | `0.0` | `0.0` |
144
+| `blur` | `0.0` | `0.0` |
145
+| `padding` | `EdgeInsets.zero` | `EdgeInsets.zero` |
146
+| `rotate` | `0.0` | `0.0` |
147
+| `translate` | `Offset.zero` | `Offset.zero` |
148
+
149
+```dart
150
+.scale(from: 0.9) // → 1.0 (identity)
151
+.scale(to: 1.05) // from 1.0 (identity) →
152
+.slideY(from: 0.15) // → 0.0 (identity)
153
+.blur(from: 8) // → 0.0 (identity, i.e. sharp)
154
+.padding(to: .symmetric(vertical: 12)) // from zero →
155
+```
156
+
157
+**Actor rules:**
158
+- `Actor` without an ancestor `Cue` throws.
159
+- Only one act per act key per `Actor` (no two slide variants in the same `Actor`).
160
+- `motion` on an act > `Actor.motion` > `Cue` motion.
161
+- `delay` is **additive**: `Actor.delay` + `Act.delay` stack — setting both does not override, it accumulates.
162
+
163
+**One `Cue` per scene:** A single trigger should be covered by a single `Cue`. For a page entrance, use one `Cue.onMount` placed above all the `Actor`s — do **not** nest multiple `Cue.onMount` wrappers around individual children. Stagger via `Actor.delay`, not by multiplying triggers.
164
+
165
+```dart
166
+// CORRECT — one Cue coordinates the whole entrance
167
+Cue.onMount(
168
+ motion: .smooth(),
169
+ child: Column(
170
+ children: [
171
+ Actor(acts: [.fadeIn(), .slideY(from: 0.12)], child: title),
172
+ Actor(delay: 60.ms, acts: [.fadeIn(), .slideY(from: 0.12)], child: subtitle),
173
+ Actor(delay: 120.ms, acts: [.fadeIn(), .slideY(from: 0.12)], child: button),
174
+ ],
175
+ ),
176
+)
177
+
178
+// WRONG — three separate triggers for the same scene
179
+Cue.onMount(acts: [.fadeIn()], child: title)
180
+Cue.onMount(acts: [.fadeIn()], child: subtitle) // ✗
181
+Cue.onMount(acts: [.fadeIn()], child: button) // ✗
182
+```
183
+
184
+## Motion Hierarchy
185
+
186
+Motion cascades down and can be overridden at each level:
187
+
188
+```
189
+Cue(motion: .smooth()) ← default for all Actors below
190
+ Actor(motion: .gentle()) ← default for all acts on this Actor
191
+ Act(motion: .snappy()) ← overrides for this specific act only
192
+```
193
+
194
+- `Cue.motion` is the scene-wide default.
195
+- `Actor.motion` overrides `Cue.motion` for all acts on that `Actor`.
196
+- An act-level `motion` overrides everything for that single property.
197
+- You only need to set `motion` where behavior should differ from the parent.
198
+- **Setting `motion` implicitly sets `reverseMotion` to the same value.** To make reverse feel different, set `reverseMotion` explicitly. This applies at both `Cue` and `Actor` level.
199
+
200
+```dart
201
+// reverse uses the same .smooth() motion — implicit
202
+Cue.onToggle(toggled: isOpen, motion: .smooth(), child: child)
203
+
204
+// reverse uses a different motion — explicit override
205
+Cue.onToggle(toggled: isOpen, motion: .smooth(), reverseMotion: .snappy(), child: child)
206
+
207
+// same rule on Actor
208
+Actor(motion: .bouncy(), reverseMotion: .linear(160.ms), acts: [...], child: child)
209
+```
210
+
211
+```dart
212
+Cue.onMount(
213
+ motion: .smooth(), // scene default
214
+ child: Column(children: [
215
+ Actor(
216
+ acts: [.fadeIn(), .slideY(from: 0.15)], // both use .smooth()
217
+ child: title,
218
+ ),
219
+ Actor(
220
+ motion: .gentle(), // overrides .smooth() for this Actor
221
+ acts: [
222
+ .scale(from: 0.95), // uses .gentle()
223
+ .fadeIn(motion: .snappy()), // overrides .gentle() for fadeIn only
224
+ ],
225
+ child: card,
226
+ ),
227
+ ]),
228
+)
229
+```
230
+
231
+## Delay is Additive
232
+
233
+`Actor.delay` and per-act `delay` stack — they do not override each other.
234
+
235
+```dart
236
+Actor(
237
+ delay: 80.ms, // Actor-level delay
238
+ acts: [
239
+ .fadeIn(), // starts at 80 ms
240
+ .slideY(from: 0.15, delay: 40.ms), // starts at 80 + 40 = 120 ms
241
+ ],
242
+ child: child,
243
+)
244
+```
245
+
246
+Use `Actor.delay` for gross stagger between children, and act-level `delay` for fine-grained sequencing within a single `Actor`.
247
+
248
+## Motion Presets (CueMotion)
249
+
250
+Prefer spring-based presets for natural interruption handling.
251
+
252
+```dart
253
+.smooth() // fast, clean, no overshoot — best default
254
+.snappy() // near-instant micro-interactions
255
+.bouncy() // visible overshoot for emphasis
256
+.gentle() // slower, softer
257
+.wobbly() // exaggerated, playful
258
+.interactive() // responsive drag/hover feel
259
+.spatial() // layout movement
260
+.effect() // decorative property changes
261
+
262
+// Fixed-duration timing when physics isn't appropriate
263
+.linear(250.ms)
264
+.easeIn(300.ms)
265
+.easeOut(300.ms)
266
+.easeInOut(300.ms)
267
+.curved(400.ms, curve: Curves.easeInOut)
268
+
269
+// Special variants
270
+CueMotion.none // zero-duration, resolves instantly — use when a CueMotion
271
+ // is required by the API but no animation is wanted
272
+.threshold(200.ms, breakpoint: 0.5)
273
+ // stays at the start value, then snaps instantly to the end
274
+ // when progress crosses breakpoint (0→1 fraction).
275
+ // no interpolation — useful for visibility toggles that must
276
+ // align precisely with another ongoing animation.
277
+```
278
+
279
+## Act Families — Quick Reference
280
+
281
+### Transform
282
+```dart
283
+.scale(from: 0.9) // uniform scale
284
+.zoomIn() / .zoomOut()
285
+.rotate(to: 180) // visual-only rotation in degrees (transform, no layout impact — prefer this)
286
+.rotateLayout(to: 1, unit: .quarterTurns) // rotates AND recomputes layout — use only when layout must shift
287
+.rotate3D(to: Rotation3D(x: 15), perspective: 0.001)
288
+.flipX() / .flipY()
289
+.translate(to: Offset(0, 24)) // pixel translation
290
+.translateX(from: -20) / .translateY(from: 20)
291
+.translateFromGlobal(offset: rect.topLeft) // translate from a global screen position
292
+.translateFromGlobalRect(rect) // translate from the center of a global rect
293
+.translateFromGlobalKey(key) // translate from a widget identified by GlobalKey
294
+.slide(from: Offset(0, 2)) // fractional offset (relative to widget size)
295
+.slideX() / .slideY() / .slideUp() / .slideDown()
296
+.slideFromLeading() / .slideFromTrailing()
297
+.stretch(to: Stretch(x: 1.2, y: 0.8)) // scale axes independently
298
+.skew(to: Skew(x: 0.3)) // skew transformation
299
+.transform(to: Matrix4.rotationZ(0.3)) // raw Matrix4 (from defaults to identity)
300
+.parallax(slide: 0.3, axis: .horizontal) // scrub-linked parallax slide
301
+```
302
+
303
+### Visual
304
+```dart
305
+.opacity(from: 0, to: 1)
306
+.fadeIn() / .fadeOut()
307
+.blur(from: 8) / .focus() / .unfocus()
308
+.backdropBlur(to: 8)
309
+.colorTint(from: Colors.white60, to: Colors.black)
310
+```
311
+
312
+### Layout & Clipping
313
+```dart
314
+// NSize constructors used by sizedClip and sizedBox:
315
+// NSize(w: 200, h: 100) — explicit width and height
316
+// NSize.width(200) — fixed width, height follows child
317
+// NSize.height(100) — fixed height, width follows child
318
+// NSize.square(48) — equal width and height
319
+// NSize.size(Size(...)) — from a Size object
320
+// NSize.childSize — both axes follow child (no constraint)
321
+// double.infinity → resolves to parent's max constraint
322
+
323
+.sizedBox(width: .tween(40, double.infinity), height: .tween(40, 56))
324
+.sizedClip(from: NSize.square(24), to: NSize.height(68))
325
+.sizedClip(from: NSize.size(rect.size), to: NSize.width(240))
326
+.fractionalSize(widthFactor: .tween(0.2, 1.0), heightFactor: .fixed(0.5))
327
+.padding(to: .symmetric(vertical: 12))
328
+.align(from: .bottomCenter, to: .bottomLeft)
329
+.clipHeight(fromFactor: 0.3) / .clipWidth() / .clip() / .circularClip()
330
+```
331
+
332
+### Multi-Property Acts — AnimatableValue
333
+
334
+Some acts animate multiple independent properties on the same widget. Each property accepts an `AnimatableValue`:
335
+- `.tween(from, to)` — interpolates between two values
336
+- `.fixed(value)` — constant, not animated (still required if the act needs to know the value)
337
+
338
+Omitting a property entirely means that property is ignored/left at its natural value.
339
+
340
+**`.sizedBox`** — animate width and/or height independently:
341
+```dart
342
+.sizedBox(
343
+ width: .tween(80, double.infinity), // expands to fill
344
+ height: .fixed(40), // height stays constant
345
+)
346
+.sizedBox(width: .tween(100, 300)) // only width animates
347
+```
348
+
349
+**`.fractionalSize`** — size as a fraction of the parent (0.0–1.0):
350
+```dart
351
+.fractionalSize(
352
+ widthFactor: .tween(0.2, 1.0),
353
+ heightFactor: .fixed(0.5),
354
+ alignment: .fixed(.center),
355
+)
356
+```
357
+
358
+**`.decorate`** — animate box decoration properties individually.
359
+Prefer `DecoratedBoxActor` for standalone use; use `.decorate` when composing with other acts inside an `Actor`:
360
+```dart
361
+.decorate(
362
+ color: .tween(Colors.transparent, Colors.white),
363
+ borderRadius: .tween(.circular(0), .circular(16)),
364
+ border: .fixed(Border.all(color: Colors.blue)),
365
+ boxShadow: .tween(
366
+ [BoxShadow(blurRadius: 0)],
367
+ [BoxShadow(blurRadius: 8, offset: Offset(0, 4))],
368
+ ),
369
+ gradient: .tween(fromGradient, toGradient),
370
+)
371
+```
372
+
373
+**`CardAct` / `CardActor`** — animate card surface properties.
374
+Prefer `CardActor` for standalone card animations; use `CardAct` inside `Actor(acts:[...])` when combining with other acts:
375
+```dart
376
+// Preferred for card-only animations
377
+CardActor(
378
+ elevation: .tween(1, 8),
379
+ color: .tween(Colors.white, Colors.blue.shade50),
380
+ borderRadius: .tween(.circular(8), .circular(24)),
381
+ shadowColor: .fixed(Colors.black),
382
+ margin: .tween(.zero, .all(8)),
383
+ child: child,
384
+)
385
+
386
+// Inside an Actor alongside other acts
387
+Actor(
388
+ acts: [
389
+ CardAct(elevation: .fixed(2), borderRadius: .tween(.circular(8), .circular(24))),
390
+ .scale(from: 0.95),
391
+ ],
392
+ child: child,
393
+)
394
+```
395
+
396
+**`PositionAct` / `PositionedActor`** — animate stack positioning.
397
+Prefer `PositionedActor` — it reads naturally inside a `Stack`:
398
+```dart
399
+Stack(
400
+ children: [
401
+ PositionedActor(
402
+ from: .fill(), // fills the stack
403
+ to: .fromSTEB(20, 250, 20, 24), // shrinks to specific insets
404
+ child: myWidget,
405
+ ),
406
+ PositionedActor.keyframed(
407
+ frames: .fractional([...]),
408
+ relativeTo: stackSize,
409
+ child: myWidget,
410
+ ),
411
+ ],
412
+)
413
+```
414
+
415
+**`DecoratedBoxActor`** — standalone animated decoration, no `Actor`/`Cue` nesting needed:
416
+```dart
417
+DecoratedBoxActor(
418
+ color: .tween(Colors.grey.shade100, Colors.blue.shade50),
419
+ borderRadius: .tween(.circular(8), .circular(32)),
420
+ shape: .circle,
421
+ child: SizedBox.square(dimension: 48),
422
+)
423
+```
424
+
425
+**`PaintActor`** — custom painting driven by animation progress:
426
+```dart
427
+PaintActor(
428
+ painter: myCustomPainter, // receives CueAnimation<double> to drive paint
429
+ child: child,
430
+)
431
+```
432
+
433
+### Specialized
434
+```dart
435
+// Animate along a custom Path — optional autoRotate to face direction of travel
436
+PathMotionAct(
437
+ path: myPath, // a Path() with a single continuous metric
438
+ autoRotate: true, // rotate widget to face path direction
439
+ alignment: .center, // pivot/anchor point
440
+)
441
+```
442
+
443
+### Style & Decoration
444
+```dart
445
+// textStyle — animates between two complete TextStyle values
446
+.textStyle(
447
+ from: TextStyle(fontSize: 14, color: Colors.grey),
448
+ to: TextStyle(fontSize: 22, color: Colors.black, fontWeight: .bold),
449
+)
450
+// iconTheme — animates icon properties
451
+.iconTheme(from: IconThemeData(size: 24), to: IconThemeData(size: 20))
452
+```
453
+
454
+### Positional (Stack)
455
+See **Multi-Property Acts** section above for full `PositionedActor` coverage.
456
+
457
+```dart
458
+PositionedActor(from: .fill(), to: .fromSTEB(20, 250, 20, 24), child: child)
459
+PositionedActor.keyframed(frames: .fractional([...]), relativeTo: size, child: child)
460
+```
461
+
462
+### Custom Value
463
+```dart
464
+// TweenActor — animate any value with a builder (does not need to be inside Actor)
465
+TweenActor<double>(
466
+ from: 0,
467
+ to: 1,
468
+ motion: .smooth(),
469
+ builder: (context, animation) {
470
+ return FadeTransition(opacity: animation, child: child);
471
+ },
472
+)
473
+```
474
+
475
+## Keyframes
476
+
477
+### Motion-based (spring between values)
478
+```dart
479
+ScaleAct.keyframed(
480
+ frames: Keyframes([
481
+ .key(0.92),
482
+ .key(1.06, motion: .bouncy()),
483
+ .key(1.0),
484
+ ], motion: .smooth()),
485
+)
486
+```
487
+
488
+### Fractional (time-based at specific progress points)
489
+```dart
490
+TranslateAct.keyframed(
491
+ frames: .fractional([
492
+ .key(const Offset(0, 24), at: 0.0),
493
+ .key(const Offset(0, -8), at: 0.7),
494
+ .key(Offset.zero, at: 1.0),
495
+ ], duration: 450.ms),
496
+)
497
+
498
+// ScaleAct with fractional keyframes (used for custom switch squish)
499
+ScaleAct.keyframed(
500
+ frames: .fractional([
501
+ .key(1.1, at: 0.4),
502
+ .key(1.1, at: 0.6),
503
+ .key(1.0, at: 1.0),
504
+ ]),
505
+)
506
+```
507
+
508
+## Reverse Behavior
509
+
510
+```dart
511
+// Mirror: same animation plays in reverse
512
+.fadeIn(reverse: .mirror(delay: 40.ms))
513
+
514
+// To: reverse targets a different value
515
+.scale(to: 1.06, reverse: .to(0.98))
516
+
517
+// None: reverse does nothing
518
+.zoomIn(reverse: .none())
519
+
520
+// Different motion for forward vs reverse on Cue/Actor
521
+Cue.onToggle(
522
+ toggled: isOpen,
523
+ motion: .smooth(),
524
+ reverseMotion: .snappy(),
525
+ child: child,
526
+)
527
+
528
+Actor(
529
+ motion: .smooth(),
530
+ reverseMotion: .linear(160.ms),
531
+ delay: 80.ms,
532
+ reverseDelay: 20.ms,
533
+ acts: [...],
534
+ child: child,
535
+)
536
+```
537
+
538
+## Modal Transitions
539
+
540
+### CueModalTransition
541
+
542
+Morphs any trigger widget into a full modal. Captures the trigger's screen-space `Rect` and passes it to the builder so you can animate directly from the trigger's position and size.
543
+
544
+```dart
545
+CueModalTransition(
546
+ motion: .smooth(),
547
+ reverseMotion: .snappy(), // defaults to motion if omitted
548
+ alignment: .bottomRight, // anchors modal's corner to trigger's same corner
549
+ // null = no positioning, builder must place itself
550
+ barrierColor: Colors.transparent, // use transparent for contextual menus
551
+ barrierDismissible: true,
552
+ hideTriggerOnTransition: true, // hides trigger while modal is open (seamless morph)
553
+ useRootNavigator: true, // false = scoped to a nested Navigator
554
+ backdrop: Actor( // optional: rendered behind modal, in front of barrier
555
+ acts: [.backdropBlur(to: 8)],
556
+ child: ColoredBox(color: Colors.transparent),
557
+ ),
558
+ triggerBuilder: (context, open) {
559
+ return FloatingActionButton(onPressed: open, child: Icon(Icons.add));
560
+ },
561
+ builder: (context, rect) {
562
+ // rect = trigger's bounding box in global screen coordinates
563
+ return Actor(
564
+ acts: [
565
+ .sizedClip(from: NSize.size(rect.size), to: NSize.width(300)), // expand from trigger size
566
+ .translateFromGlobalRect(rect), // move FROM trigger's position
567
+ ],
568
+ child: myModalContent,
569
+ );
570
+ },
571
+)
572
+```
573
+
574
+**`alignment`:** controls which corner/point of the modal is anchored to the same point on the trigger.
575
+- `.bottomRight` — ideal for FABs and corner action buttons that expand in-place
576
+- `.center` — works well for inline buttons that expand in place
577
+- `null` — no automatic positioning; builder output goes into a full-screen `Stack` and must position itself
578
+
579
+**`rect` in builder** — common act patterns using the trigger rect:
580
+```dart
581
+.sizedClip(from: NSize.size(rect.size)) // expand from trigger's exact size
582
+.translateFromGlobalRect(rect) // translate an element from trigger's center
583
+.translateFromGlobal(offset: rect.topLeft) // translate from trigger's top-left corner
584
+.iconTheme(from: IconThemeData(size: rect.height * 0.5), to: IconThemeData(size: 20))
585
+```
586
+
587
+**Nesting** — bind different gestures to different modals from the same trigger:
588
+```dart
589
+CueModalTransition(
590
+ motion: .smooth(),
591
+ triggerBuilder: (_, openTap) {
592
+ return CueModalTransition(
593
+ motion: .smooth(),
594
+ triggerBuilder: (context, openLongPress) {
595
+ return GestureDetector(
596
+ onTap: openTap,
597
+ onLongPress: openLongPress,
598
+ child: myButton,
599
+ );
600
+ },
601
+ builder: (context, rect) => LongPressContent(rect: rect),
602
+ );
603
+ },
604
+ builder: (context, rect) => TapContent(rect: rect),
605
+)
606
+```
607
+
608
+**Close the modal** from inside the builder with `Navigator.of(context).pop()`.
609
+
610
+### showCueDialog / CueDialogRoute
611
+
612
+For standard dialogs driven by Cue. All `Actor`s inside automatically pick up the route controller via `CueScope` — no need to pass a controller explicitly.
613
+
614
+```dart
615
+showCueDialog(
616
+ context: context,
617
+ motion: .smooth(),
618
+ reverseMotion: .snappy(),
619
+ barrierColor: Colors.black54,
620
+ builder: (context) => Actor(
621
+ acts: [.fadeIn(), .slideY(from: 0.12)],
622
+ child: AlertDialog(title: Text('Hello')),
623
+ ),
624
+)
625
+
626
+// Or push directly for full control:
627
+Navigator.of(context).push(
628
+ CueDialogRoute(
629
+ motion: .smooth(),
630
+ reverseMotion: .snappy(),
631
+ pageBuilder: (context, _, _) => MyModalContent(),
632
+ ),
633
+);
634
+```
635
+
636
+## CueDragScrubber
637
+
638
+`CueDragScrubber` maps drag distance to animation progress. The controller is taken from the nearest `CueScope` ancestor automatically, or passed explicitly.
639
+
640
+```dart
641
+CueDragScrubber(
642
+ distance: 250.0, // pixels that map to full 0→1 progress travel
643
+ axis: .vertical, // .vertical (default) or .horizontal
644
+ scrubDirection: .auto, // .forward, .reverse, or .auto (infers from controller state)
645
+ releaseMode: .fling, // what happens on finger lift:
646
+ // .fling — flings with velocity, falls back to .snap
647
+ // .snap — snaps forward if progress > 0.5, else reverses
648
+ // .none — stays wherever drag stopped
649
+ forceLinearScrubbing: true, // ignore motion curves while scrubbing (default: true)
650
+ onAnimationEnd: (forward) { ... }, // fires when animation completes/dismisses
651
+ child: myDraggableWidget,
652
+)
653
+```
654
+
655
+**`scrubDirection`:**
656
+- `.forward` — dragging in the positive axis direction increases progress (0→1)
657
+- `.reverse` — dragging in the positive axis direction decreases progress (1→0)
658
+- `.auto` — infers from controller state: completed/forward → reverse scrub; dismissed/reverse → forward scrub
659
+
660
+**Snap-to-final pattern** — combine with `Cue.onToggle(onEnd:)` to let Cue own the snapping and state:
661
+
662
+```dart
663
+Cue.onToggle(
664
+ toggled: _isDraggedDown,
665
+ onEnd: (forward) => setState(() => _isDraggedDown = forward),
666
+ motion: .smooth(),
667
+ child: CueDragScrubber(
668
+ distance: 250.0,
669
+ releaseMode: .fling,
670
+ child: myCard,
671
+ ),
672
+)
673
+```
674
+
675
+**Explicit controller** — skip `CueScope` when the scrubber is far from the `Cue` in the tree:
676
+
677
+```dart
678
+CueDragScrubber(
679
+ controller: _myController,
680
+ distance: 200.0,
681
+ child: handle,
682
+)
683
+```
684
+
685
+## Page/Index Controllers
686
+
687
+| Controller | Use case |
688
+|------------|----------|
689
+| `CuePageController` | `PageView` — drop-in replacement for `PageController` |
690
+| `CueTabController` | `TabBar` / `TabBarView` — drop-in for `TabController` |
691
+| `CueIndexController` | Custom index-driven sequences (carousels, steppers) |
692
+
693
+```dart
694
+final controller = CuePageController(viewportFraction: 0.8);
695
+
696
+PageView.builder(
697
+ controller: controller,
698
+ itemBuilder: (context, index) => Cue.indexed(
699
+ controller: controller,
700
+ index: index,
701
+ // During drag: scrub mode (progress mapped directly, motion shapes curve)
702
+ // After snap: play mode (motion + Actor delays produce staggered entrance)
703
+ child: Column(
704
+ children: [
705
+ Actor(acts: [.fadeIn(), .slideY(from: 0.12), .rotate(from: 4.5, reverse: .to(-4.5))], child: title),
706
+ Actor(delay: 60.ms, acts: [.scale(from: 0.96), .fadeIn()], child: card),
707
+ ],
708
+ ),
709
+ ),
710
+)
711
+```
712
+
713
+## Imperative Controllers
714
+
715
+Only reach for this when the trigger factories are insufficient.
716
+
717
+```dart
718
+late final CueController _controller;
719
+
720
+@override
721
+void initState() {
722
+ super.initState();
723
+ _controller = CueController(vsync: this, motion: .smooth());
724
+}
725
+
726
+@override
727
+void dispose() {
728
+ _controller.dispose(); // releases timeline; no need to release tracks manually
729
+ super.dispose();
730
+}
731
+
732
+@override
733
+Widget build(BuildContext context) {
734
+ return Cue(
735
+ controller: _controller,
736
+ acts: [.fadeIn(), .slideY(from: 0.2)],
737
+ child: child,
738
+ );
739
+}
740
+```
741
+
742
+For typed imperative animations outside widgets:
743
+```dart
744
+final opacity = controller.tweenTrack<double>(from: 0, to: 1);
745
+final scale = controller.keyframedTrack<double>(
746
+ frames: Keyframes([.key(0.92), .key(1.06), .key(1.0)], motion: .smooth()),
747
+);
748
+// Release when no longer needed (if controller stays alive):
749
+opacity.release();
750
+```
751
+
752
+## Debug Tools
753
+
754
+Wrap in `kDebugMode` to get a scrubber overlay for completed animations:
755
+
756
+```dart
757
+MaterialApp(
758
+ builder: (context, child) {
759
+ if (kDebugMode) return CueDebugTools(child: child!);
760
+ return child!;
761
+ },
762
+)
763
+```
764
+
765
+## Style Rules (Always Follow)
766
+
767
+- Use shorthand: `.smooth()` not `Spring.smooth()`, `.fadeIn()` not `Act.fadeIn()`
768
+- Prefer `Cue(acts: [...])` over wrapping a single child in an explicit `Actor`
769
+- Use `.act([...])` extension for single-widget inline cases
770
+- One `Cue` per coordinated group — place it high enough to cover all related `Actor`s
771
+- Compose multiple acts in one `Actor`; separate `Actor`s for different delay/motion groups
772
+- Never `Actor` without a `Cue` ancestor
773
+
774
+## Common Patterns by Use Case
775
+
776
+| Use Case | Pattern |
777
+|----------|---------|
778
+| Entrance animation | `Cue.onMount` + `.fadeIn()`, `.slideY()`, `.scale()` |
779
+| Expand/collapse | `Cue.onToggle` + `.clipHeight()`, `.fadeIn()`, `.rotate()` |
780
+| Tab/selection highlight | `Cue.onToggle` + `.sizedClip()`, `.colorTint()` |
781
+| Hover feedback | `Cue.onHover(motion: .interactive())` + `.scale(to: 1.03)` |
782
+| Context menu | `CueModalTransition` + `.sizedClip()`, `.translateFromGlobalRect()` |
783
+| Page carousel | `Cue.indexed` + `CuePageController` + `.parallax()`, `.rotate()` |
784
+| Drag-to-dismiss | `CueDragScrubber` + `Cue.onToggle(onEnd:...)` |
785
+| Scroll reveal | `Cue.onScrollVisible` + `.fadeIn()`, `.slideY()` |
786
+| Loading → done morph | `Cue.onChange` + `.sizedClip()`, `.fadeIn()`, `.zoomIn()` |
787
+| Custom value animation | `TweenActor<T>(from:, to:, builder:)` |
lib/core/components/folder_filter_bar.dart
+52
-12
@@ -16,6 +16,7 @@ class FolderFilterBar extends StatelessWidget {
16
required this.selectedFolderId,
17
required this.onFilterChanged,
18
required this.onCreateFolder,
19
+ this.onFolderAction,
20
this.isLoading = false,
21
this.showCreateFolder = true,
22
});
@@ -25,6 +26,12 @@ class FolderFilterBar extends StatelessWidget {
26
final String? selectedFolderId;
27
final ValueChanged<String?> onFilterChanged;
28
final VoidCallback onCreateFolder;
29
+
30
+ /// Called when the user invokes a secondary action on a folder chip
31
+ /// (long-press on touch devices, right-click on desktop). When `null`,
32
+ /// folder chips are not interactive for management actions.
33
+ final void Function(FolderFilterItem folder, Offset globalPosition)?
34
+ onFolderAction;
35
final bool isLoading;
36
final bool showCreateFolder;
37
@@ -62,18 +69,23 @@ class FolderFilterBar extends StatelessWidget {
69
...folders.map(
70
(folder) => Padding(
71
padding: const EdgeInsets.only(left: 6),
65
- child: FilterChip(
66
- avatar: HugeIcon(icon:
67
- HugeIcons.strokeRoundedFolder01,
68
- size: 16,
69
- color: selectedFolderId == folder.id
70
- ? theme.colorScheme.onSecondaryContainer
71
- : theme.colorScheme.primary,
72
+ child: _FolderActionWrapper(
73
+ onAction: onFolderAction == null
74
+ ? null
75
+ : (globalPos) => onFolderAction!(folder, globalPos),
76
+ child: FilterChip(
77
+ avatar: HugeIcon(icon:
78
+ HugeIcons.strokeRoundedFolder01,
79
+ size: 16,
80
+ color: selectedFolderId == folder.id
81
+ ? theme.colorScheme.onSecondaryContainer
82
+ : theme.colorScheme.primary,
83
+ ),
84
+ label: Text(folder.name),
85
+ selected: selectedFolderId == folder.id,
86
+ showCheckmark: false,
87
+ onSelected: (_) => onFilterChanged(folder.id),
88
),
73
- label: Text(folder.name),
74
- selected: selectedFolderId == folder.id,
75
- showCheckmark: false,
76
- onSelected: (_) => onFilterChanged(folder.id),
89
),
90
),
91
),
@@ -153,4 +165,32 @@ class SystemFolderFilterChip extends StatelessWidget {
165
onSelected: onSelected,
166
);
167
}
156
-}
\ No newline at end of file
168
+}
169
+
170
+/// Wraps a folder chip with secondary-action gestures (long-press on touch
171
+/// devices, right-click on desktop/web). When [onAction] is null the wrapper
172
+/// is a no-op pass-through so callers can disable the feature cheaply.
173
+class _FolderActionWrapper extends StatelessWidget {
174
+ const _FolderActionWrapper({required this.child, required this.onAction});
175
+
176
+ final Widget child;
177
+ final void Function(Offset globalPosition)? onAction;
178
+
179
+ @override
180
+ Widget build(BuildContext context) {
181
+ if (onAction == null) return child;
182
+ return GestureDetector(
183
+ // Translucent lets taps fall through to the FilterChip so the primary
184
+ // selection callback still fires. Only long-press and secondary tap
185
+ // are claimed by this detector.
186
+ behavior: HitTestBehavior.translucent,
187
+ // Long-press for touch: standard mobile affordance.
188
+ onLongPressStart: (details) => onAction!(details.globalPosition),
189
+ // Secondary (right-click) tap for desktop, web and macOS trackpad.
190
+ onSecondaryTapDown: (details) => onAction!(details.globalPosition),
191
+ // Tap is intentionally not intercepted — the FilterChip's own tap
192
+ // continues to fire, so primary selection still works.
193
+ child: child,
194
+ );
195
+ }
196
+}
lib/core/components/folder_management_dialogs.dart
new
+99
@@ -0,0 +1,99 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/l10n/app_localizations.dart';
3
+
4
+/// Generic helpers used by both chat-history folders and saved-message folders
5
+/// to rename or delete a folder. The caller is responsible for invoking the
6
+/// underlying repository method on confirmation.
7
+
8
+/// Shows a dialog asking the user to enter a new folder name. Returns the
9
+/// trimmed new name, or `null` if cancelled. Rejects empty names.
10
+Future<String?> showRenameFolderDialog(
11
+ BuildContext context, {
12
+ required String currentName,
13
+}) async {
14
+ final l10n = AppLocalizations.of(context)!;
15
+ final controller = TextEditingController(text: currentName);
16
+ controller.selection = TextSelection(
17
+ baseOffset: 0,
18
+ extentOffset: currentName.length,
19
+ );
20
+ String? errorText;
21
+
22
+ final result = await showDialog<String>(
23
+ context: context,
24
+ builder: (ctx) {
25
+ return StatefulBuilder(
26
+ builder: (ctx, setState) {
27
+ return AlertDialog(
28
+ title: Text(l10n.rename_folder),
29
+ content: TextField(
30
+ controller: controller,
31
+ autofocus: true,
32
+ decoration: InputDecoration(
33
+ hintText: l10n.folder_name_hint,
34
+ errorText: errorText,
35
+ ),
36
+ onSubmitted: (_) {
37
+ final value = controller.text.trim();
38
+ if (value.isEmpty) {
39
+ setState(() => errorText = l10n.folder_name_required);
40
+ return;
41
+ }
42
+ Navigator.pop(ctx, value);
43
+ },
44
+ ),
45
+ actions: [
46
+ TextButton(
47
+ onPressed: () => Navigator.pop(ctx),
48
+ child: Text(l10n.cancel),
49
+ ),
50
+ TextButton(
51
+ onPressed: () {
52
+ final value = controller.text.trim();
53
+ if (value.isEmpty) {
54
+ setState(() => errorText = l10n.folder_name_required);
55
+ return;
56
+ }
57
+ Navigator.pop(ctx, value);
58
+ },
59
+ child: Text(l10n.save),
60
+ ),
61
+ ],
62
+ );
63
+ },
64
+ );
65
+ },
66
+ );
67
+ controller.dispose();
68
+ return result;
69
+}
70
+
71
+/// Shows a confirmation dialog asking the user whether they really want to
72
+/// delete the folder. Returns `true` if confirmed, `false` (or `null`) otherwise.
73
+Future<bool> showDeleteFolderConfirmation(
74
+ BuildContext context, {
75
+ required String folderName,
76
+}) async {
77
+ final l10n = AppLocalizations.of(context)!;
78
+ final result = await showDialog<bool>(
79
+ context: context,
80
+ builder: (ctx) {
81
+ return AlertDialog(
82
+ title: Text(l10n.delete_folder_title),
83
+ content: Text(l10n.delete_folder_body(folderName)),
84
+ actions: [
85
+ TextButton(
86
+ onPressed: () => Navigator.pop(ctx, false),
87
+ child: Text(l10n.cancel),
88
+ ),
89
+ TextButton(
90
+ onPressed: () => Navigator.pop(ctx, true),
91
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
92
+ child: Text(l10n.delete),
93
+ ),
94
+ ],
95
+ );
96
+ },
97
+ );
98
+ return result ?? false;
99
+}
\ No newline at end of file
lib/features/chat/views/components/chat_bubble/animated_bubble.dart
+17
-22
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:flutter/material.dart';
3
4
class AnimatedBubble extends StatelessWidget {
@@ -9,29 +10,23 @@ class AnimatedBubble extends StatelessWidget {
10
@override
11
Widget build(BuildContext context) {
12
final resolvedAlignment = alignment.resolve(Directionality.of(context));
12
- final offset = resolvedAlignment == Alignment.centerRight
13
- ? const Offset(0.15, 0.0)
14
- : resolvedAlignment == Alignment.centerLeft
15
- ? const Offset(-0.15, 0.0)
16
- : const Offset(0.0, 0.1);
13
+ final isUserRight = resolvedAlignment == Alignment.centerRight;
14
+ final isAssistantLeft = resolvedAlignment == Alignment.centerLeft;
15
18
- return TweenAnimationBuilder<double>(
19
- tween: Tween(begin: 0.0, end: 1.0),
20
- duration: const Duration(milliseconds: 250),
21
- curve: Curves.easeOutCubic,
22
- builder: (context, value, child) {
23
- return Opacity(
24
- opacity: value,
25
- child: Transform.translate(
26
- offset: Offset(
27
- offset.dx * (1 - value) * 50,
28
- offset.dy * (1 - value) * 30,
29
- ),
30
- child: child,
31
- ),
32
- );
33
- },
16
+ final Offset translateOffset = isUserRight
17
+ ? const Offset(12, 0)
18
+ : isAssistantLeft
19
+ ? const Offset(-12, 0)
20
+ : const Offset(0, 8);
21
+
22
+ return Cue.onMount(
23
+ motion: .smooth(),
24
+ acts: [
25
+ .fadeIn(),
26
+ .translate(from: translateOffset),
27
+ .scale(from: 0.98),
28
+ ],
29
child: child,
30
);
31
}
37
-}
32
+}
\ No newline at end of file
lib/features/chat/views/components/message_list/empty_state.dart
+123
-186
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'dart:async';
4
@@ -34,52 +35,13 @@ class EmptyState extends StatefulWidget {
35
State<EmptyState> createState() => _EmptyStateState();
36
}
37
37
-class _EmptyStateState extends State<EmptyState>
38
- with SingleTickerProviderStateMixin {
39
- late AnimationController _controller;
40
- late List<Animation<double>> _fadeAnimations;
41
- late List<Animation<Offset>> _slideAnimations;
38
+class _EmptyStateState extends State<EmptyState> {
39
Timer? _welcomeTimer;
40
int _welcomeIndex = 0;
41
42
@override
43
void initState() {
44
super.initState();
48
- _controller = AnimationController(
49
- duration: const Duration(milliseconds: 500),
50
- vsync: this,
51
- );
52
-
53
- _fadeAnimations = List.generate(widget.quickPrompts.length, (index) {
54
- return Tween<double>(begin: 0.0, end: 1.0).animate(
55
- CurvedAnimation(
56
- parent: _controller,
57
- curve: Interval(
58
- (index * 0.1).clamp(0.0, 0.7),
59
- (0.3 + index * 0.1).clamp(0.2, 1.0),
60
- curve: Curves.easeOut,
61
- ),
62
- ),
63
- );
64
- });
65
-
66
- _slideAnimations = List.generate(widget.quickPrompts.length, (index) {
67
- return Tween<Offset>(
68
- begin: const Offset(0.0, 0.3),
69
- end: Offset.zero,
70
- ).animate(
71
- CurvedAnimation(
72
- parent: _controller,
73
- curve: Interval(
74
- (index * 0.1).clamp(0.0, 0.7),
75
- (0.3 + index * 0.1).clamp(0.2, 1.0),
76
- curve: Curves.easeOutCubic,
77
- ),
78
- ),
79
- );
80
- });
81
-
82
- _controller.forward();
45
_welcomeTimer = Timer.periodic(const Duration(seconds: 5), (_) {
46
if (!mounted) return;
47
setState(() => _welcomeIndex = (_welcomeIndex + 1) % 4);
@@ -89,7 +51,6 @@ class _EmptyStateState extends State<EmptyState>
51
@override
52
void dispose() {
53
_welcomeTimer?.cancel();
92
- _controller.dispose();
54
super.dispose();
55
}
56
@@ -106,43 +67,58 @@ class _EmptyStateState extends State<EmptyState>
67
final theme = Theme.of(context);
68
final isDark = theme.brightness == Brightness.dark;
69
109
- return SingleChildScrollView(
110
- padding: const EdgeInsets.fromLTRB(32, 8, 32, 24),
111
- child: Column(
112
- crossAxisAlignment: CrossAxisAlignment.center,
113
- children: [
114
- SizedBox(
115
- height: 72,
116
- child: Center(
117
- child: AnimatedSwitcher(
118
- duration: const Duration(milliseconds: 400),
119
- layoutBuilder: (current, previous) => Stack(
120
- alignment: Alignment.center,
121
- children: [...previous, ?current],
122
- ),
123
- child: Text(
124
- _welcomeMessages(l10n)[_welcomeIndex],
125
- key: ValueKey(_welcomeIndex),
126
- textAlign: TextAlign.center,
127
- maxLines: 2,
128
- overflow: TextOverflow.ellipsis,
129
- style: theme.textTheme.headlineSmall?.copyWith(
130
- fontWeight: FontWeight.w600,
131
- color: isDark ? Colors.white : Colors.black87,
70
+ return Cue.onMount(
71
+ motion: .smooth(),
72
+ child: SingleChildScrollView(
73
+ padding: const EdgeInsets.fromLTRB(32, 8, 32, 24),
74
+ child: Column(
75
+ crossAxisAlignment: CrossAxisAlignment.center,
76
+ children: [
77
+ SizedBox(
78
+ height: 72,
79
+ child: Center(
80
+ child: AnimatedSwitcher(
81
+ duration: const Duration(milliseconds: 400),
82
+ layoutBuilder: (current, previous) => Stack(
83
+ alignment: Alignment.center,
84
+ children: [...previous, ?current],
85
+ ),
86
+ child: Text(
87
+ _welcomeMessages(l10n)[_welcomeIndex],
88
+ key: ValueKey(_welcomeIndex),
89
+ textAlign: TextAlign.center,
90
+ maxLines: 2,
91
+ overflow: TextOverflow.ellipsis,
92
+ style: theme.textTheme.headlineSmall?.copyWith(
93
+ fontWeight: FontWeight.w600,
94
+ color: isDark ? Colors.white : Colors.black87,
95
+ ),
96
),
97
),
98
),
99
),
136
- ),
137
- const SizedBox(height: 8),
138
- _buildModelAndPersonaSelectors(isDark, theme, l10n),
139
- const SizedBox(height: 24),
140
- _buildQuickPrompts(isDark),
141
- if (widget.recentConversations.isNotEmpty) ...[
142
- const SizedBox(height: 28),
143
- _buildRecentSection(isDark, l10n),
100
+ const SizedBox(height: 8),
101
+ Actor(
102
+ delay: 80.ms,
103
+ acts: [.fadeIn(), .slideY(from: 0.1)],
104
+ child: _buildModelAndPersonaSelectors(isDark, theme, l10n),
105
+ ),
106
+ const SizedBox(height: 24),
107
+ Actor(
108
+ delay: 160.ms,
109
+ acts: [.fadeIn(), .slideY(from: 0.12)],
110
+ child: _buildQuickPrompts(isDark),
111
+ ),
112
+ if (widget.recentConversations.isNotEmpty) ...[
113
+ const SizedBox(height: 28),
114
+ Actor(
115
+ delay: 240.ms,
116
+ acts: [.fadeIn(), .slideY(from: 0.08)],
117
+ child: _buildRecentSection(isDark, l10n),
118
+ ),
119
+ ],
120
],
145
- ],
121
+ ),
122
),
123
);
124
}
@@ -152,35 +128,74 @@ class _EmptyStateState extends State<EmptyState>
128
ThemeData theme,
129
AppLocalizations l10n,
130
) {
155
- return TweenAnimationBuilder<double>(
156
- tween: Tween(begin: 0.0, end: 1.0),
157
- duration: const Duration(milliseconds: 300),
158
- curve: Curves.easeOut,
159
- builder: (context, value, child) {
160
- return Opacity(
161
- opacity: value,
162
- child: Transform.translate(
163
- offset: Offset(0, 20 * (1 - value)),
164
- child: child,
131
+ return Column(
132
+ children: [
133
+ GestureDetector(
134
+ onTap: widget.onModelTap,
135
+ child: _buildSelectorCard(
136
+ isDark: isDark,
137
+ theme: theme,
138
+ child: Row(
139
+ mainAxisSize: MainAxisSize.min,
140
+ children: [
141
+ HugeIcon(icon: HugeIcons.strokeRoundedSettings01,
142
+ size: 18, color: theme.colorScheme.primary),
143
+ const SizedBox(width: 8),
144
+ Flexible(
145
+ child: Text(
146
+ widget.selectedModel?.displayName ?? l10n.select_model,
147
+ style: TextStyle(
148
+ fontSize: 15,
149
+ fontWeight: FontWeight.w600,
150
+ color: isDark ? Colors.white : Colors.black,
151
+ ),
152
+ maxLines: 2,
153
+ overflow: TextOverflow.ellipsis,
154
+ ),
155
+ ),
156
+ const SizedBox(width: 4),
157
+ const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
158
+ ],
159
+ ),
160
),
166
- );
167
- },
168
- child: Column(
169
- children: [
170
- GestureDetector(
171
- onTap: widget.onModelTap,
172
- child: _buildSelectorCard(
173
- isDark: isDark,
174
- theme: theme,
175
- child: Row(
176
- mainAxisSize: MainAxisSize.min,
177
- children: [
178
- HugeIcon(icon: HugeIcons.strokeRoundedSettings01,
161
+ ),
162
+ const SizedBox(height: 12),
163
+ GestureDetector(
164
+ onTap: widget.onPersonaTap,
165
+ child: _buildSelectorCard(
166
+ isDark: isDark,
167
+ theme: theme,
168
+ child: Row(
169
+ mainAxisSize: widget.selectedPersonas.isEmpty
170
+ ? MainAxisSize.min
171
+ : MainAxisSize.max,
172
+ children: [
173
+ if (widget.selectedPersonas.isNotEmpty) ...[
174
+ Flexible(
175
+ child: Wrap(
176
+ spacing: 4,
177
+ runSpacing: 4,
178
+ children: widget.selectedPersonas.map((persona) {
179
+ return Text(
180
+ '${persona.emoji} ${persona.name}',
181
+ style: TextStyle(
182
+ fontSize: 14,
183
+ fontWeight: FontWeight.w600,
184
+ color: isDark ? Colors.white : Colors.black,
185
+ ),
186
+ maxLines: 1,
187
+ overflow: TextOverflow.ellipsis,
188
+ );
189
+ }).toList(),
190
+ ),
191
+ ),
192
+ ] else ...[
193
+ HugeIcon(icon: HugeIcons.strokeRoundedRobot01,
194
size: 18, color: theme.colorScheme.primary),
195
const SizedBox(width: 8),
196
Flexible(
197
child: Text(
183
- widget.selectedModel?.displayName ?? l10n.select_model,
198
+ l10n.select_persona,
199
style: TextStyle(
200
fontSize: 15,
201
fontWeight: FontWeight.w600,
@@ -190,67 +205,14 @@ class _EmptyStateState extends State<EmptyState>
205
overflow: TextOverflow.ellipsis,
206
),
207
),
193
- const SizedBox(width: 4),
194
- const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
208
],
196
- ),
209
+ const SizedBox(width: 4),
210
+ const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
211
+ ],
212
),
213
),
199
- const SizedBox(height: 12),
200
- GestureDetector(
201
- onTap: widget.onPersonaTap,
202
- child: _buildSelectorCard(
203
- isDark: isDark,
204
- theme: theme,
205
- child: Row(
206
- mainAxisSize: widget.selectedPersonas.isEmpty
207
- ? MainAxisSize.min
208
- : MainAxisSize.max,
209
- children: [
210
- if (widget.selectedPersonas.isNotEmpty) ...[
211
- Flexible(
212
- child: Wrap(
213
- spacing: 4,
214
- runSpacing: 4,
215
- children: widget.selectedPersonas.map((persona) {
216
- return Text(
217
- '${persona.emoji} ${persona.name}',
218
- style: TextStyle(
219
- fontSize: 14,
220
- fontWeight: FontWeight.w600,
221
- color: isDark ? Colors.white : Colors.black,
222
- ),
223
- maxLines: 1,
224
- overflow: TextOverflow.ellipsis,
225
- );
226
- }).toList(),
227
- ),
228
- ),
229
- ] else ...[
230
- HugeIcon(icon: HugeIcons.strokeRoundedRobot01,
231
- size: 18, color: theme.colorScheme.primary),
232
- const SizedBox(width: 8),
233
- Flexible(
234
- child: Text(
235
- l10n.select_persona,
236
- style: TextStyle(
237
- fontSize: 15,
238
- fontWeight: FontWeight.w600,
239
- color: isDark ? Colors.white : Colors.black,
240
- ),
241
- maxLines: 2,
242
- overflow: TextOverflow.ellipsis,
243
- ),
244
- ),
245
- ],
246
- const SizedBox(width: 4),
247
- const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
248
- ],
249
- ),
250
- ),
251
- ),
252
- ],
253
- ),
214
+ ),
215
+ ],
216
);
217
}
218
@@ -280,24 +242,9 @@ class _EmptyStateState extends State<EmptyState>
242
children: widget.quickPrompts.asMap().entries.map((entry) {
243
final index = entry.key;
244
final prompt = entry.value;
283
- final fadeAnimation = index < _fadeAnimations.length
284
- ? _fadeAnimations[index]
285
- : const AlwaysStoppedAnimation(1.0);
286
- final slideAnimation = index < _slideAnimations.length
287
- ? _slideAnimations[index]
288
- : const AlwaysStoppedAnimation(Offset.zero);
289
-
290
- return AnimatedBuilder(
291
- animation: _controller,
292
- builder: (context, child) {
293
- return FadeTransition(
294
- opacity: fadeAnimation,
295
- child: SlideTransition(
296
- position: slideAnimation,
297
- child: child,
298
- ),
299
- );
300
- },
245
+ return Actor(
246
+ delay: (180 + index * 35).ms,
247
+ acts: [.fadeIn(), .scale(from: 0.96), .slideY(from: 0.08)],
248
child: ActionChip(
249
label: Text(prompt),
250
onPressed: () => widget.onQuickPrompt(prompt),
@@ -344,19 +291,9 @@ class _EmptyStateState extends State<EmptyState>
291
const SizedBox(height: 12),
292
...List.generate(widget.recentConversations.take(5).length, (index) {
293
final conv = widget.recentConversations.take(5).toList()[index];
347
- return TweenAnimationBuilder<double>(
348
- tween: Tween(begin: 0.0, end: 1.0),
349
- duration: Duration(milliseconds: 400 + (index * 50)),
350
- curve: Curves.easeOutCubic,
351
- builder: (context, value, child) {
352
- return Opacity(
353
- opacity: value,
354
- child: Transform.translate(
355
- offset: Offset(0, 10 * (1 - value)),
356
- child: child,
357
- ),
358
- );
359
- },
294
+ return Actor(
295
+ delay: (260 + index * 50).ms,
296
+ acts: [.fadeIn(), .slideY(from: 0.06)],
297
child: Padding(
298
padding: const EdgeInsets.only(bottom: 8),
299
child: RecentConversationItem(conversation: conv),
lib/features/conversations/providers/conversation_providers.dart
+13
@@ -859,6 +859,19 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
859
return folder;
860
}
861
862
+ Future<void> renameFolder(String id, String name) async {
863
+ final db = ref.read(databaseProvider);
864
+ final query = db.conversationFolderBox
865
+ .query(ConversationFolderEntity_.id.equals(id))
866
+ .build();
867
+ final entity = query.findFirst();
868
+ query.close();
869
+ if (entity == null) return;
870
+ entity.name = name;
871
+ db.conversationFolderBox.put(entity);
872
+ state = AsyncData(await _loadAll());
873
+ }
874
+
875
Future<void> deleteFolder(String id) async {
876
final db = ref.read(databaseProvider);
877
final query = db.conversationFolderBox
lib/features/conversations/views/components/conversation_empty_state.dart
+52
-31
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'package:flutter/material.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
@@ -16,39 +17,59 @@ class ConversationEmptyState extends StatelessWidget {
17
final theme = Theme.of(context);
18
final isDark = theme.brightness == Brightness.dark;
19
19
- return Center(
20
- child: Padding(
21
- padding: const EdgeInsets.all(32),
22
- child: Column(
23
- mainAxisAlignment: MainAxisAlignment.center,
24
- children: [
25
- HugeIcon(icon:
26
- isSearching ? HugeIcons.strokeRoundedSearch01 : HugeIcons.strokeRoundedChatting01,
27
- size: 48,
28
- color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
29
- ),
30
- const SizedBox(height: 16),
31
- Text(
32
- isSearching ? l10n.no_results_found : l10n.no_conversations_yet,
33
- style: TextStyle(
34
- fontSize: 16,
35
- fontWeight: FontWeight.w500,
36
- color: isDark ? Colors.white : Colors.black,
20
+ return Cue.onMount(
21
+ motion: .smooth(),
22
+ child: Center(
23
+ child: Padding(
24
+ padding: const EdgeInsets.all(32),
25
+ child: Column(
26
+ mainAxisAlignment: MainAxisAlignment.center,
27
+ children: [
28
+ Actor(
29
+ acts: [.fadeIn(), .scale(from: 0.85)],
30
+ child: HugeIcon(
31
+ icon: isSearching
32
+ ? HugeIcons.strokeRoundedSearch01
33
+ : HugeIcons.strokeRoundedChatting01,
34
+ size: 48,
35
+ color: isDark
36
+ ? const Color(0xFF3A3A3A)
37
+ : const Color(0xFFE5E5E5),
38
+ ),
39
),
38
- ),
39
- const SizedBox(height: 8),
40
- Text(
41
- isSearching
42
- ? l10n.try_different_search
43
- : l10n.start_new_conversation,
44
- style: TextStyle(
45
- fontSize: 14,
46
- color: isDark
47
- ? const Color(0xFF888888)
48
- : const Color(0xFF666666),
40
+ const SizedBox(height: 16),
41
+ Actor(
42
+ delay: 80.ms,
43
+ acts: [.fadeIn(), .slideY(from: 0.08)],
44
+ child: Text(
45
+ isSearching
46
+ ? l10n.no_results_found
47
+ : l10n.no_conversations_yet,
48
+ style: TextStyle(
49
+ fontSize: 16,
50
+ fontWeight: FontWeight.w500,
51
+ color: isDark ? Colors.white : Colors.black,
52
+ ),
53
+ ),
54
),
50
- ),
51
- ],
55
+ const SizedBox(height: 8),
56
+ Actor(
57
+ delay: 140.ms,
58
+ acts: [.fadeIn(), .slideY(from: 0.06)],
59
+ child: Text(
60
+ isSearching
61
+ ? l10n.try_different_search
62
+ : l10n.start_new_conversation,
63
+ style: TextStyle(
64
+ fontSize: 14,
65
+ color: isDark
66
+ ? const Color(0xFF888888)
67
+ : const Color(0xFF666666),
68
+ ),
69
+ ),
70
+ ),
71
+ ],
72
+ ),
73
),
74
),
75
);
lib/features/conversations/views/components/conversation_folder_bar.dart
+87
@@ -1,6 +1,8 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
import 'package:localmind/core/components/folder_filter_bar.dart';
5
+import 'package:localmind/core/components/folder_management_dialogs.dart';
6
import 'package:localmind/l10n/app_localizations.dart';
7
import '../../providers/conversation_providers.dart';
8
@@ -36,6 +38,87 @@ class ConversationFolderBar extends ConsumerWidget {
38
await ref.read(conversationFoldersProvider.notifier).createFolder(name);
39
}
40
41
+ Future<void> _showFolderActions(
42
+ BuildContext context,
43
+ WidgetRef ref,
44
+ FolderFilterItem folder,
45
+ Offset tapPosition,
46
+ ) async {
47
+ final l10n = AppLocalizations.of(context)!;
48
+ final overlay =
49
+ Overlay.of(context).context.findRenderObject() as RenderBox?;
50
+ // tapPosition is in global screen coordinates; showMenu's RelativeRect is
51
+ // expressed relative to the overlay, so translate it.
52
+ final relativePosition = overlay != null
53
+ ? overlay.globalToLocal(tapPosition)
54
+ : tapPosition;
55
+
56
+ final action = await showMenu<_FolderAction>(
57
+ context: context,
58
+ position: RelativeRect.fromLTRB(
59
+ relativePosition.dx,
60
+ relativePosition.dy,
61
+ // Keep the menu near the cursor on the right edge.
62
+ overlay != null ? overlay.size.width - relativePosition.dx : 0,
63
+ overlay != null ? overlay.size.height - relativePosition.dy : 0,
64
+ ),
65
+ items: [
66
+ PopupMenuItem(
67
+ value: _FolderAction.rename,
68
+ child: Row(
69
+ children: [
70
+ const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
71
+ const SizedBox(width: 12),
72
+ Text(l10n.rename_folder),
73
+ ],
74
+ ),
75
+ ),
76
+ PopupMenuItem(
77
+ value: _FolderAction.delete,
78
+ child: Row(
79
+ children: [
80
+ const HugeIcon(
81
+ icon: HugeIcons.strokeRoundedDelete01,
82
+ color: Colors.red,
83
+ ),
84
+ const SizedBox(width: 12),
85
+ Text(
86
+ l10n.delete_folder,
87
+ style: const TextStyle(color: Colors.red),
88
+ ),
89
+ ],
90
+ ),
91
+ ),
92
+ ],
93
+ );
94
+
95
+ if (action == null || !context.mounted) return;
96
+
97
+ final notifier = ref.read(conversationFoldersProvider.notifier);
98
+ switch (action) {
99
+ case _FolderAction.rename:
100
+ final newName = await showRenameFolderDialog(
101
+ context,
102
+ currentName: folder.name,
103
+ );
104
+ if (newName == null || newName == folder.name) return;
105
+ await notifier.renameFolder(folder.id, newName);
106
+ case _FolderAction.delete:
107
+ final confirmed = await showDeleteFolderConfirmation(
108
+ context,
109
+ folderName: folder.name,
110
+ );
111
+ if (!confirmed) return;
112
+ // If the user is currently filtering by the folder being deleted,
113
+ // reset to "All" so the conversation list doesn't suddenly disappear.
114
+ final filter = ref.read(historyFolderFilterProvider);
115
+ if (filter == folder.id) {
116
+ ref.read(historyFolderFilterProvider.notifier).setFilter(null);
117
+ }
118
+ await notifier.deleteFolder(folder.id);
119
+ }
120
+ }
121
+
122
@override
123
Widget build(BuildContext context, WidgetRef ref) {
124
final foldersAsync = ref.watch(conversationFoldersProvider);
@@ -50,9 +133,13 @@ class ConversationFolderBar extends ConsumerWidget {
133
onFilterChanged: (id) =>
134
ref.read(historyFolderFilterProvider.notifier).setFilter(id),
135
onCreateFolder: () => _createFolder(context, ref),
136
+ onFolderAction: (folder, pos) =>
137
+ _showFolderActions(context, ref, folder, pos),
138
),
139
loading: () => const SizedBox(height: 44),
140
error: (_, _) => const SizedBox.shrink(),
141
);
142
}
143
}
144
+
145
+enum _FolderAction { rename, delete }
lib/features/conversations/views/components/conversation_list.dart
+65
-58
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -59,68 +60,74 @@ class ConversationList extends ConsumerWidget {
60
return Column(
61
crossAxisAlignment: CrossAxisAlignment.start,
62
children: [
62
- DateSectionHeader(title: section),
63
+ Cue.onScrollVisible(
64
+ acts: [.fadeIn(), .slideY(from: 0.05)],
65
+ child: DateSectionHeader(title: section),
66
+ ),
67
...conversations.map((conversation) {
64
- return ConversationTile(
65
- conversation: conversation,
66
- isActive: activeConversation?.id == conversation.id,
67
- selectionMode: selectionMode,
68
- isSelected: selectedIds.contains(conversation.id),
69
- onEnterSelectionMode: () {
70
- ref.read(historySelectionModeProvider.notifier).enable();
71
- ref
72
- .read(historySelectedIdsProvider.notifier)
73
- .toggle(conversation.id);
74
- },
75
- onTap: () {
76
- if (selectionMode) {
68
+ return Cue.onScrollVisible(
69
+ acts: [.fadeIn(), .slideX(from: -0.04)],
70
+ child: ConversationTile(
71
+ conversation: conversation,
72
+ isActive: activeConversation?.id == conversation.id,
73
+ selectionMode: selectionMode,
74
+ isSelected: selectedIds.contains(conversation.id),
75
+ onEnterSelectionMode: () {
76
+ ref.read(historySelectionModeProvider.notifier).enable();
77
ref
78
.read(historySelectedIdsProvider.notifier)
79
.toggle(conversation.id);
80
- return;
81
- }
82
- ref
83
- .read(chatProvider.notifier)
84
- .loadConversation(conversation);
85
- ref
86
- .read(chatOriginProvider.notifier)
87
- .set(ChatOrigin.history);
88
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
89
- Navigator.pop(context);
90
- }
91
- context.go(AppRoutes.home);
92
- },
93
- onRename: () {
94
- _showRenameDialog(context, ref, l10n, conversation);
95
- },
96
- onTogglePin: () {
97
- ref
98
- .read(conversationsProvider.notifier)
99
- .togglePin(conversation.id);
100
- },
101
- onDelete: () {
102
- _showDeleteConfirmation(context, ref, l10n, conversation);
103
- },
104
- onDuplicate: () {
105
- ref
106
- .read(conversationsProvider.notifier)
107
- .duplicateConversation(conversation.id);
108
- ScaffoldMessenger.of(context).showSnackBar(
109
- SnackBar(content: Text(l10n.duplicate_chat_success)),
110
- );
111
- },
112
- onMoveToFolder: () {
113
- showMoveToFolderSheet(context, ref, l10n, conversation);
114
- },
115
- onExport: () {
116
- _exportConversation(context, ref, l10n, conversation);
117
- },
118
- onArchive: () {
119
- ref.read(conversationsProvider.notifier).setArchived(
120
- conversation.id,
121
- !conversation.isArchived,
122
- );
123
- },
80
+ },
81
+ onTap: () {
82
+ if (selectionMode) {
83
+ ref
84
+ .read(historySelectedIdsProvider.notifier)
85
+ .toggle(conversation.id);
86
+ return;
87
+ }
88
+ ref
89
+ .read(chatProvider.notifier)
90
+ .loadConversation(conversation);
91
+ ref
92
+ .read(chatOriginProvider.notifier)
93
+ .set(ChatOrigin.history);
94
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
95
+ Navigator.pop(context);
96
+ }
97
+ context.go(AppRoutes.home);
98
+ },
99
+ onRename: () {
100
+ _showRenameDialog(context, ref, l10n, conversation);
101
+ },
102
+ onTogglePin: () {
103
+ ref
104
+ .read(conversationsProvider.notifier)
105
+ .togglePin(conversation.id);
106
+ },
107
+ onDelete: () {
108
+ _showDeleteConfirmation(context, ref, l10n, conversation);
109
+ },
110
+ onDuplicate: () {
111
+ ref
112
+ .read(conversationsProvider.notifier)
113
+ .duplicateConversation(conversation.id);
114
+ ScaffoldMessenger.of(context).showSnackBar(
115
+ SnackBar(content: Text(l10n.duplicate_chat_success)),
116
+ );
117
+ },
118
+ onMoveToFolder: () {
119
+ showMoveToFolderSheet(context, ref, l10n, conversation);
120
+ },
121
+ onExport: () {
122
+ _exportConversation(context, ref, l10n, conversation);
123
+ },
124
+ onArchive: () {
125
+ ref.read(conversationsProvider.notifier).setArchived(
126
+ conversation.id,
127
+ !conversation.isArchived,
128
+ );
129
+ },
130
+ ),
131
);
132
}),
133
],
lib/features/models/views/model_picker_sheet.dart
+5
-1
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -95,7 +96,9 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
96
maxChildSize: 1.0,
97
expand: false,
98
builder: (context, scrollController) {
98
- return Container(
99
+ return Cue.onMount(
100
+ motion: .smooth(),
101
+ child: Container(
102
padding: const EdgeInsets.all(16),
103
decoration: BoxDecoration(
104
color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
@@ -179,6 +182,7 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
182
),
183
],
184
),
185
+ ),
186
);
187
},
188
);
lib/features/saved_messages/providers/saved_message_providers.dart
+13
@@ -116,6 +116,19 @@ class SavedMessageFoldersNotifier
116
return folder;
117
}
118
119
+ Future<void> renameFolder(String id, String name) async {
120
+ final db = ref.read(databaseProvider);
121
+ final folderQuery = db.savedMessageFolderBox
122
+ .query(SavedMessageFolderEntity_.id.equals(id))
123
+ .build();
124
+ final entity = folderQuery.findFirst();
125
+ folderQuery.close();
126
+ if (entity == null) return;
127
+ entity.name = name;
128
+ db.savedMessageFolderBox.put(entity);
129
+ state = AsyncData(await _loadAll());
130
+ }
131
+
132
Future<void> deleteFolder(String id) async {
133
final db = ref.read(databaseProvider);
134
final folderQuery = db.savedMessageFolderBox
lib/features/saved_messages/views/components/saved_message_folder_bar.dart
+82
@@ -1,6 +1,8 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
import 'package:localmind/core/components/folder_filter_bar.dart';
5
+import 'package:localmind/core/components/folder_management_dialogs.dart';
6
import 'package:localmind/l10n/app_localizations.dart';
7
import '../../providers/saved_message_providers.dart';
8
@@ -38,6 +40,82 @@ class SavedMessageFolderBar extends ConsumerWidget {
40
await ref.read(savedMessageFoldersProvider.notifier).createFolder(name);
41
}
42
43
+ Future<void> _showFolderActions(
44
+ BuildContext context,
45
+ WidgetRef ref,
46
+ FolderFilterItem folder,
47
+ Offset tapPosition,
48
+ ) async {
49
+ final l10n = AppLocalizations.of(context)!;
50
+ final overlay =
51
+ Overlay.of(context).context.findRenderObject() as RenderBox?;
52
+ final relativePosition = overlay != null
53
+ ? overlay.globalToLocal(tapPosition)
54
+ : tapPosition;
55
+
56
+ final action = await showMenu<_FolderAction>(
57
+ context: context,
58
+ position: RelativeRect.fromLTRB(
59
+ relativePosition.dx,
60
+ relativePosition.dy,
61
+ overlay != null ? overlay.size.width - relativePosition.dx : 0,
62
+ overlay != null ? overlay.size.height - relativePosition.dy : 0,
63
+ ),
64
+ items: [
65
+ PopupMenuItem(
66
+ value: _FolderAction.rename,
67
+ child: Row(
68
+ children: [
69
+ const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
70
+ const SizedBox(width: 12),
71
+ Text(l10n.rename_folder),
72
+ ],
73
+ ),
74
+ ),
75
+ PopupMenuItem(
76
+ value: _FolderAction.delete,
77
+ child: Row(
78
+ children: [
79
+ const HugeIcon(
80
+ icon: HugeIcons.strokeRoundedDelete01,
81
+ color: Colors.red,
82
+ ),
83
+ const SizedBox(width: 12),
84
+ Text(
85
+ l10n.delete_folder,
86
+ style: const TextStyle(color: Colors.red),
87
+ ),
88
+ ],
89
+ ),
90
+ ),
91
+ ],
92
+ );
93
+
94
+ if (action == null || !context.mounted) return;
95
+
96
+ final notifier = ref.read(savedMessageFoldersProvider.notifier);
97
+ switch (action) {
98
+ case _FolderAction.rename:
99
+ final newName = await showRenameFolderDialog(
100
+ context,
101
+ currentName: folder.name,
102
+ );
103
+ if (newName == null || newName == folder.name) return;
104
+ await notifier.renameFolder(folder.id, newName);
105
+ case _FolderAction.delete:
106
+ final confirmed = await showDeleteFolderConfirmation(
107
+ context,
108
+ folderName: folder.name,
109
+ );
110
+ if (!confirmed) return;
111
+ final filter = ref.read(savedMessageFolderFilterProvider);
112
+ if (filter == folder.id) {
113
+ ref.read(savedMessageFolderFilterProvider.notifier).setFilter(null);
114
+ }
115
+ await notifier.deleteFolder(folder.id);
116
+ }
117
+ }
118
+
119
@override
120
Widget build(BuildContext context, WidgetRef ref) {
121
final foldersAsync = ref.watch(savedMessageFoldersProvider);
@@ -53,9 +131,13 @@ class SavedMessageFolderBar extends ConsumerWidget {
131
ref.read(savedMessageFolderFilterProvider.notifier).setFilter(id),
132
onCreateFolder: () => _createFolder(context, ref),
133
showCreateFolder: showCreateFolder,
134
+ onFolderAction: (folder, pos) =>
135
+ _showFolderActions(context, ref, folder, pos),
136
),
137
loading: () => const SizedBox(height: 44),
138
error: (_, _) => const SizedBox.shrink(),
139
);
140
}
141
}
142
+
143
+enum _FolderAction { rename, delete }
lib/features/settings/views/settings_screen.dart
+88
-64
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -584,76 +585,99 @@ class _SettingsHero extends StatelessWidget {
585
final theme = Theme.of(context);
586
final primary = _primaryColor(context);
587
587
- return Container(
588
- decoration: BoxDecoration(
589
- color: _surfaceColor(context),
590
- borderRadius: BorderRadius.circular(20),
591
- border: Border.all(color: _outlineColor(context, alpha: 0.9)),
592
- ),
593
- child: Padding(
594
- padding: const EdgeInsets.all(16),
595
- child: Column(
596
- crossAxisAlignment: CrossAxisAlignment.start,
597
- children: [
598
- Row(
599
- children: [
600
- Container(
601
- width: 36,
602
- height: 36,
603
- decoration: BoxDecoration(
604
- color: primary.withValues(alpha: 0.10),
605
- borderRadius: BorderRadius.circular(12),
606
- ),
607
- child: Center(
608
- child: HugeIcon(
609
- icon: HugeIcons.strokeRoundedSettings01,
610
- size: 18,
611
- color: primary,
588
+ return Cue.onChange(
589
+ value: themeType,
590
+ motion: .smooth(),
591
+ child: Container(
592
+ decoration: BoxDecoration(
593
+ color: _surfaceColor(context),
594
+ borderRadius: BorderRadius.circular(20),
595
+ border: Border.all(color: _outlineColor(context, alpha: 0.9)),
596
+ ),
597
+ child: Padding(
598
+ padding: const EdgeInsets.all(16),
599
+ child: Column(
600
+ crossAxisAlignment: CrossAxisAlignment.start,
601
+ children: [
602
+ Actor(
603
+ acts: [.fadeIn(), .slideY(from: 0.05)],
604
+ child: Row(
605
+ children: [
606
+ Container(
607
+ width: 36,
608
+ height: 36,
609
+ decoration: BoxDecoration(
610
+ color: primary.withValues(alpha: 0.10),
611
+ borderRadius: BorderRadius.circular(12),
612
+ ),
613
+ child: Center(
614
+ child: HugeIcon(
615
+ icon: HugeIcons.strokeRoundedSettings01,
616
+ size: 18,
617
+ color: primary,
618
+ ),
619
+ ),
620
+ ),
621
+ const SizedBox(width: 10),
622
+ Expanded(
623
+ child: Text(
624
+ l10n.settings_title,
625
+ style: theme.textTheme.headlineSmall?.copyWith(
626
+ fontWeight: FontWeight.w700,
627
+ ),
628
+ ),
629
),
630
+ ],
631
+ ),
632
+ ),
633
+ const SizedBox(height: 6),
634
+ Actor(
635
+ delay: 60.ms,
636
+ acts: [.fadeIn(), .slideY(from: 0.05)],
637
+ child: Text(
638
+ l10n.app_tagline,
639
+ style: theme.textTheme.bodyMedium?.copyWith(
640
+ color: _mutedColor(context),
641
+ height: 1.35,
642
),
643
),
615
- const SizedBox(width: 10),
616
- Expanded(
617
- child: Text(
618
- l10n.settings_title,
619
- style: theme.textTheme.headlineSmall?.copyWith(
620
- fontWeight: FontWeight.w700,
644
+ ),
645
+ const SizedBox(height: 14),
646
+ Wrap(
647
+ spacing: 8,
648
+ runSpacing: 8,
649
+ children: [
650
+ Actor(
651
+ delay: 120.ms,
652
+ acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
653
+ child: _HeroStat(
654
+ label: l10n.theme,
655
+ value: _themeLabel(themeType, l10n),
656
+ icon: HugeIcons.strokeRoundedSparkles,
657
),
658
),
623
- ),
624
- ],
625
- ),
626
- const SizedBox(height: 6),
627
- Text(
628
- l10n.app_tagline,
629
- style: theme.textTheme.bodyMedium?.copyWith(
630
- color: _mutedColor(context),
631
- height: 1.35,
659
+ Actor(
660
+ delay: 180.ms,
661
+ acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
662
+ child: _HeroStat(
663
+ label: l10n.settings_language,
664
+ value: _languageLabel(settings.localeCode, l10n),
665
+ icon: HugeIcons.strokeRoundedGlobe,
666
+ ),
667
+ ),
668
+ Actor(
669
+ delay: 240.ms,
670
+ acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
671
+ child: _HeroStat(
672
+ label: l10n.tts_engine,
673
+ value: _engineLabel(settings.ttsEngine, l10n),
674
+ icon: HugeIcons.strokeRoundedVoice,
675
+ ),
676
+ ),
677
+ ],
678
),
633
- ),
634
- const SizedBox(height: 14),
635
- Wrap(
636
- spacing: 8,
637
- runSpacing: 8,
638
- children: [
639
- _HeroStat(
640
- label: l10n.theme,
641
- value: _themeLabel(themeType, l10n),
642
- icon: HugeIcons.strokeRoundedSparkles,
643
- ),
644
- _HeroStat(
645
- label: l10n.settings_language,
646
- value: _languageLabel(settings.localeCode, l10n),
647
- icon: HugeIcons.strokeRoundedGlobe,
648
- ),
649
- _HeroStat(
650
- label: l10n.tts_engine,
651
- value: _engineLabel(settings.ttsEngine, l10n),
652
- icon: HugeIcons.strokeRoundedVoice,
653
- ),
654
- ],
655
- ),
656
- ],
679
+ ],
680
+ ),
681
),
682
),
683
);
lib/features/sidebar/sidebar_widget.dart
+237
-193
@@ -1,3 +1,4 @@
1
+import 'package:cue/cue.dart';
2
import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:go_router/go_router.dart';
@@ -52,211 +53,254 @@ class SidebarWidget extends ConsumerWidget {
53
: Border(right: BorderSide(color: theme.colorScheme.outline)),
54
),
55
child: SafeArea(
55
- child: Column(
56
- children: [
57
- const ConversationDrawerHeader(),
56
+ child: Cue.onMount(
57
+ motion: .smooth(),
58
+ child: Column(
59
+ children: [
60
+ Actor(
61
+ acts: [.fadeIn(), .slideX(from: -0.05)],
62
+ child: const ConversationDrawerHeader(),
63
+ ),
64
59
- // New Chat Button
60
- Padding(
61
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
62
- child: ShadButton(
63
- width: double.infinity,
64
- leading: const HugeIcon(
65
- icon: HugeIcons.strokeRoundedAdd01,
66
- size: 20,
65
+ // New Chat Button
66
+ Actor(
67
+ delay: 60.ms,
68
+ acts: [.fadeIn(), .slideX(from: -0.04)],
69
+ child: Padding(
70
+ padding:
71
+ const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
72
+ child: ShadButton(
73
+ width: double.infinity,
74
+ leading: const HugeIcon(
75
+ icon: HugeIcons.strokeRoundedAdd01,
76
+ size: 20,
77
+ ),
78
+ onPressed: () {
79
+ ref.read(chatProvider.notifier).startNewConversation();
80
+ context.go(AppRoutes.home);
81
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
82
+ Navigator.pop(context);
83
+ }
84
+ },
85
+ child: Text(l10n.nav_new_chat),
86
+ ),
87
),
68
- onPressed: () {
69
- ref.read(chatProvider.notifier).startNewConversation();
70
- context.go(AppRoutes.home);
71
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
72
- Navigator.pop(context);
73
- }
74
- },
75
- child: Text(l10n.nav_new_chat),
88
),
77
- ),
89
79
- if (hasActiveChat)
80
- Padding(
81
- padding: EdgeInsets.fromLTRB(16, 0, 16, isHome ? 8 : 8),
82
- child: isTemporary
83
- ? ShadButton.outline(
84
- width: double.infinity,
85
- leading: const HugeIcon(
86
- icon: HugeIcons.strokeRoundedArrowLeft01,
87
- size: 18,
88
- ),
89
- onPressed: () {
90
- if (!isHome) context.go(AppRoutes.home);
91
- if (Scaffold.maybeOf(context)?.isDrawerOpen ??
92
- false) {
93
- Navigator.pop(context);
94
- }
95
- },
96
- child: Text(l10n.return_to_temp_chat),
97
- )
98
- : ShadButton.secondary(
99
- width: double.infinity,
100
- leading: const HugeIcon(
101
- icon: HugeIcons.strokeRoundedArrowLeft01,
102
- size: 18,
103
- ),
104
- onPressed: () {
105
- if (!isHome) context.go(AppRoutes.home);
106
- if (Scaffold.maybeOf(context)?.isDrawerOpen ??
107
- false) {
108
- Navigator.pop(context);
109
- }
110
- },
111
- child: Text(l10n.return_to_chat),
112
- ),
113
- ),
90
+ if (hasActiveChat)
91
+ Actor(
92
+ delay: 120.ms,
93
+ acts: [.fadeIn(), .slideX(from: -0.04)],
94
+ child: Padding(
95
+ padding: EdgeInsets.fromLTRB(16, 0, 16, isHome ? 8 : 8),
96
+ child: isTemporary
97
+ ? ShadButton.outline(
98
+ width: double.infinity,
99
+ leading: const HugeIcon(
100
+ icon: HugeIcons.strokeRoundedArrowLeft01,
101
+ size: 18,
102
+ ),
103
+ onPressed: () {
104
+ if (!isHome) context.go(AppRoutes.home);
105
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
106
+ false) {
107
+ Navigator.pop(context);
108
+ }
109
+ },
110
+ child: Text(l10n.return_to_temp_chat),
111
+ )
112
+ : ShadButton.secondary(
113
+ width: double.infinity,
114
+ leading: const HugeIcon(
115
+ icon: HugeIcons.strokeRoundedArrowLeft01,
116
+ size: 18,
117
+ ),
118
+ onPressed: () {
119
+ if (!isHome) context.go(AppRoutes.home);
120
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
121
+ false) {
122
+ Navigator.pop(context);
123
+ }
124
+ },
125
+ child: Text(l10n.return_to_chat),
126
+ ),
127
+ ),
128
+ ),
129
115
- const SidebarSearchButton(),
116
- const SizedBox(height: 8),
117
- const Divider(height: 1, indent: 16, endIndent: 16),
118
- const SizedBox(height: 12),
130
+ Actor(
131
+ delay: 180.ms,
132
+ acts: [.fadeIn()],
133
+ child: const SidebarSearchButton(),
134
+ ),
135
+ const SizedBox(height: 8),
136
+ const Divider(height: 1, indent: 16, endIndent: 16),
137
+ const SizedBox(height: 12),
138
120
- // Primary Navigation
121
- Expanded(
122
- child: SingleChildScrollView(
123
- child: Column(
124
- children: [
125
- DrawerNavItem(
126
- iconData: HugeIcons.strokeRoundedChatting01,
127
- label: l10n.nav_history,
128
- isSelected: isHistory,
129
- onTap: () {
130
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
131
- Navigator.pop(context);
132
- }
133
- context.go(AppRoutes.chatHistory);
134
- },
135
- ),
136
- DrawerNavItem(
137
- iconData: HugeIcons.strokeRoundedBookmark02,
138
- label: l10n.nav_saved_messages,
139
- isSelected: isSavedMessages,
140
- onTap: () {
141
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
142
- Navigator.pop(context);
143
- }
144
- context.go(AppRoutes.savedMessages);
145
- },
146
- ),
147
- DrawerNavItem(
148
- iconData: HugeIcons.strokeRoundedServerStack01,
149
- label: l10n.nav_servers,
150
- isSelected: isServers,
151
- onTap: () {
152
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
153
- Navigator.pop(context);
154
- }
155
- context.go(AppRoutes.servers);
156
- },
157
- ),
158
- DrawerNavItem(
159
- iconData: HugeIcons.strokeRoundedMcpServer,
160
- label: l10n.mcp_tools_title,
161
- isSelected: isMcpTools,
162
- onTap: () {
163
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
164
- Navigator.pop(context);
165
- }
166
- context.go(AppRoutes.mcpTools);
167
- },
168
- ),
169
- if (isLmStudio)
170
- DrawerNavItem(
171
- iconData: HugeIcons.strokeRoundedAiSearch,
172
- label: l10n.lm_studio_model_search,
173
- isSelected: false,
174
- trailing: const LmDownloadIndicatorButton(
175
- compact: true,
139
+ // Primary Navigation
140
+ Expanded(
141
+ child: Actor(
142
+ delay: 220.ms,
143
+ acts: [.fadeIn(), .slideY(from: 0.04)],
144
+ child: SingleChildScrollView(
145
+ child: Column(
146
+ children: [
147
+ DrawerNavItem(
148
+ iconData: HugeIcons.strokeRoundedChatting01,
149
+ label: l10n.nav_history,
150
+ isSelected: isHistory,
151
+ onTap: () {
152
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
153
+ false) {
154
+ Navigator.pop(context);
155
+ }
156
+ context.go(AppRoutes.chatHistory);
157
+ },
158
),
177
- onTap: () {
178
- if (Scaffold.maybeOf(context)?.isDrawerOpen ??
179
- false) {
180
- Navigator.pop(context);
181
- }
182
- context.push(
183
- AppRoutes.lmStudioModelBrowser,
184
- extra: activeServer,
185
- );
186
- },
187
- ),
188
- DrawerNavItem(
189
- iconData: HugeIcons.strokeRoundedSmartPhone01,
190
- label: l10n.nav_local_models,
191
- isSelected: isLocalModels,
192
- onTap: () {
193
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
194
- Navigator.pop(context);
195
- }
196
- context.go(AppRoutes.onDeviceModels);
197
- },
198
- ),
199
- DrawerNavItem(
200
- iconData: HugeIcons.strokeRoundedVoice,
201
- label: l10n.nav_tts,
202
- isSelected: isTtsModels,
203
- onTap: () {
204
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
205
- Navigator.pop(context);
206
- }
207
- context.go(AppRoutes.ttsModels);
208
- },
209
- ),
210
- DrawerNavItem(
211
- iconData: HugeIcons.strokeRoundedCompass01,
212
- label: l10n.nav_personas,
213
- isSelected: isPersonas,
214
- onTap: () {
215
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
216
- Navigator.pop(context);
217
- }
218
- context.go(AppRoutes.personas);
219
- },
220
- ),
221
- const SizedBox(height: 8),
222
- const Divider(height: 1, indent: 16, endIndent: 16),
223
- const SizedBox(height: 8),
224
- DrawerNavItem(
225
- iconData: HugeIcons.strokeRoundedCloudSavingDone02,
226
- label: l10n.cloud_sync,
227
- isSelected: isCloudSync,
228
- onTap: () {
229
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
230
- Navigator.pop(context);
231
- }
232
- context.go(AppRoutes.cloudSync);
233
- },
234
- ),
235
- DrawerNavItem(
236
- iconData: HugeIcons.strokeRoundedSettings01,
237
- label: l10n.nav_settings,
238
- isSelected: isSettings,
239
- onTap: () {
240
- if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
241
- Navigator.pop(context);
242
- }
243
- context.go(AppRoutes.settings);
244
- },
159
+ DrawerNavItem(
160
+ iconData: HugeIcons.strokeRoundedBookmark02,
161
+ label: l10n.nav_saved_messages,
162
+ isSelected: isSavedMessages,
163
+ onTap: () {
164
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
165
+ false) {
166
+ Navigator.pop(context);
167
+ }
168
+ context.go(AppRoutes.savedMessages);
169
+ },
170
+ ),
171
+ DrawerNavItem(
172
+ iconData: HugeIcons.strokeRoundedServerStack01,
173
+ label: l10n.nav_servers,
174
+ isSelected: isServers,
175
+ onTap: () {
176
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
177
+ false) {
178
+ Navigator.pop(context);
179
+ }
180
+ context.go(AppRoutes.servers);
181
+ },
182
+ ),
183
+ DrawerNavItem(
184
+ iconData: HugeIcons.strokeRoundedMcpServer,
185
+ label: l10n.mcp_tools_title,
186
+ isSelected: isMcpTools,
187
+ onTap: () {
188
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
189
+ false) {
190
+ Navigator.pop(context);
191
+ }
192
+ context.go(AppRoutes.mcpTools);
193
+ },
194
+ ),
195
+ if (isLmStudio)
196
+ DrawerNavItem(
197
+ iconData: HugeIcons.strokeRoundedAiSearch,
198
+ label: l10n.lm_studio_model_search,
199
+ isSelected: false,
200
+ trailing: const LmDownloadIndicatorButton(
201
+ compact: true,
202
+ ),
203
+ onTap: () {
204
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
205
+ false) {
206
+ Navigator.pop(context);
207
+ }
208
+ context.push(
209
+ AppRoutes.lmStudioModelBrowser,
210
+ extra: activeServer,
211
+ );
212
+ },
213
+ ),
214
+ DrawerNavItem(
215
+ iconData: HugeIcons.strokeRoundedSmartPhone01,
216
+ label: l10n.nav_local_models,
217
+ isSelected: isLocalModels,
218
+ onTap: () {
219
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
220
+ false) {
221
+ Navigator.pop(context);
222
+ }
223
+ context.go(AppRoutes.onDeviceModels);
224
+ },
225
+ ),
226
+ DrawerNavItem(
227
+ iconData: HugeIcons.strokeRoundedVoice,
228
+ label: l10n.nav_tts,
229
+ isSelected: isTtsModels,
230
+ onTap: () {
231
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
232
+ false) {
233
+ Navigator.pop(context);
234
+ }
235
+ context.go(AppRoutes.ttsModels);
236
+ },
237
+ ),
238
+ DrawerNavItem(
239
+ iconData: HugeIcons.strokeRoundedCompass01,
240
+ label: l10n.nav_personas,
241
+ isSelected: isPersonas,
242
+ onTap: () {
243
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
244
+ false) {
245
+ Navigator.pop(context);
246
+ }
247
+ context.go(AppRoutes.personas);
248
+ },
249
+ ),
250
+ const SizedBox(height: 8),
251
+ const Divider(height: 1, indent: 16, endIndent: 16),
252
+ const SizedBox(height: 8),
253
+ DrawerNavItem(
254
+ iconData: HugeIcons.strokeRoundedCloudSavingDone02,
255
+ label: l10n.cloud_sync,
256
+ isSelected: isCloudSync,
257
+ onTap: () {
258
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
259
+ false) {
260
+ Navigator.pop(context);
261
+ }
262
+ context.go(AppRoutes.cloudSync);
263
+ },
264
+ ),
265
+ DrawerNavItem(
266
+ iconData: HugeIcons.strokeRoundedSettings01,
267
+ label: l10n.nav_settings,
268
+ isSelected: isSettings,
269
+ onTap: () {
270
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
271
+ false) {
272
+ Navigator.pop(context);
273
+ }
274
+ context.go(AppRoutes.settings);
275
+ },
276
+ ),
277
+ ],
278
),
246
- ],
279
+ ),
280
),
281
),
249
- ),
282
251
- // Duplicate Player controls
252
- const TtsPlayerBar(),
283
+ Actor(
284
+ delay: 320.ms,
285
+ acts: [.fadeIn(), .slideY(from: 0.06)],
286
+ child: const TtsPlayerBar(),
287
+ ),
288
254
- // Bottom Section
255
- const GitHubRepoCard(),
256
- const Divider(height: 1),
257
- const ActiveServerIndicator(),
258
- const SizedBox(height: 16),
259
- ],
289
+ // Bottom Section
290
+ Actor(
291
+ delay: 360.ms,
292
+ acts: [.fadeIn(), .slideY(from: 0.08)],
293
+ child: const GitHubRepoCard(),
294
+ ),
295
+ const Divider(height: 1),
296
+ Actor(
297
+ delay: 400.ms,
298
+ acts: [.fadeIn()],
299
+ child: const ActiveServerIndicator(),
300
+ ),
301
+ const SizedBox(height: 16),
302
+ ],
303
+ ),
304
),
305
),
306
);
lib/l10n/app_ar.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<فارغ>",
856
"crash_report_disclaimer": "يفتح الإبلاغ GitHub مع بيانات التشخيص مملوءة مسبقًا. أنت المسؤول — لا يتم إرسال أي شيء تلقائيًا. يرجى المراجعة وإزالة أي محتوى حساس قبل الإرسال.",
857
"crash_report_copied": "تم النسخ إلى الحافظة",
858
- "report_a_problem": "الإبلاغ عن مشكلة"
858
+ "report_a_problem": "الإبلاغ عن مشكلة",
859
+ "rename_folder": "إعادة تسمية المجلد",
860
+ "delete_folder": "حذف المجلد",
861
+ "delete_folder_title": "حذف المجلد؟",
862
+ "delete_folder_body": "هل أنت متأكد من رغبتك في حذف \"{name}\"؟ سيتم نقل المحادثات أو الرسائل المحفوظة بداخله إلى \"غير مصنّف\". لا يمكن التراجع.",
863
+ "folder_name_required": "الرجاء إدخال اسم المجلد"
864
}
lib/l10n/app_bn.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<খালি>",
856
"crash_report_disclaimer": "রিপোর্টিং GitHub খুলবে যেখানে ডায়াগনস্টিক্স পূর্বে পূরণ করা থাকবে। নিয়ন্ত্রণ আপনার হাতে — কিছুই স্বয়ংক্রিয়ভাবে জমা দেওয়া হয় না। জমা দেওয়ার আগে দয়া করে সংবেদনশীল তথ্য সরিয়ে দিন।",
857
"crash_report_copied": "ক্লিপবোর্ডে কপি করা হয়েছে",
858
- "report_a_problem": "একটি সমস্যা রিপোর্ট করুন"
858
+ "report_a_problem": "একটি সমস্যা রিপোর্ট করুন",
859
+ "rename_folder": "ফোল্ডারের নাম পরিবর্তন",
860
+ "delete_folder": "ফোল্ডার মুছুন",
861
+ "delete_folder_title": "ফোল্ডার মুছে ফেলবেন?",
862
+ "delete_folder_body": "আপনি কি নিশ্চিত যে \"{name}\" মুছে ফেলতে চান? ভিতরের কথোপকথন বা সংরক্ষিত বার্তা \"অবিভাজিত\"-এ ফিরে যাবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।",
863
+ "folder_name_required": "অনুগ্রহ করে একটি ফোল্ডারের নাম লিখুন"
864
}
lib/l10n/app_en.arb
+26
@@ -3067,5 +3067,31 @@
3067
"report_a_problem": "Report a problem",
3068
"@report_a_problem": {
3069
"description": "Settings tile label that opens the generic feedback issue form"
3070
+ },
3071
+ "rename_folder": "Rename folder",
3072
+ "@rename_folder": {
3073
+ "description": "Menu item and dialog title for renaming a chat history or saved messages folder"
3074
+ },
3075
+ "delete_folder": "Delete folder",
3076
+ "@delete_folder": {
3077
+ "description": "Menu item for deleting a chat history or saved messages folder"
3078
+ },
3079
+ "delete_folder_title": "Delete folder?",
3080
+ "@delete_folder_title": {
3081
+ "description": "Title of the confirmation dialog for deleting a folder"
3082
+ },
3083
+ "delete_folder_body": "Are you sure you want to delete \"{name}\"? Conversations or saved messages inside will be moved back to \"Unfiled\". This cannot be undone.",
3084
+ "@delete_folder_body": {
3085
+ "description": "Body of the confirmation dialog for deleting a folder. Names the folder and warns that contained items are moved to Unfiled.",
3086
+ "placeholders": {
3087
+ "name": {
3088
+ "type": "String",
3089
+ "example": "Research"
3090
+ }
3091
+ }
3092
+ },
3093
+ "folder_name_required": "Please enter a folder name",
3094
+ "@folder_name_required": {
3095
+ "description": "Validation error when a folder name is empty"
3096
}
3097
}
lib/l10n/app_es.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<vacío>",
856
"crash_report_disclaimer": "Informar abre GitHub con el diagnóstico rellenado de antemano. Tú tienes el control: no se envía nada automáticamente. Revisa y elimina cualquier contenido sensible antes de enviar.",
857
"crash_report_copied": "Copiado al portapapeles",
858
- "report_a_problem": "Informar de un problema"
858
+ "report_a_problem": "Informar de un problema",
859
+ "rename_folder": "Renombrar carpeta",
860
+ "delete_folder": "Eliminar carpeta",
861
+ "delete_folder_title": "¿Eliminar carpeta?",
862
+ "delete_folder_body": "¿Seguro que quieres eliminar \"{name}\"? Las conversaciones o mensajes guardados dentro se moverán a \"Sin clasificar\". No se puede deshacer.",
863
+ "folder_name_required": "Introduce un nombre de carpeta"
864
}
lib/l10n/app_hi.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<खाली>",
856
"crash_report_disclaimer": "रिपोर्टिंग GitHub खोलेगी जिसमें निदान पहले से भरा होगा। नियंत्रण आपके पास है — कुछ भी स्वचालित रूप से सबमिट नहीं किया जाता। कृपया सबमिट करने से पहले संवेदनशील सामग्री की समीक्षा करें और हटा दें।",
857
"crash_report_copied": "क्लिपबोर्ड पर कॉपी किया गया",
858
- "report_a_problem": "समस्या की रिपोर्ट करें"
858
+ "report_a_problem": "समस्या की रिपोर्ट करें",
859
+ "rename_folder": "फ़ोल्डर का नाम बदलें",
860
+ "delete_folder": "फ़ोल्डर हटाएँ",
861
+ "delete_folder_title": "फ़ोल्डर हटाएँ?",
862
+ "delete_folder_body": "क्या आप वाकई \"{name}\" हटाना चाहते हैं? अंदर की बातचीत या सहेजे गए संदेश \"अवर्गीकृत\" में वापस चले जाएंगे। इसे पूर्ववत नहीं किया जा सकता।",
863
+ "folder_name_required": "कृपया फ़ोल्डर का नाम दर्ज करें"
864
}
lib/l10n/app_it.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<vuoto>",
856
"crash_report_disclaimer": "Segnalare apre GitHub con le diagnostiche già compilate. Hai il controllo: non viene inviato nulla automaticamente. Rivedi e rimuovi eventuali contenuti sensibili prima di inviare.",
857
"crash_report_copied": "Copiato negli appunti",
858
- "report_a_problem": "Segnala un problema"
858
+ "report_a_problem": "Segnala un problema",
859
+ "rename_folder": "Rinomina cartella",
860
+ "delete_folder": "Elimina cartella",
861
+ "delete_folder_title": "Eliminare la cartella?",
862
+ "delete_folder_body": "Sei sicuro di voler eliminare \"{name}\"? Le conversazioni o i messaggi salvati al suo interno verranno spostati in \"Senza cartella\". Operazione irreversibile.",
863
+ "folder_name_required": "Inserisci un nome per la cartella"
864
}
lib/l10n/app_ja.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<空>",
856
"crash_report_disclaimer": "報告すると、診断情報があらかじめ入力された状態で GitHub が開きます。自動送信はされません。送信前に機密性の高い内容を削除してください。",
857
"crash_report_copied": "クリップボードにコピーしました",
858
- "report_a_problem": "問題を報告"
858
+ "report_a_problem": "問題を報告",
859
+ "rename_folder": "フォルダ名を変更",
860
+ "delete_folder": "フォルダを削除",
861
+ "delete_folder_title": "フォルダを削除しますか?",
862
+ "delete_folder_body": "「{name}」を削除してもよろしいですか?フォルダ内の会話や保存済みメッセージは「未分類」に戻されます。この操作は元に戻せません。",
863
+ "folder_name_required": "フォルダ名を入力してください"
864
}
lib/l10n/app_localizations.dart
+30
@@ -5230,6 +5230,36 @@ abstract class AppLocalizations {
5230
/// In en, this message translates to:
5231
/// **'Report a problem'**
5232
String get report_a_problem;
5233
+
5234
+ /// Menu item and dialog title for renaming a chat history or saved messages folder
5235
+ ///
5236
+ /// In en, this message translates to:
5237
+ /// **'Rename folder'**
5238
+ String get rename_folder;
5239
+
5240
+ /// Menu item for deleting a chat history or saved messages folder
5241
+ ///
5242
+ /// In en, this message translates to:
5243
+ /// **'Delete folder'**
5244
+ String get delete_folder;
5245
+
5246
+ /// Title of the confirmation dialog for deleting a folder
5247
+ ///
5248
+ /// In en, this message translates to:
5249
+ /// **'Delete folder?'**
5250
+ String get delete_folder_title;
5251
+
5252
+ /// Body of the confirmation dialog for deleting a folder. Names the folder and warns that contained items are moved to Unfiled.
5253
+ ///
5254
+ /// In en, this message translates to:
5255
+ /// **'Are you sure you want to delete \"{name}\"? Conversations or saved messages inside will be moved back to \"Unfiled\". This cannot be undone.'**
5256
+ String delete_folder_body(String name);
5257
+
5258
+ /// Validation error when a folder name is empty
5259
+ ///
5260
+ /// In en, this message translates to:
5261
+ /// **'Please enter a folder name'**
5262
+ String get folder_name_required;
5263
}
5264
5265
class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+17
@@ -2779,4 +2779,21 @@ class AppLocalizationsAr extends AppLocalizations {
2779
2780
@override
2781
String get report_a_problem => 'الإبلاغ عن مشكلة';
2782
+
2783
+ @override
2784
+ String get rename_folder => 'إعادة تسمية المجلد';
2785
+
2786
+ @override
2787
+ String get delete_folder => 'حذف المجلد';
2788
+
2789
+ @override
2790
+ String get delete_folder_title => 'حذف المجلد؟';
2791
+
2792
+ @override
2793
+ String delete_folder_body(String name) {
2794
+ return 'هل أنت متأكد من رغبتك في حذف \"$name\"؟ سيتم نقل المحادثات أو الرسائل المحفوظة بداخله إلى \"غير مصنّف\". لا يمكن التراجع.';
2795
+ }
2796
+
2797
+ @override
2798
+ String get folder_name_required => 'الرجاء إدخال اسم المجلد';
2799
}
lib/l10n/app_localizations_bn.dart
+17
@@ -2799,4 +2799,21 @@ class AppLocalizationsBn extends AppLocalizations {
2799
2800
@override
2801
String get report_a_problem => 'একটি সমস্যা রিপোর্ট করুন';
2802
+
2803
+ @override
2804
+ String get rename_folder => 'ফোল্ডারের নাম পরিবর্তন';
2805
+
2806
+ @override
2807
+ String get delete_folder => 'ফোল্ডার মুছুন';
2808
+
2809
+ @override
2810
+ String get delete_folder_title => 'ফোল্ডার মুছে ফেলবেন?';
2811
+
2812
+ @override
2813
+ String delete_folder_body(String name) {
2814
+ return 'আপনি কি নিশ্চিত যে \"$name\" মুছে ফেলতে চান? ভিতরের কথোপকথন বা সংরক্ষিত বার্তা \"অবিভাজিত\"-এ ফিরে যাবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।';
2815
+ }
2816
+
2817
+ @override
2818
+ String get folder_name_required => 'অনুগ্রহ করে একটি ফোল্ডারের নাম লিখুন';
2819
}
lib/l10n/app_localizations_en.dart
+17
@@ -2787,4 +2787,21 @@ class AppLocalizationsEn extends AppLocalizations {
2787
2788
@override
2789
String get report_a_problem => 'Report a problem';
2790
+
2791
+ @override
2792
+ String get rename_folder => 'Rename folder';
2793
+
2794
+ @override
2795
+ String get delete_folder => 'Delete folder';
2796
+
2797
+ @override
2798
+ String get delete_folder_title => 'Delete folder?';
2799
+
2800
+ @override
2801
+ String delete_folder_body(String name) {
2802
+ return 'Are you sure you want to delete \"$name\"? Conversations or saved messages inside will be moved back to \"Unfiled\". This cannot be undone.';
2803
+ }
2804
+
2805
+ @override
2806
+ String get folder_name_required => 'Please enter a folder name';
2807
}
lib/l10n/app_localizations_es.dart
+17
@@ -2803,4 +2803,21 @@ class AppLocalizationsEs extends AppLocalizations {
2803
2804
@override
2805
String get report_a_problem => 'Informar de un problema';
2806
+
2807
+ @override
2808
+ String get rename_folder => 'Renombrar carpeta';
2809
+
2810
+ @override
2811
+ String get delete_folder => 'Eliminar carpeta';
2812
+
2813
+ @override
2814
+ String get delete_folder_title => '¿Eliminar carpeta?';
2815
+
2816
+ @override
2817
+ String delete_folder_body(String name) {
2818
+ return '¿Seguro que quieres eliminar \"$name\"? Las conversaciones o mensajes guardados dentro se moverán a \"Sin clasificar\". No se puede deshacer.';
2819
+ }
2820
+
2821
+ @override
2822
+ String get folder_name_required => 'Introduce un nombre de carpeta';
2823
}
lib/l10n/app_localizations_hi.dart
+17
@@ -2792,4 +2792,21 @@ class AppLocalizationsHi extends AppLocalizations {
2792
2793
@override
2794
String get report_a_problem => 'समस्या की रिपोर्ट करें';
2795
+
2796
+ @override
2797
+ String get rename_folder => 'फ़ोल्डर का नाम बदलें';
2798
+
2799
+ @override
2800
+ String get delete_folder => 'फ़ोल्डर हटाएँ';
2801
+
2802
+ @override
2803
+ String get delete_folder_title => 'फ़ोल्डर हटाएँ?';
2804
+
2805
+ @override
2806
+ String delete_folder_body(String name) {
2807
+ return 'क्या आप वाकई \"$name\" हटाना चाहते हैं? अंदर की बातचीत या सहेजे गए संदेश \"अवर्गीकृत\" में वापस चले जाएंगे। इसे पूर्ववत नहीं किया जा सकता।';
2808
+ }
2809
+
2810
+ @override
2811
+ String get folder_name_required => 'कृपया फ़ोल्डर का नाम दर्ज करें';
2812
}
lib/l10n/app_localizations_it.dart
+17
@@ -2806,4 +2806,21 @@ class AppLocalizationsIt extends AppLocalizations {
2806
2807
@override
2808
String get report_a_problem => 'Segnala un problema';
2809
+
2810
+ @override
2811
+ String get rename_folder => 'Rinomina cartella';
2812
+
2813
+ @override
2814
+ String get delete_folder => 'Elimina cartella';
2815
+
2816
+ @override
2817
+ String get delete_folder_title => 'Eliminare la cartella?';
2818
+
2819
+ @override
2820
+ String delete_folder_body(String name) {
2821
+ return 'Sei sicuro di voler eliminare \"$name\"? Le conversazioni o i messaggi salvati al suo interno verranno spostati in \"Senza cartella\". Operazione irreversibile.';
2822
+ }
2823
+
2824
+ @override
2825
+ String get folder_name_required => 'Inserisci un nome per la cartella';
2826
}
lib/l10n/app_localizations_ja.dart
+17
@@ -2739,4 +2739,21 @@ class AppLocalizationsJa extends AppLocalizations {
2739
2740
@override
2741
String get report_a_problem => '問題を報告';
2742
+
2743
+ @override
2744
+ String get rename_folder => 'フォルダ名を変更';
2745
+
2746
+ @override
2747
+ String get delete_folder => 'フォルダを削除';
2748
+
2749
+ @override
2750
+ String get delete_folder_title => 'フォルダを削除しますか?';
2751
+
2752
+ @override
2753
+ String delete_folder_body(String name) {
2754
+ return '「$name」を削除してもよろしいですか?フォルダ内の会話や保存済みメッセージは「未分類」に戻されます。この操作は元に戻せません。';
2755
+ }
2756
+
2757
+ @override
2758
+ String get folder_name_required => 'フォルダ名を入力してください';
2759
}
lib/l10n/app_localizations_ru.dart
+17
@@ -2806,4 +2806,21 @@ class AppLocalizationsRu extends AppLocalizations {
2806
2807
@override
2808
String get report_a_problem => 'Сообщить о проблеме';
2809
+
2810
+ @override
2811
+ String get rename_folder => 'Переименовать папку';
2812
+
2813
+ @override
2814
+ String get delete_folder => 'Удалить папку';
2815
+
2816
+ @override
2817
+ String get delete_folder_title => 'Удалить папку?';
2818
+
2819
+ @override
2820
+ String delete_folder_body(String name) {
2821
+ return 'Удалить папку «$name»? Содержащиеся в ней беседы и сохранённые сообщения будут перенесены в «Без папки». Это действие нельзя отменить.';
2822
+ }
2823
+
2824
+ @override
2825
+ String get folder_name_required => 'Введите название папки';
2826
}
lib/l10n/app_localizations_zh.dart
+34
@@ -2719,6 +2719,23 @@ class AppLocalizationsZh extends AppLocalizations {
2719
2720
@override
2721
String get report_a_problem => '报告问题';
2722
+
2723
+ @override
2724
+ String get rename_folder => '重命名文件夹';
2725
+
2726
+ @override
2727
+ String get delete_folder => '删除文件夹';
2728
+
2729
+ @override
2730
+ String get delete_folder_title => '删除文件夹?';
2731
+
2732
+ @override
2733
+ String delete_folder_body(String name) {
2734
+ return '确定要删除\"$name\"吗?其中的对话或已保存的消息将移回\"未分类\"。此操作无法撤销。';
2735
+ }
2736
+
2737
+ @override
2738
+ String get folder_name_required => '请输入文件夹名称';
2739
}
2740
2741
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -5437,4 +5454,21 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
5454
5455
@override
5456
String get report_a_problem => '回報問題';
5457
+
5458
+ @override
5459
+ String get rename_folder => '重新命名資料夾';
5460
+
5461
+ @override
5462
+ String get delete_folder => '刪除資料夾';
5463
+
5464
+ @override
5465
+ String get delete_folder_title => '刪除資料夾?';
5466
+
5467
+ @override
5468
+ String delete_folder_body(String name) {
5469
+ return '確定要刪除「$name」嗎?其中的對話或已儲存的訊息將移回「未分類」。此操作無法復原。';
5470
+ }
5471
+
5472
+ @override
5473
+ String get folder_name_required => '請輸入資料夾名稱';
5474
}
lib/l10n/app_ru.arb
+6
-1
@@ -852,5 +852,10 @@
852
"crash_report_empty_stack": "<пусто>",
853
"crash_report_disclaimer": "Отчёт откроет GitHub с уже заполненной диагностикой. Контроль остаётся за вами — ничего не отправляется автоматически. Перед отправкой проверьте и удалите конфиденциальные данные.",
854
"crash_report_copied": "Скопировано в буфер обмена",
855
- "report_a_problem": "Сообщить о проблеме"
855
+ "report_a_problem": "Сообщить о проблеме",
856
+ "rename_folder": "Переименовать папку",
857
+ "delete_folder": "Удалить папку",
858
+ "delete_folder_title": "Удалить папку?",
859
+ "delete_folder_body": "Удалить папку «{name}»? Содержащиеся в ней беседы и сохранённые сообщения будут перенесены в «Без папки». Это действие нельзя отменить.",
860
+ "folder_name_required": "Введите название папки"
861
}
lib/l10n/app_zh.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<空>",
856
"crash_report_disclaimer": "报告将打开已预填诊断信息的 GitHub。由您掌控 — 不会自动提交任何内容。请在提交前检查并删除敏感内容。",
857
"crash_report_copied": "已复制到剪贴板",
858
- "report_a_problem": "报告问题"
858
+ "report_a_problem": "报告问题",
859
+ "rename_folder": "重命名文件夹",
860
+ "delete_folder": "删除文件夹",
861
+ "delete_folder_title": "删除文件夹?",
862
+ "delete_folder_body": "确定要删除\"{name}\"吗?其中的对话或已保存的消息将移回\"未分类\"。此操作无法撤销。",
863
+ "folder_name_required": "请输入文件夹名称"
864
}
lib/l10n/app_zh_TW.arb
+6
-1
@@ -855,5 +855,10 @@
855
"crash_report_empty_stack": "<空>",
856
"crash_report_disclaimer": "回報將開啟已預先填入診斷資訊的 GitHub。由您掌控 — 不會自動提交任何內容。請在提交前檢查並移除敏感內容。",
857
"crash_report_copied": "已複製到剪貼簿",
858
- "report_a_problem": "回報問題"
858
+ "report_a_problem": "回報問題",
859
+ "rename_folder": "重新命名資料夾",
860
+ "delete_folder": "刪除資料夾",
861
+ "delete_folder_title": "刪除資料夾?",
862
+ "delete_folder_body": "確定要刪除「{name}」嗎?其中的對話或已儲存的訊息將移回「未分類」。此操作無法復原。",
863
+ "folder_name_required": "請輸入資料夾名稱"
864
}
test/core/components/folder_filter_bar_test.dart
new
+75
@@ -0,0 +1,75 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/core/components/folder_filter_bar.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+
6
+void main() {
7
+ testWidgets('user folder chip triggers onFolderAction on long-press', (
8
+ tester,
9
+ ) async {
10
+ FolderFilterItem? captured;
11
+ Offset? capturedPos;
12
+
13
+ await tester.pumpWidget(
14
+ MaterialApp(
15
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
16
+ supportedLocales: AppLocalizations.supportedLocales,
17
+ locale: const Locale('en'),
18
+ home: Scaffold(
19
+ body: FolderFilterBar(
20
+ folders: const [
21
+ FolderFilterItem(id: 'research', name: 'Research'),
22
+ ],
23
+ selectedFolderId: null,
24
+ onFilterChanged: (_) {},
25
+ onCreateFolder: () {},
26
+ onFolderAction: (folder, pos) {
27
+ captured = folder;
28
+ capturedPos = pos;
29
+ },
30
+ ),
31
+ ),
32
+ ),
33
+ );
34
+
35
+ // Long-press is hard to simulate reliably in a widget test (the
36
+ // recognizer waits for a kLongPressTimeout), so we instead tap the
37
+ // chip to verify the primary tap path still works through the gesture
38
+ // wrapper, then verify the wrapper respects the no-callback case.
39
+ await tester.tap(find.text('Research'));
40
+ expect(captured, isNull, reason: 'tap should not trigger action');
41
+
42
+ // Pump long-press just to confirm the wrapper renders without errors.
43
+ await tester.longPress(find.text('Research'));
44
+ await tester.pump();
45
+ // The wrapper delivers globalPosition; we just confirm the callback fired
46
+ // for the right folder.
47
+ expect(captured?.id, 'Research');
48
+ expect(capturedPos, isNotNull);
49
+ });
50
+
51
+ testWidgets('FolderFilterBar does not throw when onFolderAction is null',
52
+ (tester) async {
53
+ await tester.pumpWidget(
54
+ MaterialApp(
55
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
56
+ supportedLocales: AppLocalizations.supportedLocales,
57
+ locale: const Locale('en'),
58
+ home: Scaffold(
59
+ body: FolderFilterBar(
60
+ folders: const [
61
+ FolderFilterItem(id: 'a', name: 'A'),
62
+ ],
63
+ selectedFolderId: null,
64
+ onFilterChanged: (_) {},
65
+ onCreateFolder: () {},
66
+ // onFolderAction deliberately omitted.
67
+ ),
68
+ ),
69
+ ),
70
+ );
71
+ expect(tester.takeException(), isNull);
72
+ await tester.tap(find.text('A'));
73
+ expect(tester.takeException(), isNull);
74
+ });
75
+}
\ No newline at end of file
test/core/components/folder_management_dialogs_test.dart
new
+90
@@ -0,0 +1,90 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/core/components/folder_management_dialogs.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+
6
+Future<void> _pumpHarness(
7
+ WidgetTester tester,
8
+ Future<void> Function(BuildContext) body,
9
+) async {
10
+ await tester.pumpWidget(
11
+ MaterialApp(
12
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
13
+ supportedLocales: AppLocalizations.supportedLocales,
14
+ locale: const Locale('en'),
15
+ home: Scaffold(
16
+ body: Builder(
17
+ builder: (ctx) {
18
+ // Call the test action once the initial frame has rendered.
19
+ WidgetsBinding.instance.addPostFrameCallback((_) {
20
+ body(ctx);
21
+ });
22
+ return const SizedBox.shrink();
23
+ },
24
+ ),
25
+ ),
26
+ ),
27
+ );
28
+ // Allow async dialog futures to settle.
29
+ await tester.pump();
30
+ await tester.pump(const Duration(milliseconds: 300));
31
+}
32
+
33
+void main() {
34
+ testWidgets('showRenameFolderDialog returns trimmed name on save', (
35
+ tester,
36
+ ) async {
37
+ String? result;
38
+ await _pumpHarness(tester, (ctx) async {
39
+ result = await showRenameFolderDialog(ctx, currentName: 'Old');
40
+ });
41
+ // The dialog should be visible.
42
+ expect(find.text('Rename folder'), findsOneWidget);
43
+ // Replace the existing name.
44
+ await tester.enterText(find.byType(TextField), ' New Name ');
45
+ await tester.tap(find.text('Save'));
46
+ await tester.pumpAndSettle();
47
+ expect(result, 'New Name');
48
+ });
49
+
50
+ testWidgets('showRenameFolderDialog rejects empty name', (tester) async {
51
+ String? result;
52
+ await _pumpHarness(tester, (ctx) async {
53
+ result = await showRenameFolderDialog(ctx, currentName: 'Old');
54
+ });
55
+ await tester.enterText(find.byType(TextField), ' ');
56
+ await tester.tap(find.text('Save'));
57
+ await tester.pump();
58
+ // Dialog still visible because validation failed.
59
+ expect(find.text('Please enter a folder name'), findsOneWidget);
60
+ expect(result, isNull);
61
+ // Cancel closes the dialog.
62
+ await tester.tap(find.text('Cancel'));
63
+ await tester.pumpAndSettle();
64
+ });
65
+
66
+ testWidgets('showDeleteFolderConfirmation returns true on confirm', (
67
+ tester,
68
+ ) async {
69
+ bool result = false;
70
+ await _pumpHarness(tester, (ctx) async {
71
+ result = await showDeleteFolderConfirmation(ctx, folderName: 'Notes');
72
+ });
73
+ expect(find.text('Delete folder?'), findsOneWidget);
74
+ await tester.tap(find.text('Delete'));
75
+ await tester.pumpAndSettle();
76
+ expect(result, isTrue);
77
+ });
78
+
79
+ testWidgets('showDeleteFolderConfirmation returns false on cancel', (
80
+ tester,
81
+ ) async {
82
+ bool result = true;
83
+ await _pumpHarness(tester, (ctx) async {
84
+ result = await showDeleteFolderConfirmation(ctx, folderName: 'Notes');
85
+ });
86
+ await tester.tap(find.text('Cancel'));
87
+ await tester.pumpAndSettle();
88
+ expect(result, isFalse);
89
+ });
90
+}
\ No newline at end of file