ai_wave_animations 0.2.0
ai_wave_animations: ^0.2.0 copied to clipboard
Assistant-style wave animations for Flutter: Gemini ribbons, Claude surfaces, Perplexity bars, a ChatGPT orb, plus ten frequency visualisers and a wave-filled play button. Controller driven and pure C [...]
import 'dart:async';
import 'dart:math' as math;
import 'package:ai_wave_animations/ai_wave_animations.dart';
import 'package:flutter/material.dart';
import 'pages/chatgpt_page.dart';
import 'pages/claude_page.dart';
import 'pages/gemini_page.dart';
import 'pages/perplexity_page.dart';
import 'pages/visualisers_page.dart';
import 'widgets/wave_stage.dart';
void main() => runApp(const WaveGalleryApp());
class WaveGalleryApp extends StatefulWidget {
const WaveGalleryApp({super.key});
@override
State<WaveGalleryApp> createState() => _WaveGalleryAppState();
}
class _WaveGalleryAppState extends State<WaveGalleryApp> {
ThemeMode _themeMode = ThemeMode.dark;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ai_wave_animations',
debugShowCheckedModeBanner: false,
themeMode: _themeMode,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF5489D6),
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF5489D6),
brightness: Brightness.dark,
),
home: GalleryPage(
themeMode: _themeMode,
onThemeModeChanged: (mode) => setState(() => _themeMode = mode),
),
);
}
}
class GalleryPage extends StatefulWidget {
const GalleryPage({
super.key,
required this.themeMode,
required this.onThemeModeChanged,
});
final ThemeMode themeMode;
final ValueChanged<ThemeMode> onThemeModeChanged;
@override
State<GalleryPage> createState() => _GalleryPageState();
}
class _GalleryPageState extends State<GalleryPage> {
final WaveController _controller = WaveController(
activity: WaveActivity.speaking,
);
final math.Random _random = math.Random();
int _index = 0;
bool _simulateMic = false;
Timer? _micTimer;
@override
void dispose() {
_micTimer?.cancel();
_controller.dispose();
super.dispose();
}
void _toggleMicSimulation(bool value) {
setState(() => _simulateMic = value);
_micTimer?.cancel();
if (!value) {
_controller.level = 0;
return;
}
// Stand-in for a real microphone RMS stream.
_micTimer = Timer.periodic(const Duration(milliseconds: 90), (_) {
final base =
0.5 + 0.5 * math.sin(DateTime.now().millisecondsSinceEpoch / 700);
_controller.level = (base * 0.7 + _random.nextDouble() * 0.3)
.clamp(0.0, 1.0)
.toDouble();
});
}
@override
Widget build(BuildContext context) {
final pages = <Widget>[
OverviewPage(controller: _controller),
GeminiPage(controller: _controller),
ClaudePage(controller: _controller),
PerplexityPage(controller: _controller),
ChatGptPage(controller: _controller),
VisualisersPage(controller: _controller),
];
const destinations = <NavigationDestination>[
NavigationDestination(icon: Icon(Icons.grid_view), label: 'All'),
NavigationDestination(icon: Icon(Icons.auto_awesome), label: 'Gemini'),
NavigationDestination(icon: Icon(Icons.waves), label: 'Claude'),
NavigationDestination(icon: Icon(Icons.equalizer), label: 'Perplexity'),
NavigationDestination(icon: Icon(Icons.blur_circular), label: 'ChatGPT'),
NavigationDestination(icon: Icon(Icons.graphic_eq), label: 'Visualisers'),
];
return Scaffold(
appBar: AppBar(
title: const Text('ai_wave_animations'),
actions: [
IconButton(
tooltip: 'Toggle theme',
icon: Icon(
widget.themeMode == ThemeMode.dark
? Icons.light_mode
: Icons.dark_mode,
),
onPressed: () => widget.onThemeModeChanged(
widget.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
),
],
),
body: Column(
children: [
_DriverBar(
controller: _controller,
simulateMic: _simulateMic,
onSimulateMicChanged: _toggleMicSimulation,
),
const Divider(height: 1),
Expanded(
child: IndexedStack(index: _index, children: pages),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
destinations: destinations,
onDestinationSelected: (i) => setState(() => _index = i),
),
);
}
}
/// The shared controller strip: activity, live level, play / pause.
class _DriverBar extends StatelessWidget {
const _DriverBar({
required this.controller,
required this.simulateMic,
required this.onSimulateMicChanged,
});
final WaveController controller;
final bool simulateMic;
final ValueChanged<bool> onSimulateMicChanged;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SegmentedButton<WaveActivity>(
showSelectedIcon: false,
segments: [
for (final activity in WaveActivity.values)
ButtonSegment(value: activity, label: Text(activity.name)),
],
selected: {controller.activity},
onSelectionChanged: (set) => controller.activity = set.first,
),
FilledButton.tonalIcon(
onPressed: controller.toggle,
icon: Icon(
controller.isAnimating ? Icons.pause : Icons.play_arrow,
),
label: Text(controller.isAnimating ? 'Pause' : 'Play'),
),
SizedBox(
width: 260,
child: Row(
children: [
const Text('level'),
Expanded(
child: Slider(
value: controller.level,
onChanged: simulateMic
? null
: (v) => controller.level = v,
),
),
Text(controller.level.toStringAsFixed(2)),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('simulate mic'),
Switch(value: simulateMic, onChanged: onSimulateMicChanged),
],
),
],
),
);
},
);
}
}
/// All four styles side by side, driven by the same controller.
class OverviewPage extends StatelessWidget {
const OverviewPage({super.key, required this.controller});
final WaveController controller;
@override
Widget build(BuildContext context) {
final cards = <_OverviewCard>[
_OverviewCard(
title: 'GeminiWave',
subtitle: 'Layered multi-hue ribbons',
background: const Color(0xFF0B0F1A),
child: GeminiWave(
height: 120,
blendMode: BlendMode.plus,
controller: controller,
),
),
_OverviewCard(
title: 'ClaudeWave',
subtitle: 'Warm overlapping surfaces',
background: WavePalette.claudePaper,
child: ClaudeWave(height: 180, controller: controller),
),
_OverviewCard(
title: 'PerplexityWave',
subtitle: 'Travelling equaliser bars',
background: WavePalette.perplexityInk,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: PerplexityWave(height: 96, controller: controller),
),
),
_OverviewCard(
title: 'ChatGptWave',
subtitle: 'Breathing rippled orb',
background: WavePalette.chatGptInk,
child: ChatGptWave(
width: 180,
height: 180,
ringWidth: 1.5,
controller: controller,
),
),
];
return LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth > 1000 ? 2 : 1;
return GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: columns,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: columns == 1 ? 1.35 : 1.5,
children: cards,
);
},
);
}
}
class _OverviewCard extends StatelessWidget {
const _OverviewCard({
required this.title,
required this.subtitle,
required this.background,
required this.child,
});
final String title;
final String subtitle;
final Color background;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
Text(subtitle, style: theme.textTheme.bodySmall),
const SizedBox(height: 8),
Expanded(
child: WaveStage(
height: double.infinity,
background: background,
child: child,
),
),
],
);
}
}