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;