diff --git a/assets/images/atoms_logo.svg b/assets/images/atoms_logo.svg
new file mode 100644
index 00000000..2fd8c0cf
--- /dev/null
+++ b/assets/images/atoms_logo.svg
@@ -0,0 +1,52 @@
+
diff --git a/assets/images/copilot_chat.svg b/assets/images/copilot_chat.svg
new file mode 100644
index 00000000..8ea825bb
--- /dev/null
+++ b/assets/images/copilot_chat.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/copilot_history.svg b/assets/images/copilot_history.svg
new file mode 100644
index 00000000..0bc98f28
--- /dev/null
+++ b/assets/images/copilot_history.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/copilot_logo.svg b/assets/images/copilot_logo.svg
new file mode 100644
index 00000000..6023bfb9
--- /dev/null
+++ b/assets/images/copilot_logo.svg
@@ -0,0 +1,45 @@
+
diff --git a/assets/images/copilot_send_msg.svg b/assets/images/copilot_send_msg.svg
new file mode 100644
index 00000000..aded8642
--- /dev/null
+++ b/assets/images/copilot_send_msg.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/border_beam/border_beam.dart b/lib/border_beam/border_beam.dart
new file mode 100644
index 00000000..43b4e574
--- /dev/null
+++ b/lib/border_beam/border_beam.dart
@@ -0,0 +1,6 @@
+
+library;
+
+export 'src/beam_clock.dart' show BeamClock;
+export 'src/beam_style.dart' show BeamSize, BeamColorVariant, BeamTheme;
+export 'src/border_beam.dart' show BorderBeam;
diff --git a/lib/border_beam/src/beam_clock.dart b/lib/border_beam/src/beam_clock.dart
new file mode 100644
index 00000000..caa960af
--- /dev/null
+++ b/lib/border_beam/src/beam_clock.dart
@@ -0,0 +1,41 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/scheduler.dart';
+class BeamClock extends ChangeNotifier implements ValueListenable {
+ BeamClock._();
+ static final BeamClock instance = BeamClock._();
+
+ Ticker? _ticker;
+ int _attachCount = 0;
+ double _base = 0;
+ double _value = 0;
+ @override
+ double get value => _value;
+
+ @visibleForTesting
+ bool get isRunning => _ticker != null;
+
+ @visibleForTesting
+ int get attachCount => _attachCount;
+
+ void attach() {
+ _attachCount++;
+ if (_ticker == null) {
+ _base = _value;
+ _ticker = Ticker(_onTick)..start();
+ }
+ }
+
+ void detach() {
+ if (_attachCount == 0) return;
+ _attachCount--;
+ if (_attachCount == 0) {
+ _ticker?.dispose();
+ _ticker = null;
+ }
+ }
+
+ void _onTick(Duration elapsed) {
+ _value = _base + elapsed.inMicroseconds / Duration.microsecondsPerSecond;
+ notifyListeners();
+ }
+}
diff --git a/lib/border_beam/src/beam_painter.dart b/lib/border_beam/src/beam_painter.dart
new file mode 100644
index 00000000..46cbf498
--- /dev/null
+++ b/lib/border_beam/src/beam_painter.dart
@@ -0,0 +1,317 @@
+import 'dart:math' as math;
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+
+import 'beam_style.dart';
+
+@immutable
+class AmbientBlobFrame {
+ const AmbientBlobFrame({
+ required this.pathFraction,
+ required this.restFraction,
+ required this.color,
+ required this.alpha,
+ });
+
+ final double pathFraction;
+ final double restFraction;
+ final Color color;
+ final double alpha;
+}
+
+@visibleForTesting
+const ambientDriftHz = [0.047, 0.069, 0.091, 0.113, 0.135];
+
+/// See [ambientDriftHz].
+@visibleForTesting
+const ambientBreatheHz = [0.14, 0.185, 0.23, 0.275, 0.32];
+
+@visibleForTesting
+List ambientBlobFrames({
+ required BeamPreset preset,
+ required double time,
+ required BeamColorVariant variant,
+ required BeamTheme theme,
+ required double bloom,
+}) {
+ final frames = [];
+ for (var i = 0; i < preset.blobCount; i++) {
+ final rest = (i * 0.6180339887) % 1.0;
+ final driftSpeed = ambientDriftHz[i % ambientDriftHz.length];
+ final breatheSpeed = ambientBreatheHz[i % ambientBreatheHz.length];
+ final phase = i * 2.399963; // decorrelates blobs from each other
+
+ final wobble = math.sin(time * driftSpeed * 2 * math.pi + phase);
+ final pathFraction = BeamPainter._wrap(rest + preset.driftAmplitude * wobble);
+
+ // Hue drifts slowly per-blob on top of a slow overall creep, so
+ // colours shift gradually rather than sweeping in one direction.
+ final hue = rest + time * preset.revolutionsPerSecond + i * 0.07;
+ final color = BeamPalette.colorAt(variant, hue, theme);
+
+ final breathe = 0.55 + 0.45 * math.sin(time * breatheSpeed + phase * 1.7);
+ final alpha = (breathe * bloom).clamp(0.0, 1.0).toDouble();
+
+ frames.add(AmbientBlobFrame(
+ pathFraction: pathFraction,
+ restFraction: rest,
+ color: color,
+ alpha: alpha,
+ ));
+ }
+ return frames;
+}
+
+class BeamPainter extends CustomPainter {
+ BeamPainter({
+ required this.clock,
+ required this.timeOffset,
+ required this.speed,
+ required this.preset,
+ required this.variant,
+ required this.theme,
+ required this.strength,
+ required this.radius,
+ required this.inset,
+ double? frozenTime,
+ }) : frozenTime = frozenTime,
+ super(repaint: frozenTime == null ? clock : null);
+
+ final ValueListenable clock;
+ final double timeOffset;
+ final double speed;
+ final BeamPreset preset;
+ final BeamColorVariant variant;
+ final BeamTheme theme;
+
+ /// 0..1 glow intensity, as handed to the widget.
+ final double strength;
+
+ /// Corner radius of the border the beam rides.
+ final double radius;
+ final double inset;
+ final double? frozenTime;
+
+ // Building the path and its metric is the expensive part, so both are
+ // cached and only rebuilt when the box actually changes.
+ Size? _cachedSize;
+ double? _cachedRadius;
+ double? _cachedInset;
+ ui.PathMetric? _metric;
+ Path? _path;
+
+ double get _time => frozenTime ?? (clock.value - timeOffset) * speed;
+
+ void _ensurePath(Size size) {
+ if (_metric != null &&
+ _cachedSize == size &&
+ _cachedRadius == radius &&
+ _cachedInset == inset) {
+ return;
+ }
+ final rect = Offset.zero & size;
+ final grown = rect.inflate(inset);
+ // Clamp so a radius larger than the box can't produce a degenerate path.
+ final r = math.min(radius + inset, math.min(grown.width, grown.height) / 2);
+ final rrect = RRect.fromRectAndRadius(grown, Radius.circular(math.max(0, r)));
+ final path = Path()..addRRect(rrect);
+ final metrics = path.computeMetrics().toList(growable: false);
+
+ _path = path;
+ _metric = metrics.isEmpty ? null : metrics.first;
+ _cachedSize = size;
+ _cachedRadius = radius;
+ _cachedInset = inset;
+ }
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (size.isEmpty) return;
+ _ensurePath(size);
+ final metric = _metric;
+ final path = _path;
+ if (metric == null || path == null || metric.length <= 0) return;
+
+ final bloom = BeamPalette.bloomFor(strength.clamp(0.0, 1.0).toDouble());
+ if (preset.isPulse) {
+ _paintPulse(canvas, size, path, bloom);
+ } else if (preset.isAmbient) {
+ _paintAmbient(canvas, metric, bloom);
+ } else {
+ _paintComet(canvas, metric, bloom);
+ }
+ }
+
+ /// A comet with a fading tail, riding the perimeter.
+ void _paintComet(Canvas canvas, ui.PathMetric metric, double bloom) {
+ final length = metric.length;
+ final head = _time * preset.revolutionsPerSecond;
+ final tail = preset.tailFraction;
+ final segments = preset.segments;
+
+ // Bloom pass first, crisp core over it.
+ for (final isGlow in const [true, false]) {
+ final paint = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round
+ ..isAntiAlias = true;
+ if (isGlow) {
+ paint.maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * bloom);
+ }
+
+ Offset? previous;
+ for (var i = 0; i <= segments; i++) {
+ final f = i / segments;
+ final at = head - f * tail;
+ final point = metric.getTangentForOffset(_wrap(at) * length)?.position;
+ if (point == null) {
+ previous = null;
+ continue;
+ }
+ if (previous != null) {
+ // Squared falloff gives a bright head and a long, soft tail.
+ final fade = (1 - f) * (1 - f);
+ final alpha = fade * bloom * (isGlow ? 0.75 : 1.0);
+ if (alpha > 0.004) {
+ paint
+ ..color = BeamPalette.colorAt(variant, at, theme)
+ .withAlpha((alpha.clamp(0.0, 1.0) * 255).round())
+ ..strokeWidth =
+ preset.strokeWidth * (isGlow ? 2.6 : 1.0) * (0.55 + 0.45 * fade);
+ canvas.drawLine(previous, point, paint);
+ }
+ }
+ previous = point;
+ }
+ }
+ }
+
+ /// Soft, independently-drifting colour blobs along the border — an
+ /// ambient light leak rather than a travelling comet.
+ ///
+ /// The motion and colour math lives in [ambientBlobFrames], kept separate
+ /// from canvas calls so it can be tested without rendering anything.
+ void _paintAmbient(Canvas canvas, ui.PathMetric metric, double bloom) {
+ final length = metric.length;
+ final blobRadius = preset.blobRadius;
+ final additive = theme == BeamTheme.dark;
+ final blend = additive ? BlendMode.plus : BlendMode.srcOver;
+
+ for (final frame in ambientBlobFrames(
+ preset: preset,
+ time: _time,
+ variant: variant,
+ theme: theme,
+ bloom: bloom,
+ )) {
+ final tangent = metric.getTangentForOffset(frame.pathFraction * length);
+ if (tangent == null) continue;
+
+ canvas.save();
+ canvas.translate(tangent.position.dx, tangent.position.dy);
+ canvas.rotate(tangent.angle);
+ // Stretched along the edge, flattened across it, so each blob hugs
+ // the border instead of reading as a perfect floating dot.
+ canvas.scale(1.5, 0.85);
+
+ // Wide, soft wash first...
+ canvas.drawCircle(
+ Offset.zero,
+ blobRadius,
+ Paint()
+ ..color = frame.color.withAlpha(_alpha8(frame.alpha * 0.5))
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma)
+ ..blendMode = blend,
+ );
+ // ...then a tighter, slightly brighter core so the blob still reads
+ // as a source of colour rather than pure haze.
+ canvas.drawCircle(
+ Offset.zero,
+ blobRadius * 0.5,
+ Paint()
+ ..color = frame.color.withAlpha(_alpha8(frame.alpha * 0.8))
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * 0.45)
+ ..blendMode = blend,
+ );
+ canvas.restore();
+ }
+ }
+
+ static int _alpha8(double a) => (a.clamp(0.0, 1.0) * 255).round();
+
+ /// The whole rim, breathing in or out.
+ void _paintPulse(Canvas canvas, Size size, Path path, double bloom) {
+ final breath = 0.45 + 0.55 * ((math.sin(_time * 2 * math.pi * 0.35) + 1) / 2);
+ final spin = _time * preset.revolutionsPerSecond;
+ final rect = (Offset.zero & size).inflate(inset);
+ final alpha = (breath * bloom).clamp(0.0, 1.0);
+ Color rampAt(int i, double a) => BeamPalette
+ .colorAt(variant, i / 6 + spin, theme)
+ .withAlpha((a.clamp(0.0, 1.0) * 255).round());
+
+ final stops = List.generate(7, (i) => i / 6);
+ final glowShader = ui.Gradient.sweep(
+ rect.center,
+ [for (var i = 0; i <= 6; i++) rampAt(i, alpha)],
+ stops,
+ );
+ final coreShader = ui.Gradient.sweep(
+ rect.center,
+ [for (var i = 0; i <= 6; i++) rampAt(i, alpha * 0.7)],
+ stops,
+ );
+
+ canvas.save();
+ if (preset.size == BeamSize.pulseInner) {
+ // Keep the glow on the surface: clip to the shape itself.
+ canvas.clipPath(path);
+ } else {
+ // Keep the glow outside: punch the shape out of a generous bounds.
+ canvas.clipPath(
+ Path.combine(
+ PathOperation.difference,
+ Path()..addRect(rect.inflate(preset.bleed * 2)),
+ path,
+ ),
+ );
+ }
+
+ canvas.drawPath(
+ path,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = preset.strokeWidth * 3.2
+ ..shader = glowShader
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * bloom),
+ );
+ canvas.restore();
+
+ // A restrained core so the rim still reads as an edge, not just haze.
+ canvas.drawPath(
+ path,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = preset.strokeWidth
+ ..shader = coreShader,
+ );
+ }
+
+ /// Wraps to [0,1) for any input, including negatives from the tail.
+ static double _wrap(double v) => v - v.floorToDouble();
+
+ @override
+ bool shouldRepaint(covariant BeamPainter old) {
+ return old.clock != clock ||
+ old.timeOffset != timeOffset ||
+ old.speed != speed ||
+ old.preset != preset ||
+ old.variant != variant ||
+ old.theme != theme ||
+ old.strength != strength ||
+ old.radius != radius ||
+ old.inset != inset ||
+ old.frozenTime != frozenTime;
+ }
+}
diff --git a/lib/border_beam/src/beam_style.dart b/lib/border_beam/src/beam_style.dart
new file mode 100644
index 00000000..2da4302b
--- /dev/null
+++ b/lib/border_beam/src/beam_style.dart
@@ -0,0 +1,231 @@
+import 'dart:ui' show Color, lerpDouble;
+
+enum BeamSize {
+ /// Medium travelling comet — the default, tuned for cards.
+ md,
+
+ /// Smaller, tighter comet, for buttons and inputs.
+ sm,
+
+ /// A crisp hairline with barely any bloom.
+ line,
+
+ /// The whole rim breathes, glowing inwards over the surface.
+ pulseInner,
+ pulseOutside,
+ ambient,
+}
+
+/// Colour ramps the beam cycles through.
+enum BeamColorVariant {
+ /// Full rainbow sweep.
+ colorful,
+
+ /// Single ink, resolved from the beam's theme.
+ mono,
+
+ /// Deep blue through cyan.
+ ocean,
+
+ /// Amber through pink to violet.
+ sunset,
+}
+
+/// Light or dark rendering, for [BeamColorVariant.mono] and overall bloom.
+enum BeamTheme { light, dark }
+
+/// Per-[BeamSize] tuning. These are separate designs, not one design scaled:
+/// each carries its own stroke, bloom, comet length and travel speed.
+class BeamPreset {
+ const BeamPreset({
+ required this.size,
+ required this.strokeWidth,
+ required this.glowSigma,
+ required this.tailFraction,
+ required this.revolutionsPerSecond,
+ required this.segments,
+ required this.isPulse,
+ this.isAmbient = false,
+ this.blobCount = 0,
+ this.blobRadius = 0,
+ this.driftAmplitude = 0,
+ });
+
+ /// Which variant this preset describes.
+ final BeamSize size;
+
+ /// Width of the crisp core stroke, in logical pixels.
+ final double strokeWidth;
+
+ /// Blur sigma of the bloom pass.
+ final double glowSigma;
+
+ /// How much of the perimeter the comet's tail covers, 0..1.
+ final double tailFraction;
+
+ /// Laps around the border per second at `speed: 1`.
+ final double revolutionsPerSecond;
+
+ /// Segments used to draw the comet. More is smoother, and costs more.
+ final int segments;
+
+ /// Whether this preset pulses the whole rim instead of travelling.
+ final bool isPulse;
+
+ /// Whether this preset is the ambient blob field rather than a comet.
+ final bool isAmbient;
+
+ /// How many independent blobs drift along the border.
+ final int blobCount;
+
+ /// A blob's radius, in logical pixels.
+ ///
+ /// Fixed rather than a fraction of the wrapped box: every other preset
+ /// here sizes itself in absolute pixels regardless of what it wraps,
+ /// and [bleed] can only reserve a fixed amount of padding — sizing this
+ /// as a fraction of the box would make the blobs outgrow that padding on
+ /// a large card and get clipped by whatever ancestor the padding was
+ /// meant to protect against.
+ final double blobRadius;
+
+ /// How far each blob wanders from its resting point on the perimeter,
+ /// as a fraction of the perimeter's length.
+ final double driftAmplitude;
+
+ static const Map _presets = {
+ BeamSize.md: BeamPreset(
+ size: BeamSize.md,
+ strokeWidth: 2.4,
+ glowSigma: 13,
+ tailFraction: 0.34,
+ revolutionsPerSecond: 0.42,
+ segments: 52,
+ isPulse: false,
+ ),
+ BeamSize.sm: BeamPreset(
+ size: BeamSize.sm,
+ strokeWidth: 1.6,
+ glowSigma: 7,
+ tailFraction: 0.24,
+ revolutionsPerSecond: 0.55,
+ segments: 40,
+ isPulse: false,
+ ),
+ BeamSize.line: BeamPreset(
+ size: BeamSize.line,
+ strokeWidth: 1.2,
+ glowSigma: 2.5,
+ tailFraction: 0.18,
+ revolutionsPerSecond: 0.7,
+ segments: 34,
+ isPulse: false,
+ ),
+ BeamSize.pulseInner: BeamPreset(
+ size: BeamSize.pulseInner,
+ strokeWidth: 2.0,
+ glowSigma: 17,
+ tailFraction: 1,
+ revolutionsPerSecond: 0.16,
+ segments: 1,
+ isPulse: true,
+ ),
+ BeamSize.pulseOutside: BeamPreset(
+ size: BeamSize.pulseOutside,
+ strokeWidth: 2.0,
+ glowSigma: 21,
+ tailFraction: 1,
+ revolutionsPerSecond: 0.16,
+ segments: 1,
+ isPulse: true,
+ ),
+ BeamSize.ambient: BeamPreset(
+ size: BeamSize.ambient,
+ strokeWidth: 0,
+ // A wide, soft blur reads as a light leak; a narrow one reads as a
+ // stroke, which is exactly the "linear" look this mode replaces.
+ glowSigma: 18,
+ tailFraction: 0,
+ // Slow overall hue creep, independent of any one blob's drift.
+ revolutionsPerSecond: 0.025,
+ segments: 1,
+ isPulse: false,
+ isAmbient: true,
+ blobCount: 5,
+ // Calibrated against a reference screenshot of the effect: a glow
+ // that reads as a soft light leak hugging the edge, not a halo that
+ // dwarfs the element it's on.
+ blobRadius: 28,
+ driftAmplitude: 0.045,
+ ),
+ };
+
+ static BeamPreset of(BeamSize size) => _presets[size]!;
+
+ /// How far the bloom reaches past the border, so the widget can reserve
+ /// room and the glow isn't clipped by an ancestor.
+ ///
+ /// Additive, not a max of the two terms: a blob's visual reach is its own
+ /// radius *plus* however far its blur bleeds past that radius, and both
+ /// terms are present at once. The `blobRadius * 1.5` rather than `* 1`
+ /// accounts for the tangential stretch the painter applies to each blob
+ /// (`canvas.scale(1.5, 0.85)`) — `bleed` pads every side equally, so it
+ /// has to cover the wider of the two axes.
+ double get bleed => glowSigma * 2.2 + strokeWidth + blobRadius * 1.5;
+}
+
+/// Colour ramps, sampled by [colorAt].
+class BeamPalette {
+ const BeamPalette._();
+
+ static const List _colorful = [
+ Color(0xFFFF4D4D),
+ Color(0xFFFFB020),
+ Color(0xFF32D74B),
+ Color(0xFF32ADE6),
+ Color(0xFF7A5CFF),
+ Color(0xFFFF4DA6),
+ ];
+
+ static const List _ocean = [
+ Color(0xFF0B3D91),
+ Color(0xFF1E6FD9),
+ Color(0xFF29C7D6),
+ Color(0xFF7FF3E1),
+ Color(0xFF1E6FD9),
+ ];
+
+ static const List _sunset = [
+ Color(0xFFFFC24D),
+ Color(0xFFFF7A3D),
+ Color(0xFFFF4D8D),
+ Color(0xFF9B4DFF),
+ Color(0xFFFF7A3D),
+ ];
+
+ /// Samples the ramp for [variant] at [u], wrapping so the ring has no seam.
+ static Color colorAt(BeamColorVariant variant, double u, BeamTheme theme) {
+ if (variant == BeamColorVariant.mono) {
+ return theme == BeamTheme.dark
+ ? const Color(0xFFFFFFFF)
+ : const Color(0xFF111111);
+ }
+ final ramp = switch (variant) {
+ BeamColorVariant.colorful => _colorful,
+ BeamColorVariant.ocean => _ocean,
+ BeamColorVariant.sunset => _sunset,
+ BeamColorVariant.mono => _colorful, // unreachable, handled above
+ };
+ // Wrap into [0,1) even for negative input, then walk the ramp as a loop.
+ final wrapped = u - u.floorToDouble();
+ final scaled = wrapped * ramp.length;
+ final i = scaled.floor() % ramp.length;
+ final next = (i + 1) % ramp.length;
+ final f = scaled - scaled.floorToDouble();
+ return Color.lerp(ramp[i], ramp[next], f) ?? ramp[i];
+ }
+
+ /// Eased strength curve: low `strength` values stay subtle instead of
+ /// jumping straight to a heavy bloom.
+ static double bloomFor(double strength) =>
+ lerpDouble(0.18, 1.0, strength * strength)!;
+}
diff --git a/lib/border_beam/src/border_beam.dart b/lib/border_beam/src/border_beam.dart
new file mode 100644
index 00000000..64288e29
--- /dev/null
+++ b/lib/border_beam/src/border_beam.dart
@@ -0,0 +1,168 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+import 'beam_clock.dart';
+import 'beam_painter.dart';
+import 'beam_style.dart';
+
+class BorderBeam extends StatefulWidget {
+ const BorderBeam({
+ super.key,
+ required this.child,
+ this.size = BeamSize.md,
+ this.colorVariant = BeamColorVariant.colorful,
+ this.strength = 0.7,
+ this.active = true,
+ this.theme,
+ this.borderRadius = 16,
+ this.speed = 1.0,
+ this.reserveGlowSpace = true,
+ }) : assert(strength >= 0 && strength <= 1, 'strength must be 0..1'),
+ assert(speed > 0, 'speed must be positive'),
+ assert(borderRadius >= 0, 'borderRadius must not be negative');
+
+ /// The widget the beam rides around.
+ final Widget child;
+
+ /// Which beam treatment to use.
+ final BeamSize size;
+
+ /// Which colour ramp the beam cycles through.
+ final BeamColorVariant colorVariant;
+
+ /// Glow intensity, 0..1. Eased, so low values stay genuinely subtle.
+ final double strength;
+
+ /// Freezes the beam on its current frame, and releases its hold on the
+ /// shared ticker, when `false`. Re-activating resumes from that frame.
+ final bool active;
+
+ /// Light or dark rendering. Defaults to the surrounding [Theme] brightness.
+ final BeamTheme? theme;
+
+ /// Corner radius of the border the beam follows. Match this to your
+ /// child's own radius.
+ final double borderRadius;
+
+ /// Multiplier on the preset's baked travel speed.
+ final double speed;
+
+ /// Whether to pad the widget so the bloom has room to spill.
+ ///
+ /// The beam paints outside the child's box, which an ancestor that clips
+ /// (a `ListView`, a `ClipRRect`) would cut off. Leaving this `true` adds
+ /// outer padding sized to the preset's bloom. Set it `false` if you'd
+ /// rather manage spacing yourself and are sure nothing clips.
+ final bool reserveGlowSpace;
+
+ @override
+ State createState() => _BorderBeamState();
+}
+
+class _BorderBeamState extends State {
+ /// Frame shown when animation is suppressed, picked so the comet sits
+ /// somewhere recognisable rather than at a seam.
+ static const double _staticFrame = 0.6;
+
+ final BeamClock _clock = BeamClock.instance;
+
+ bool _attached = false;
+ double _offset = 0;
+ double? _frozenTime;
+
+ double get _currentTime => _frozenTime ?? (_clock.value - _offset) * widget.speed;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _sync();
+ }
+
+ @override
+ void didUpdateWidget(covariant BorderBeam old) {
+ super.didUpdateWidget(old);
+ _sync();
+ }
+
+ /// Re-derives whether the beam should run, keeping the animation
+ /// continuous across every transition.
+ ///
+ /// [MediaQuery] and [TickerMode] are read here rather than in a tick
+ /// callback: resolving an inherited widget outside build or a lifecycle
+ /// callback isn't safe.
+ void _sync() {
+ final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
+ final tickerEnabled = TickerMode.of(context);
+ final shouldRun = widget.active && !reduceMotion && tickerEnabled;
+
+ if (reduceMotion) {
+ _frozenTime = _staticFrame;
+ } else if (!shouldRun) {
+ _frozenTime ??= _currentTime;
+ } else {
+ // Rebase so resuming continues from the frozen frame, and a speed
+ // change bends the curve instead of teleporting.
+ _offset = _clock.value - (_frozenTime ?? _currentTime) / widget.speed;
+ _frozenTime = null;
+ }
+
+ _setAttached(shouldRun);
+ }
+
+ void _setAttached(bool value) {
+ if (value == _attached) return;
+ _attached = value;
+ if (value) {
+ _clock.attach();
+ } else {
+ _clock.detach();
+ }
+ }
+
+ @override
+ void dispose() {
+ _setAttached(false);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final preset = BeamPreset.of(widget.size);
+ final theme = widget.theme ?? (Theme.of(context).brightness == Brightness.dark ? BeamTheme.dark : BeamTheme.light);
+
+ // Inner pulses and the ambient field already sit centered on the
+ // border path; the travelling styles read better nudged just outside
+ // the child's edge.
+ final zeroInset = preset.size == BeamSize.pulseInner || preset.size == BeamSize.ambient;
+ final inset = zeroInset ? 0.0 : preset.strokeWidth;
+
+ Widget painted = CustomPaint(
+ foregroundPainter: BeamPainter(
+ clock: _clock,
+ timeOffset: _offset,
+ speed: widget.speed,
+ preset: preset,
+ variant: widget.colorVariant,
+ theme: theme,
+ strength: widget.strength,
+ radius: widget.borderRadius,
+ inset: inset,
+ frozenTime: _frozenTime,
+ ),
+ isComplex: false,
+ willChange: _frozenTime == null,
+ child: widget.child,
+ );
+
+ // Isolated so the beam's per-frame repaint never dirties the child or
+ // its siblings. The painter is decorative and adds nothing to the
+ // semantics tree, so the child's own semantics come through untouched.
+ painted = RepaintBoundary(child: painted);
+
+ if (!widget.reserveGlowSpace) return painted;
+ return Padding(
+ padding: EdgeInsets.all(preset.bleed),
+ child: painted,
+ );
+ }
+}
diff --git a/lib/dashboard_latest/dashboard_view.dart b/lib/dashboard_latest/dashboard_view.dart
index af51b0bb..a2cf8f4f 100644
--- a/lib/dashboard_latest/dashboard_view.dart
+++ b/lib/dashboard_latest/dashboard_view.dart
@@ -4,8 +4,8 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:provider/provider.dart';
-import 'package:test_sa/controllers/notification/firebase_notification_manger.dart';
-import 'package:test_sa/controllers/notification/notification_manger.dart';
+import 'package:test_sa/border_beam/src/beam_style.dart';
+import 'package:test_sa/border_beam/src/border_beam.dart';
import 'package:test_sa/controllers/providers/api/all_requests_provider.dart';
import 'package:test_sa/controllers/providers/api/notifications_provider.dart';
import 'package:test_sa/controllers/providers/api/user_provider.dart';
@@ -19,8 +19,11 @@ import 'package:test_sa/dashboard_latest/widgets/requests_fragment.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
+import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/models/enums/user_types.dart';
import 'package:test_sa/models/user.dart';
+import 'package:test_sa/modules/copilot/controller/copilot_provider.dart';
+import 'package:test_sa/modules/copilot/screens/copilot_chat_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/swipe_module/circular_animated_widget.dart';
import 'package:test_sa/new_views/swipe_module/utils/swipe_general_utils.dart';
@@ -273,6 +276,25 @@ class _DashboardViewState extends State {
),
),
),
+ if (user != null && isUserFlowMedical == true)
+ Positioned(
+ right: 20.toScreenWidth,
+ bottom: 20.toScreenHeight,
+ child: Container(
+ padding: const EdgeInsets.all(0),
+ decoration: BoxDecoration(color: AppColor.background(context), shape: BoxShape.circle, border: Border.all(color: Colors.transparent)),
+ child: BorderBeam(
+ size: BeamSize.pulseInner,
+ reserveGlowSpace: false,
+ borderRadius: 35,
+ strength: 0.7,
+ child: "copilot_logo".toSvgAsset(width: 24, height: 24).paddingAll(16),
+ ),
+ ).onPress(() {
+ context.read().startNewConversation();
+ Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read().loadHistory());
+ }),
+ )
],
);
}
diff --git a/lib/extensions/text_extensions.dart b/lib/extensions/text_extensions.dart
index da73be78..777310c3 100644
--- a/lib/extensions/text_extensions.dart
+++ b/lib/extensions/text_extensions.dart
@@ -190,6 +190,9 @@ extension FilesExtension on String {
Image toPngAsset({int? width, int? height, Color? color, BoxFit fit = BoxFit.contain}) =>
Image.asset("assets/images/$this.png", width: width?.toScreenWidth, height: height?.toScreenHeight, color: color, fit: fit);
+ Image toJpgAsset({int? width, int? height, Color? color, BoxFit fit = BoxFit.contain}) =>
+ Image.asset("assets/images/$this.jpg", width: width?.toScreenWidth, height: height?.toScreenHeight, color: color, fit: fit);
+
LottieBuilder toLottieAsset({int? width, int? height, BoxFit fit = BoxFit.contain, bool repeat = true}) =>
Lottie.asset("assets/lottie/$this.json", width: width?.toScreenWidth, height: height?.toScreenHeight, fit: fit, repeat: repeat);
}
diff --git a/lib/modules/copilot/screens/copilot_chat_page.dart b/lib/modules/copilot/screens/copilot_chat_page.dart
index 16fdf37e..a629ec45 100644
--- a/lib/modules/copilot/screens/copilot_chat_page.dart
+++ b/lib/modules/copilot/screens/copilot_chat_page.dart
@@ -2,6 +2,8 @@ import 'dart:io';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:test_sa/border_beam/src/beam_style.dart';
+import 'package:test_sa/border_beam/src/border_beam.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/string_extensions.dart';
@@ -10,6 +12,7 @@ import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/modules/copilot/screens/copilot_history_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
+import 'package:test_sa/thinking_orbs/src/thinking_orb.dart';
import '../controller/copilot_provider.dart';
import '../widgets/copilot_message_bubble.dart';
@@ -114,7 +117,7 @@ class _CopilotChatPageState extends State {
IconButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotHistoryPage())),
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: "history_icon".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
+ icon: "copilot_history".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
tooltip: "History",
),
@@ -138,13 +141,22 @@ class _CopilotChatPageState extends State {
}
if (provider.messages.isEmpty) {
- return Text(
- "Send a message to start conversation",
- overflow: TextOverflow.ellipsis,
- maxLines: 1,
- style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500),
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ spacing: 24,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ "atoms_logo".toSvgAsset(width: 54),
+ Text(
+ "How Can I help you To Day?",
+ overflow: TextOverflow.ellipsis,
+ maxLines: 1,
+ style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500),
+ ),
+ ],
).center;
}
+
_scrollToBottom();
final messages = provider.messages;
return ListView.builder(
@@ -184,122 +196,136 @@ class _CopilotChatPageState extends State {
);
},
),
- const Divider(height: 1).defaultStyle(context),
SafeArea(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
- child: Row(
- children: [
- if (_inputState == _InputState.idle) ...[
- TextFormField(
- controller: _textController,
- cursorColor: AppColor.loadingColor(context),
- style: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 14, color: AppColor.textStyleColor(context)),
- minLines: 1,
- maxLines: 3,
- textInputAction: TextInputAction.none,
- keyboardType: TextInputType.multiline,
- decoration: InputDecoration(
- enabledBorder: InputBorder.none,
- focusedBorder: InputBorder.none,
- border: InputBorder.none,
- errorBorder: InputBorder.none,
- contentPadding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
- alignLabelWithHint: true,
- constraints: const BoxConstraints(),
- suffixIconConstraints: const BoxConstraints(),
- hintText: "Ask Copilot...",
- hintStyle: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 12, color: AppColor.lightTextColor(context)),
- ),
- ).expanded,
- IconButton(
- onPressed: _startRecording,
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
- constraints: const BoxConstraints(),
- ),
- ] else if (_inputState == _InputState.recording) ...[
- AudioWaveforms(
- size: Size(MediaQuery.of(context).size.width, 56.0),
- waveStyle: const WaveStyle(waveColor: AppColor.neutral50, extendWaveform: true, showMiddleLine: false),
- padding: const EdgeInsets.only(left: 16),
- recorderController: _recorderController,
- ).expanded,
- IconButton(
- onPressed: _stopRecording,
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: const Icon(Icons.stop_circle_rounded),
- constraints: const BoxConstraints(),
- ),
- ] else ...[
- if (_recordedPlayerController.playerState == PlayerState.playing)
- IconButton(
- onPressed: () async {
- await _recordedPlayerController.pausePlayer();
- await _recordedPlayerController.stopPlayer();
- setState(() {});
- },
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: const Icon(Icons.stop_circle_outlined, size: 20),
- constraints: const BoxConstraints(),
- )
- else
- IconButton(
- onPressed: () async {
- await _recordedPlayerController.preparePlayer(path: _recordedFilePath!);
- await _recordedPlayerController.startPlayer();
- setState(() {});
- },
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
- constraints: const BoxConstraints(),
- ),
- AudioFileWaveforms(
- playerController: _recordedPlayerController,
- waveformData: _recorderController.waveData,
- enableSeekGesture: false,
- continuousWaveform: false,
- waveformType: WaveformType.long,
- playerWaveStyle: const PlayerWaveStyle(
- fixedWaveColor: AppColor.neutral50,
- liveWaveColor: AppColor.primary10,
- showSeekLine: true,
- ),
- size: Size(MediaQuery.of(context).size.width, 56.0),
- ).expanded,
- IconButton(
- onPressed: _cancelRecording,
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: "delete_icon".toSvgAsset(width: 24, height: 24),
- constraints: const BoxConstraints(),
- ),
- ],
- if (_inputState != _InputState.recording)
- IconButton(
- splashColor: Colors.transparent,
- highlightColor: Colors.transparent,
- hoverColor: Colors.transparent,
- onPressed: () {
- if (_inputState == _InputState.recorded) {
- _sendRecording();
- } else {
- _sendText();
- }
- },
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: Consumer(
- builder: (_, provider, __) => provider.isStreaming
- ? const SizedBox(
- height: 24,
- width: 24,
- child: CircularProgressIndicator(color: AppColor.primary10, strokeWidth: 2),
- )
- : "chat_msg_send".toSvgAsset(width: 24, height: 24),
- ),
- constraints: const BoxConstraints(),
- ),
- 8.width,
- ],
+ child: Container(
+ height: 50,
+ margin: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: AppColor.background(context),
+ border: Border.all(width: 1, color: Colors.transparent),
+ borderRadius: BorderRadius.circular(35),
+ ),
+ child: BorderBeam(
+ size: BeamSize.pulseOutside,
+ reserveGlowSpace: false,
+ borderRadius: 35,
+ strength: 0.7,
+ child: Row(
+ children: [
+ if (_inputState == _InputState.idle) ...[
+ TextFormField(
+ controller: _textController,
+ cursorColor: AppColor.loadingColor(context),
+ style: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 14, color: AppColor.textStyleColor(context)),
+ minLines: 1,
+ maxLines: 3,
+ textInputAction: TextInputAction.none,
+ keyboardType: TextInputType.multiline,
+ decoration: InputDecoration(
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ border: InputBorder.none,
+ errorBorder: InputBorder.none,
+ contentPadding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
+ alignLabelWithHint: true,
+ constraints: const BoxConstraints(),
+ suffixIconConstraints: const BoxConstraints(),
+ hintText: "Ask anything...",
+ hintStyle: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 12, color: AppColor.lightTextColor(context)),
+ ),
+ ).expanded,
+ IconButton(
+ onPressed: _startRecording,
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
+ constraints: const BoxConstraints(),
+ ),
+ ] else if (_inputState == _InputState.recording) ...[
+ AudioWaveforms(
+ size: Size(MediaQuery.of(context).size.width, 24.0),
+ waveStyle: const WaveStyle(waveColor: AppColor.neutral50, extendWaveform: true, showMiddleLine: false),
+ padding: const EdgeInsets.only(left: 16),
+ recorderController: _recorderController,
+ ).expanded,
+ IconButton(
+ onPressed: _stopRecording,
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: const Icon(Icons.stop_circle_rounded),
+ constraints: const BoxConstraints(),
+ ),
+ ] else ...[
+ if (_recordedPlayerController.playerState == PlayerState.playing)
+ IconButton(
+ onPressed: () async {
+ await _recordedPlayerController.pausePlayer();
+ await _recordedPlayerController.stopPlayer();
+ setState(() {});
+ },
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: const Icon(Icons.stop_circle_outlined, size: 20),
+ constraints: const BoxConstraints(),
+ )
+ else
+ IconButton(
+ onPressed: () async {
+ await _recordedPlayerController.preparePlayer(path: _recordedFilePath!);
+ await _recordedPlayerController.startPlayer();
+ setState(() {});
+ },
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
+ constraints: const BoxConstraints(),
+ ),
+ AudioFileWaveforms(
+ playerController: _recordedPlayerController,
+ waveformData: _recorderController.waveData,
+ enableSeekGesture: false,
+ continuousWaveform: false,
+ waveformType: WaveformType.long,
+ playerWaveStyle: const PlayerWaveStyle(
+ fixedWaveColor: AppColor.neutral50,
+ liveWaveColor: AppColor.primary10,
+ showSeekLine: true,
+ ),
+ size: Size(MediaQuery.of(context).size.width, 24.0),
+ ).expanded,
+ IconButton(
+ onPressed: _cancelRecording,
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: "delete_icon".toSvgAsset(width: 24, height: 24),
+ constraints: const BoxConstraints(),
+ ),
+ ],
+ if (_inputState != _InputState.recording)
+ IconButton(
+ splashColor: Colors.transparent,
+ highlightColor: Colors.transparent,
+ hoverColor: Colors.transparent,
+ onPressed: () {
+ if (_inputState == _InputState.recorded) {
+ _sendRecording();
+ } else {
+ _sendText();
+ }
+ },
+ style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
+ icon: Consumer(
+ builder: (_, provider, __) => provider.isStreaming
+ ? const SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(color: AppColor.primary10, strokeWidth: 2),
+ )
+ : "copilot_send_msg".toSvgAsset(width: 24, height: 24),
+ ),
+ constraints: const BoxConstraints(),
+ ),
+ 8.width,
+ ],
+ ),
+ ),
),
),
),
diff --git a/lib/modules/copilot/screens/copilot_history_page.dart b/lib/modules/copilot/screens/copilot_history_page.dart
index f3d41425..a9f169de 100644
--- a/lib/modules/copilot/screens/copilot_history_page.dart
+++ b/lib/modules/copilot/screens/copilot_history_page.dart
@@ -67,13 +67,25 @@ class _CopilotHistoryPageState extends State {
appBar: DefaultAppBar(
title: "Copilot History",
actions: [
- IconButton(
- onPressed: _startNewConversation,
- style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
- icon: Icon(Icons.chat_bubble, color: AppColor.icon2Color(context)),
- constraints: const BoxConstraints(),
- tooltip: "New Chat",
- ),
+ Container(
+ margin: const EdgeInsets.only(left: 12, right: 12),
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
+ decoration: BoxDecoration(
+ color: AppColor.primary10,
+ borderRadius: BorderRadius.circular(35),
+ ),
+ child: Row(
+ spacing: 8,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ("edit_icon").toSvgAsset(height: 20, width: 20, color: AppColor.icon2Color(context)),
+ Text(
+ "New Chat",
+ style: AppTextStyles.bodyText2.copyWith(color: AppColor.textStyleColor(context)),
+ ),
+ ],
+ ),
+ ).onPress(_startNewConversation),
],
),
body: Consumer(
@@ -88,22 +100,12 @@ class _CopilotHistoryPageState extends State {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
+ spacing: 12,
children: [
+ Icon(Icons.search_off, color: AppColor.icon2Color(context), size: 24),
Text(
"No conversations yet",
- style: AppTextStyles.heading6.copyWith(
- color: AppColor.neutral120,
- ),
- ),
- 24.height,
- AppFilledButton(
- label: "Start a new conversation",
- maxWidth: true,
- buttonColor: AppColor.primary10,
- onPressed: _startNewConversation,
- ).paddingOnly(
- start: 48,
- end: 48,
+ style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context)),
),
],
).center;
@@ -175,10 +177,7 @@ class _CopilotHistoryPageState extends State {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 12,
children: [
- Icon(
- Icons.chat_bubble,
- color: AppColor.icon2Color(context),
- ),
+ ("copilot_chat").toSvgAsset(height: 24, width: 24, color: AppColor.icon2Color(context)),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
diff --git a/lib/modules/copilot/widgets/copilot_message_bubble.dart b/lib/modules/copilot/widgets/copilot_message_bubble.dart
index e62ea1a1..754fdcbe 100644
--- a/lib/modules/copilot/widgets/copilot_message_bubble.dart
+++ b/lib/modules/copilot/widgets/copilot_message_bubble.dart
@@ -1,10 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
+import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
+import 'package:test_sa/thinking_orbs/src/thinking_orb.dart';
import 'package:test_sa/views/widgets/item_views/info_text_widget.dart';
import 'package:test_sa/views/widgets/sound/sound_player.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -59,7 +61,7 @@ class CopilotMessageBubble extends StatelessWidget {
decoration: BoxDecoration(
color: AppColor.assetGroupBackground(context),
border: Border.all(color: AppColor.borderColor(context)),
- borderRadius: BorderRadius.circular(6),
+ borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -118,21 +120,20 @@ class CopilotMessageBubble extends StatelessWidget {
decoration: BoxDecoration(
color: AppColor.background(context),
border: Border.all(color: AppColor.borderColor(context)),
- borderRadius: BorderRadius.circular(6),
+ borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (showLoadingDots)
- const Align(
- alignment: Alignment.centerLeft,
- child: SizedBox(
- height: 16,
- width: 16,
- child: CircularProgressIndicator(strokeWidth: 2, color: AppColor.primary10),
- ),
- )
+ Align(
+ alignment: Alignment.centerLeft,
+ child: ThinkingOrb(
+ size: 48,
+ state: ThinkingOrbState.searching,
+ theme: context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light,
+ ))
else
_buildMessageContent(context),
// if (message.content.isNotEmpty && message.images.isNotEmpty) 8.height,
diff --git a/lib/new_views/common_widgets/app_drawer.dart b/lib/new_views/common_widgets/app_drawer.dart
index 3ef90ef7..66ab0b6f 100644
--- a/lib/new_views/common_widgets/app_drawer.dart
+++ b/lib/new_views/common_widgets/app_drawer.dart
@@ -78,12 +78,6 @@ class AppDrawer extends StatelessWidget {
// drawerItem("rate_us", context.translation.rateUs, context),
// 18.height,
drawerItem("setting", context.translation.settings, context).onPress(() => Navigator.of(context).pushNamed(SettingsPage.id)),
- 18.height,
- drawerItem("setting", "Copilot", context).onPress(() {
- context.read().startNewConversation();
- Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read().loadHistory());
- }),
-
18.height,
drawerItem("site_request_icon", "Site Request", context).onPress(() => Navigator.of(context).pushNamed(SiteRequestHistoryScreen.routeName)),
18.height,
diff --git a/lib/thinking_orbs/src/orb_clock.dart b/lib/thinking_orbs/src/orb_clock.dart
new file mode 100644
index 00000000..110474a6
--- /dev/null
+++ b/lib/thinking_orbs/src/orb_clock.dart
@@ -0,0 +1,49 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/scheduler.dart';
+
+class OrbClock extends ChangeNotifier implements ValueListenable {
+ OrbClock._();
+
+ /// The shared instance. Not disposed — it owns no resources while idle.
+ static final OrbClock instance = OrbClock._();
+
+ Ticker? _ticker;
+ int _attachCount = 0;
+ double _base = 0;
+ double _value = 0;
+
+ @override
+ double get value => _value;
+
+ /// Whether the ticker is currently running. For tests.
+ @visibleForTesting
+ bool get isRunning => _ticker != null;
+
+ /// Number of live subscribers. For tests.
+ @visibleForTesting
+ int get attachCount => _attachCount;
+
+ void attach() {
+ _attachCount++;
+ if (_ticker == null) {
+ // A restarted Ticker reports elapsed from zero again, so carry the
+ // previous value forward as a base and keep `value` monotonic.
+ _base = _value;
+ _ticker = Ticker(_onTick)..start();
+ }
+ }
+
+ void detach() {
+ if (_attachCount == 0) return;
+ _attachCount--;
+ if (_attachCount == 0) {
+ _ticker?.dispose();
+ _ticker = null;
+ }
+ }
+
+ void _onTick(Duration elapsed) {
+ _value = _base + elapsed.inMicroseconds / Duration.microsecondsPerSecond;
+ notifyListeners();
+ }
+}
diff --git a/lib/thinking_orbs/src/orb_painter.dart b/lib/thinking_orbs/src/orb_painter.dart
new file mode 100644
index 00000000..65880326
--- /dev/null
+++ b/lib/thinking_orbs/src/orb_painter.dart
@@ -0,0 +1,421 @@
+import 'dart:math' as math;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import 'orb_clock.dart';
+import 'thinking_orb.dart' show ThinkingOrbState;
+
+@immutable
+class OrbDot {
+ const OrbDot(this.x, this.y, this.r, this.opacity);
+
+ final double x;
+ final double y;
+ final double r;
+ final double opacity;
+}
+
+class OrbPainter extends CustomPainter {
+ OrbPainter({
+ required this.state,
+ required this.clock,
+ required this.timeOffset,
+ required this.speed,
+ required this.color,
+ required this.large,
+ double? frozenTime,
+ }) : frozenTime = frozenTime,
+ super(repaint: frozenTime == null ? clock : null);
+
+ /// Which of the nine animated states to render.
+ final ThinkingOrbState state;
+
+ /// The shared clock every orb reads, so instances stay in phase.
+ final ValueListenable clock;
+
+ /// Subtracted from the clock before scaling, so pausing and speed changes
+ /// don't make the animation jump.
+ final double timeOffset;
+
+ /// Multiplier on the state's baked-in speed.
+ final double speed;
+
+ /// Dot colour, fully opaque; per-dot alpha is applied at paint time.
+ final Color color;
+
+ /// `true` for the 64px-scale tuning (more, smaller dots), `false` for the
+ /// 20px-scale tuning (fewer, larger dots).
+ final bool large;
+
+ /// When non-null the orb is frozen on this timestamp: paused, or showing
+ /// the static representative frame for reduce-motion.
+ final double? frozenTime;
+
+ double get _time => frozenTime ?? (clock.value - timeOffset) * speed;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final center = size.center(Offset.zero);
+ final radius = size.shortestSide / 2;
+ if (radius <= 0) return;
+
+ final dots = dotsForState(state, _time, large);
+ final baseDotRadius = radius * (large ? 0.052 : 0.095);
+ // Leaves a hair of breathing room so a dot centred on the unit circle
+ // still paints fully inside the widget's box.
+ final spread = radius - baseDotRadius * 1.4;
+ final paint = Paint()
+ ..style = PaintingStyle.fill
+ ..isAntiAlias = true;
+
+ for (final dot in dots) {
+ final pos = center + Offset(dot.x * spread, dot.y * spread);
+ final dotRadius = (baseDotRadius * dot.r).clamp(0.35, radius).toDouble();
+ final alpha = (dot.opacity.clamp(0.0, 1.0) * 255).round();
+ if (alpha <= 0) continue;
+ // withAlpha rather than withOpacity: not deprecated on any Flutter
+ // version this package supports.
+ paint.color = color.withAlpha(alpha);
+ canvas.drawCircle(pos, dotRadius, paint);
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant OrbPainter old) {
+ return old.state != state ||
+ old.color != color ||
+ old.large != large ||
+ old.speed != speed ||
+ old.timeOffset != timeOffset ||
+ old.frozenTime != frozenTime ||
+ old.clock != clock;
+ }
+}
+
+@visibleForTesting
+List dotsForState(ThinkingOrbState state, double t, bool large) {
+ switch (state) {
+ case ThinkingOrbState.working:
+ return _working(t, large);
+ case ThinkingOrbState.searching:
+ return _searching(t, large);
+ case ThinkingOrbState.solving:
+ return _solving(t, large);
+ case ThinkingOrbState.listening:
+ return _listening(t, large);
+ case ThinkingOrbState.connecting:
+ return _connecting(t, large);
+ case ThinkingOrbState.weaving:
+ return _weaving(t, large);
+ case ThinkingOrbState.composing:
+ return _composing(t, large);
+ case ThinkingOrbState.breathing:
+ return _breathing(t, large);
+ case ThinkingOrbState.shaping:
+ return _shaping(t, large);
+ }
+}
+
+/// Half-width of the unit circle at height [v] — used to tuck bands and
+/// strands inside the orb instead of letting them run past its edge.
+double _chord(double v) {
+ final s = 1 - v * v;
+ return s <= 0 ? 0 : math.sqrt(s);
+}
+
+// ---------------------------------------------------------------------------
+// working — particles on tilted orbits
+// ---------------------------------------------------------------------------
+List _working(double t, bool large) {
+ final n = large ? 18 : 9;
+ const tilts = [0.0, math.pi / 3, -math.pi / 3];
+ final dots = [];
+
+ for (var i = 0; i < n; i++) {
+ final orbit = i % 3;
+ final tilt = tilts[orbit];
+ final speed = 0.55 + orbit * 0.16;
+ final angle = t * speed * 2 * math.pi + (i / n) * 2 * math.pi;
+
+ // Point on a squashed ellipse, then rotated into screen space by `tilt`
+ // so the three orbits genuinely cross rather than sitting flat.
+ final ax = math.cos(angle) * 0.94;
+ final ay = math.sin(angle) * 0.36;
+ final x = ax * math.cos(tilt) - ay * math.sin(tilt);
+ final y = ax * math.sin(tilt) + ay * math.cos(tilt);
+
+ // Minor axis carries depth: the particle is in front on the way "down".
+ final near = (math.sin(angle) + 1) / 2;
+ dots.add(OrbDot(x, y, 0.55 + 0.55 * near, 0.28 + 0.72 * near));
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// searching — a scan meridian sweeps a dotted globe
+// ---------------------------------------------------------------------------
+List _searching(double t, bool large) {
+ final latSteps = large ? 7 : 5;
+ final lonSteps = large ? 12 : 8;
+ final sweep = (t * 0.5 * 2 * math.pi) % (2 * math.pi);
+ final dots = [];
+
+ for (var la = 0; la < latSteps; la++) {
+ final lat = (la / (latSteps - 1) - 0.5) * math.pi * 0.82;
+ final rowRadius = math.cos(lat);
+ final y = math.sin(lat);
+ // Thin the rows out towards the poles so dots don't bunch up there.
+ final count = math.max(3, (lonSteps * rowRadius).round());
+
+ for (var lo = 0; lo < count; lo++) {
+ final lon = (lo / count) * 2 * math.pi;
+ final z = math.sin(lon) * rowRadius;
+ if (z < -0.25) continue; // cull most of the back face
+
+ var diff = (lon - sweep) % (2 * math.pi);
+ if (diff > math.pi) diff -= 2 * math.pi;
+ final beam = math.exp(-(diff * diff) / 0.1);
+ final front = (z + 1) / 2;
+
+ dots.add(OrbDot(
+ math.cos(lon) * rowRadius,
+ y,
+ 0.5 + 0.3 * front + beam * 0.35,
+ 0.16 + 0.32 * front + beam * 0.7,
+ ));
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// solving — bands scramble, then click back solved
+// ---------------------------------------------------------------------------
+List _solving(double t, bool large) {
+ final rows = large ? 6 : 4;
+ final cols = large ? 6 : 4;
+ final cycle = (t * 0.35) % 1.0;
+ final scramble = cycle < 0.7 ? math.sin(cycle / 0.7 * math.pi) : 0.0;
+ final dots = [];
+
+ for (var r = 0; r < rows; r++) {
+ final yBase = (r / (rows - 1) - 0.5) * 1.5;
+ for (var c = 0; c < cols; c++) {
+ final xBase = (c / (cols - 1) - 0.5) * 1.5;
+ // Keep the solved state a disc, not a square: drop corner cells.
+ if (xBase * xBase + yBase * yBase > 0.95) continue;
+
+ final seed = r * 31.0 + c * 7.0;
+ final jx = math.sin(seed * 12.9898 + t * (2 + (r + c) % 5)) * 0.45;
+ final jy = math.cos(seed * 7.233 + t * (2 + (r + c) % 3)) * 0.45;
+
+ // Clamp the displaced point by magnitude. Clamping each axis
+ // independently let corner dots reach |p| = 1.41 and paint outside.
+ var x = xBase + jx * scramble;
+ var y = yBase + jy * scramble;
+ final mag = math.sqrt(x * x + y * y);
+ if (mag > 1) {
+ x /= mag;
+ y /= mag;
+ }
+ dots.add(OrbDot(x, y, 0.75, 0.45 + 0.55 * (1 - scramble)));
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// listening — a waveform rolls through the rings
+// ---------------------------------------------------------------------------
+List _listening(double t, bool large) {
+ final rings = large ? 4 : 3;
+ final perRing = large ? 14 : 9;
+ final dots = [];
+
+ for (var ri = 0; ri < rings; ri++) {
+ final baseR = (ri + 1) / rings;
+ for (var i = 0; i < perRing; i++) {
+ final angle = (i / perRing) * 2 * math.pi;
+ final wave = math.sin(angle * 3 + t * 3 - ri * 0.6);
+ final r = baseR * (0.75 + 0.24 * wave);
+ final near = (wave + 1) / 2;
+ dots.add(OrbDot(
+ math.cos(angle) * r,
+ math.sin(angle) * r,
+ 0.55 + 0.45 * near,
+ 0.35 + 0.55 * near,
+ ));
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// connecting — a constellation wires itself
+// ---------------------------------------------------------------------------
+List _connecting(double t, bool large) {
+ final nodeCount = large ? 9 : 6;
+ // Two extra beats at the end so the finished constellation holds for a
+ // moment before the loop restarts, instead of snapping away.
+ final cycle = (t * 0.22) % 1.0;
+ final progress = cycle * (nodeCount + 2);
+ final revealed = math.min(progress.floor() + 1, nodeCount);
+
+ final nodes = [];
+ for (var i = 0; i < nodeCount; i++) {
+ final angle = i * 2.39996; // golden angle, keeps nodes well spread
+ final r = 0.32 + 0.58 * math.sqrt((i + 0.5) / nodeCount);
+ nodes.add(Offset(math.cos(angle) * r, math.sin(angle) * r));
+ }
+
+ final dots = [];
+ for (var i = 0; i < revealed; i++) {
+ final isNewest = i == revealed - 1 && revealed < nodeCount;
+ final pulse = isNewest ? 0.5 + 0.5 * math.sin((progress % 1.0) * math.pi) : 1.0;
+ dots.add(OrbDot(nodes[i].dx, nodes[i].dy, 0.7 + 0.5 * pulse, 0.45 + 0.55 * pulse));
+
+ if (i > 0) {
+ final a = nodes[i - 1];
+ final b = nodes[i];
+ const steps = 4;
+ for (var s = 1; s < steps; s++) {
+ final f = s / steps;
+ dots.add(OrbDot(
+ a.dx + (b.dx - a.dx) * f,
+ a.dy + (b.dy - a.dy) * f,
+ 0.32,
+ 0.3 * (isNewest ? pulse : 1.0),
+ ));
+ }
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// weaving — three strands plait around the sphere
+// ---------------------------------------------------------------------------
+List _weaving(double t, bool large) {
+ final perStrand = large ? 14 : 8;
+ final dots = [];
+
+ for (var s = 0; s < 3; s++) {
+ final phase = s * (2 * math.pi / 3);
+ for (var i = 0; i < perStrand; i++) {
+ final u = i / (perStrand - 1);
+ final y = (u - 0.5) * 1.78;
+ // Ride the sphere's silhouette instead of a bounding box, so the
+ // strands stay inside the orb at every height.
+ final halfWidth = _chord(y) * 0.98;
+ final theta = u * 4 * math.pi + t * 1.3 + phase;
+ final depth = math.sin(theta);
+ final front = (depth + 1) / 2;
+ dots.add(OrbDot(
+ math.cos(theta) * halfWidth,
+ y,
+ 0.5 + 0.45 * front,
+ 0.25 + 0.65 * front,
+ ));
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// composing — an undulating multi-band sash
+// ---------------------------------------------------------------------------
+List _composing(double t, bool large) {
+ final bands = large ? 4 : 3;
+ final perBand = large ? 13 : 8;
+ final dots = [];
+
+ for (var b = 0; b < bands; b++) {
+ final spread = bands == 1 ? 0.0 : (b / (bands - 1) - 0.5);
+ for (var i = 0; i < perBand; i++) {
+ final u = i / (perBand - 1);
+ final x = (u - 0.5) * 1.84;
+ // Bands converge towards the orb's edge rather than running past it.
+ final halfHeight = _chord(x) * 0.96;
+ final wave = math.sin(u * 4 * math.pi + t * 2.4 + b * 1.1);
+ final near = (wave + 1) / 2;
+ final y = (spread * 1.5 + wave * 0.28).clamp(-1.0, 1.0) * halfHeight;
+ dots.add(OrbDot(x, y, 0.5 + 0.4 * near, 0.38 + 0.52 * near));
+ }
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// breathing — a ring slowly morphing
+// ---------------------------------------------------------------------------
+List _breathing(double t, bool large) {
+ final n = large ? 24 : 13;
+ final breath = (math.sin(t * 1.05) + 1) / 2;
+ final dots = [];
+
+ for (var i = 0; i < n; i++) {
+ final angle = (i / n) * 2 * math.pi;
+ // Two slow harmonics on top of the breath, so the ring morphs shape as
+ // well as scale rather than just pulsing in and out.
+ final r = 0.60 +
+ 0.16 * breath +
+ 0.09 * math.sin(2 * angle + t * 0.7) +
+ 0.06 * math.sin(3 * angle - t * 0.45);
+ dots.add(OrbDot(
+ math.cos(angle) * r,
+ math.sin(angle) * r,
+ 0.55 + 0.45 * breath,
+ 0.42 + 0.48 * breath,
+ ));
+ }
+ return dots;
+}
+
+// ---------------------------------------------------------------------------
+// shaping — dotted outline: circle -> triangle -> square
+// ---------------------------------------------------------------------------
+List _shaping(double t, bool large) {
+ final n = large ? 28 : 16;
+ final phase = (t * 0.33) % 3.0;
+ final shapeA = phase.floor() % 3;
+ final shapeB = (shapeA + 1) % 3;
+ final morph = (1 - math.cos((phase - phase.floor()) * math.pi)) / 2;
+
+ final dots = [];
+ for (var i = 0; i < n; i++) {
+ final u = i / n;
+ final a = _pointOnShape(shapeA, u);
+ final b = _pointOnShape(shapeB, u);
+ dots.add(OrbDot(
+ (a.dx + (b.dx - a.dx) * morph) * 0.9,
+ (a.dy + (b.dy - a.dy) * morph) * 0.9,
+ 0.65,
+ 0.78,
+ ));
+ }
+ return dots;
+}
+
+Offset _pointOnShape(int shape, double u) {
+ switch (shape) {
+ case 0: // circle
+ final a = u * 2 * math.pi - math.pi / 2;
+ return Offset(math.cos(a), math.sin(a));
+ case 1: // triangle, point up
+ return _polygonPoint(u, 3, -math.pi / 2);
+ default: // square, sitting flat rather than on a corner
+ return _polygonPoint(u, 4, -math.pi / 4);
+ }
+}
+
+Offset _polygonPoint(double u, int sides, double rotation) {
+ final seg = u * sides;
+ final index = seg.floor() % sides;
+ final f = seg - seg.floor();
+ final a0 = index / sides * 2 * math.pi + rotation;
+ final a1 = (index + 1) / sides * 2 * math.pi + rotation;
+ final p0 = Offset(math.cos(a0), math.sin(a0));
+ final p1 = Offset(math.cos(a1), math.sin(a1));
+ return Offset(p0.dx + (p1.dx - p0.dx) * f, p0.dy + (p1.dy - p0.dy) * f);
+}
diff --git a/lib/thinking_orbs/src/thinking_orb.dart b/lib/thinking_orbs/src/thinking_orb.dart
new file mode 100644
index 00000000..039d9565
--- /dev/null
+++ b/lib/thinking_orbs/src/thinking_orb.dart
@@ -0,0 +1,203 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+import 'orb_clock.dart';
+import 'orb_painter.dart';
+
+enum ThinkingOrbState {
+ /// Particles on tilted orbits.
+ working,
+
+ /// A scan meridian sweeps a dotted globe.
+ searching,
+
+ /// Bands scramble, then click back solved.
+ solving,
+
+ /// A waveform rolls through the rings.
+ listening,
+
+ /// A constellation wires itself.
+ connecting,
+
+ /// Three strands plait around the sphere.
+ weaving,
+
+ /// An undulating multi-band sash.
+ composing,
+
+ /// A ring slowly morphing.
+ breathing,
+
+ /// Dotted outline: circle -> triangle -> square.
+ shaping,
+}
+
+/// How [ThinkingOrb] resolves its (strictly monochrome) dot colour.
+enum ThinkingOrbTheme {
+ /// Follow the ambient [Theme] brightness: light dots on dark backgrounds,
+ /// dark dots on light ones. The default.
+ auto,
+
+ /// Pin to light dots, for dark backgrounds.
+ dark,
+
+ /// Pin to dark dots, for light backgrounds.
+ light,
+}
+
+class ThinkingOrb extends StatefulWidget {
+ const ThinkingOrb({
+ super.key,
+ this.state = ThinkingOrbState.working,
+ this.size = 64,
+ this.speed = 1.0,
+ this.paused = false,
+ this.theme = ThinkingOrbTheme.auto,
+ this.color,
+ this.semanticLabel,
+ }) : assert(size > 0, 'size must be positive'),
+ assert(speed > 0, 'speed must be positive');
+
+ /// Which animated state to render.
+ final ThinkingOrbState state;
+
+ final double size;
+
+ final double speed;
+
+ final bool paused;
+
+ /// How to resolve the dot colour. Ignored when [color] is set.
+ final ThinkingOrbTheme theme;
+
+ /// Overrides [theme] with an explicit colour. Any alpha is ignored — the
+ /// orb applies its own per-dot alpha.
+ final Color? color;
+
+ /// Overrides the per-state default semantics label (e.g. "Searching…").
+ final String? semanticLabel;
+
+ /// Sizes at or above this use the large dot-field tuning.
+ static const double kLargePresetMinSize = 40;
+
+ @override
+ State createState() => _ThinkingOrbState();
+}
+
+class _ThinkingOrbState extends State {
+ /// The frame shown when animation is suppressed for reduce-motion — picked
+ /// so every state reads as recognisably itself rather than mid-blank.
+ static const double _staticFrame = 0.35;
+
+ final OrbClock _clock = OrbClock.instance;
+
+ bool _attached = false;
+ double _offset = 0;
+ double? _frozenTime;
+
+ double get _currentTime => _frozenTime ?? (_clock.value - _offset) * widget.speed;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _sync();
+ }
+
+ @override
+ void didUpdateWidget(covariant ThinkingOrb old) {
+ super.didUpdateWidget(old);
+ _sync();
+ }
+
+ void _sync() {
+ final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
+ // Respects the TickerMode an enclosing route sets, so orbs on a covered
+ // route stop animating for free.
+ final tickerEnabled = TickerMode.of(context);
+ final shouldRun = !widget.paused && !reduceMotion && tickerEnabled;
+
+ if (reduceMotion) {
+ _frozenTime = _staticFrame;
+ } else if (!shouldRun) {
+ // Freeze on whatever is on screen right now.
+ _frozenTime ??= _currentTime;
+ } else {
+ // Rebase so the resumed animation continues from the frozen frame, and
+ // so a speed change bends the curve instead of teleporting.
+ _rebaseTo(_frozenTime ?? _currentTime);
+ _frozenTime = null;
+ }
+
+ _setAttached(shouldRun);
+ }
+
+ /// Chooses an offset such that [_currentTime] evaluates to [time] right now.
+ void _rebaseTo(double time) {
+ _offset = _clock.value - time / widget.speed;
+ }
+
+ void _setAttached(bool value) {
+ if (value == _attached) return;
+ _attached = value;
+ if (value) {
+ _clock.attach();
+ } else {
+ _clock.detach();
+ }
+ }
+
+ @override
+ void dispose() {
+ _setAttached(false);
+ super.dispose();
+ }
+
+ Color _resolveColor() {
+ final explicit = widget.color;
+ if (explicit != null) return explicit.withAlpha(255);
+ final dark = switch (widget.theme) {
+ ThinkingOrbTheme.dark => true,
+ ThinkingOrbTheme.light => false,
+ ThinkingOrbTheme.auto => Theme.of(context).brightness == Brightness.dark,
+ };
+ return dark ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
+ }
+
+ String get _defaultLabel => switch (widget.state) {
+ ThinkingOrbState.working => 'Working',
+ ThinkingOrbState.searching => 'Searching',
+ ThinkingOrbState.solving => 'Solving',
+ ThinkingOrbState.listening => 'Listening',
+ ThinkingOrbState.connecting => 'Connecting',
+ ThinkingOrbState.weaving => 'Weaving',
+ ThinkingOrbState.composing => 'Composing',
+ ThinkingOrbState.breathing => 'Breathing',
+ ThinkingOrbState.shaping => 'Shaping',
+ };
+
+ @override
+ Widget build(BuildContext context) {
+ return Semantics(
+ label: widget.semanticLabel ?? _defaultLabel,
+ image: true,
+ container: true,
+ child: RepaintBoundary(
+ child: CustomPaint(
+ size: Size.square(widget.size),
+ isComplex: false,
+ willChange: _frozenTime == null,
+ painter: OrbPainter(
+ state: widget.state,
+ clock: _clock,
+ timeOffset: _offset,
+ speed: widget.speed,
+ color: _resolveColor(),
+ large: widget.size >= ThinkingOrb.kLargePresetMinSize,
+ frozenTime: _frozenTime,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/thinking_orbs/thinking_orbs.dart b/lib/thinking_orbs/thinking_orbs.dart
new file mode 100644
index 00000000..a0ae7998
--- /dev/null
+++ b/lib/thinking_orbs/thinking_orbs.dart
@@ -0,0 +1,4 @@
+library;
+
+export 'src/orb_clock.dart' show OrbClock;
+export 'src/thinking_orb.dart' show ThinkingOrb, ThinkingOrbState, ThinkingOrbTheme;