levit_flutter 0.0.11
levit_flutter: ^0.0.11 copied to clipboard
Deterministic ownership and fine-grained reactivity for Flutter applications.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:levit_flutter/levit_flutter.dart';
/// Runs the `levit_flutter` example application.
void main() {
// Auto-linking ensures controller-owned reactives are disposed with controller lifecycle.
Levit.enableAutoLinking();
runApp(const MyApp());
}
/// Immutable configuration and reactive state for a single simulated circle.
class CircleData {
/// A stable identifier for this circle.
final String id;
/// The display color for this circle.
final Color color;
/// The speed scalar used by the simulation.
final double speed;
// UI animations subscribe to these reactives for position/radius updates.
/// The current center position of the circle.
late final LxVar<Offset> position;
/// The current radius of the circle.
late final LxVar<double> radius;
/// Creates a circle with an initial position and radius.
///
/// [id] is the circle identifier.
/// [initialPosition] is the initial center position.
/// [initialRadius] is the initial radius.
/// [color] is the display color.
/// [speed] is the simulation speed scalar.
CircleData({
required this.id,
required Offset initialPosition,
required double initialRadius,
required this.color,
required this.speed,
}) {
position = LxVar(initialPosition).named('pos_$id');
radius = LxVar(initialRadius).named('rad_$id');
}
// Manual disposal is still required when a circle leaves the simulation.
/// Closes the reactive state owned by this circle.
void dispose() {
position.close();
radius.close();
}
}
/// A controller that simulates and renders a set of animated circles.
class CircleController extends LevitController
with
LevitLoopExecutionMixin,
LevitLoopExecutionLifecycleMixin,
LevitSelectionMixin<String> {
/// The active circles in the simulation.
final circles = LxList<CircleData>().named('circles');
/// Whether the simulation is currently paused.
final isSimulationPaused = LxVar(false).named('isSimulationPaused');
final _random = Random();
/// The current size of the canvas used for bounds checking.
Size canvasSize = Size.zero;
/// Initializes the controller and starts the simulation loops.
@override
void onInit() {
super.onInit();
autoDispose(circles);
// Spawn loop adds circles while canvas bounds are known.
loopEngine.startLoop('spawn', () async {
if (canvasSize != Size.zero) {
_spawnCircle();
}
}, delay: const Duration(milliseconds: 500));
// Path loop publishes new targets; UI tween handles interpolation.
loopEngine.startLoop('path', () async {
_updateCircleTargets();
}, delay: const Duration(milliseconds: 500));
// Shrink loop reduces radius and removes exhausted circles.
loopEngine.startLoop('shrink', () async {
_shrinkCircles();
}, delay: const Duration(milliseconds: 100));
}
void _spawnCircle() {
final id = 'c_${DateTime.now().microsecondsSinceEpoch}';
final initialRadius = 50.0; //20.0 + _random.nextDouble() * 30.0;
final initialPosition = Offset(
_random.nextDouble() * canvasSize.width,
_random.nextDouble() * canvasSize.height,
);
final circle = CircleData(
id: id,
initialPosition: initialPosition,
initialRadius: initialRadius,
color: Color.fromARGB(
255,
_random.nextInt(256),
_random.nextInt(256),
_random.nextInt(256),
),
speed: 1.0,
);
circles.add(circle);
}
void _updateCircleTargets() {
for (final circle in circles) {
// Random walk keeps movement local and visually continuous.
final offset = Offset(
(_random.nextDouble() - 0.5) * 200,
(_random.nextDouble() - 0.5) * 200,
);
var newPos = circle.position.value + offset;
// Clamp to canvas to keep circles visible.
newPos = Offset(
newPos.dx.clamp(0, canvasSize.width),
newPos.dy.clamp(0, canvasSize.height),
);
circle.position.value = newPos;
}
}
void _shrinkCircles() {
final toRemove = <CircleData>[];
for (final circle in circles) {
circle.radius.value -= 1.0;
if (circle.radius.value <= 0) {
toRemove.add(circle);
}
}
if (toRemove.isNotEmpty) {
for (final c in toRemove) {
deselect(c.id);
c.dispose();
}
circles.removeWhere((c) => toRemove.contains(c));
}
}
/// Updates [canvasSize] used by the simulation bounds.
void updateCanvasSize(Size size) {
canvasSize = size;
}
/// Pauses or resumes the simulation loops.
void toggleSimulation() {
isSimulationPaused.value = !isSimulationPaused.value;
if (isSimulationPaused.value) {
loopEngine.pauseAllServices();
} else {
loopEngine.resumeAllServices();
}
}
/// Resumes all loop services unless the simulation is manually paused.
void resumeAllServices({bool force = false}) {
// Manual pause state takes precedence over lifecycle-driven resume.
if (isSimulationPaused.value) return;
loopEngine.resumeAllServices(force: force);
}
}
/// The root widget for the example application.
class MyApp extends StatelessWidget {
/// Creates the root widget for the example application.
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Levit Circle Stream Canvas',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.blueAccent,
),
home: const CircleCanvasPage(),
);
}
}
/// A page that displays an animated circle simulation driven by a controller.
class CircleCanvasPage extends LScopedView<CircleController> {
/// Creates the circle canvas page.
const CircleCanvasPage({super.key});
/// Creates and registers the controller instance in the view scope.
@override
CircleController onConfigScope(LevitScope scope) {
return scope.put(() => CircleController());
}
/// Whether the view automatically rebuilds when reactive values change.
@override
bool get autoWatch => true;
/// Builds the view widget tree.
@override
Widget buildView(BuildContext context, CircleController controller) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text('Levit Bubbles'),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
Text(
'Selected: ${controller.selectionCount}',
style: TextStyle(fontSize: 16),
),
SizedBox(width: 8),
IconButton(
icon: Icon(
controller.isSimulationPaused.value
? Icons.play_arrow
: Icons.pause,
),
onPressed: () => controller.toggleSimulation(),
tooltip: controller.isSimulationPaused.value
? 'Resume Simulation'
: 'Pause Simulation',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => controller.circles.clear(),
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
controller.updateCanvasSize(size);
// Explicit watch keeps reactive tracking scoped to this builder.
return LWatch(
() => Stack(
children: [
for (final circle in controller.circles)
_AnimatedCircle(key: ValueKey(circle.id), data: circle),
],
),
);
},
),
);
}
}
class _AnimatedCircle extends LView<CircleController> {
final CircleData data;
const _AnimatedCircle({super.key, required this.data});
@override
Widget buildView(BuildContext context, CircleController controller) {
// Animated widgets smooth discrete simulation updates into continuous motion.
return AnimatedPositioned(
duration: const Duration(
milliseconds: 500,
), // Match controller pathLoop delay
curve: Curves.easeInOutSine, // Smooth navigation-like movement
left: data.position.value.dx - data.radius.value,
top: data.position.value.dy - data.radius.value,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) =>
Opacity(opacity: value, child: child),
child: GestureDetector(
onTap: () => controller.toggle(data.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: data.radius.value * 2,
height: data.radius.value * 2,
decoration: BoxDecoration(
color: data.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: data.color.withValues(alpha: 0.5),
blurRadius: 10,
spreadRadius: 2,
),
],
border: Border.all(
color: controller.isSelected(data.id)
? Colors.white
: Colors.white.withValues(alpha: 0.3),
width: controller.isSelected(data.id) ? 4.0 : 1.5,
),
),
),
),
),
);
}
}