ambient_effects_container 0.1.0
ambient_effects_container: ^0.1.0 copied to clipboard
A reusable Flutter ambient background container with rain, snow, leaf, and sakura particle effects.
import 'package:ambient_effects_container/ambient_effects_container.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const AmbientEffectsDemoApp());
}
class AmbientEffectsDemoApp extends StatelessWidget {
const AmbientEffectsDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Ambient Effects Container',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF496A81),
brightness: Brightness.light,
),
scaffoldBackgroundColor: Colors.transparent,
useMaterial3: true,
),
home: const AmbientEffectsDemoPage(),
);
}
}
enum DemoEffectKind { rain, snow, leaf, sakura }
enum DemoIntensity { light, medium, heavy }
class AmbientEffectsDemoPage extends StatefulWidget {
const AmbientEffectsDemoPage({super.key});
@override
State<AmbientEffectsDemoPage> createState() => _AmbientEffectsDemoPageState();
}
class _AmbientEffectsDemoPageState extends State<AmbientEffectsDemoPage> {
DemoEffectKind _effectKind = DemoEffectKind.rain;
DemoIntensity _intensity = DemoIntensity.medium;
PerformanceMode _performanceMode = PerformanceMode.balanced;
EffectRenderStyle _renderStyle = EffectRenderStyle.layered;
bool _enabled = true;
double _speed = 1.0;
double _opacity = 1.0;
double _particleSliderValue = 0;
int? get _particleCountOverride {
final value = _particleSliderValue.round();
return value <= 0 ? null : value;
}
AmbientEffectConfig _buildEffect({double speedMultiplier = 1.0}) {
switch (_effectKind) {
case DemoEffectKind.rain:
return RainEffect(
intensity: _mapRainIntensity(_intensity),
speed: _speed * speedMultiplier,
opacity: 0.55 * _opacity,
particleCount: _particleCountOverride,
color: const Color(0xFFCFE5FF),
angle: -0.24,
wind: 0.15,
strokeWidth: _intensity == DemoIntensity.heavy ? 1.4 : 1.1,
);
case DemoEffectKind.snow:
return SnowEffect(
intensity: _mapSnowIntensity(_intensity),
speed: _speed * speedMultiplier,
opacity: 0.88 * _opacity,
particleCount: _particleCountOverride,
minSize: 2,
maxSize: _intensity == DemoIntensity.heavy ? 8 : 6,
drift: 1.1,
color: const Color(0xFFFDFEFF),
);
case DemoEffectKind.leaf:
return LeafEffect(
intensity: _mapLeafIntensity(_intensity),
speed: _speed * speedMultiplier,
opacity: 0.92 * _opacity,
particleCount: _particleCountOverride,
minSize: 10,
maxSize: _intensity == DemoIntensity.heavy ? 24 : 20,
sway: 1.1,
rotationSpeed: 1.05,
palette: const <Color>[
Color(0xFFD86A2D),
Color(0xFFCA4C1D),
Color(0xFFE5A056),
Color(0xFFA93D1D),
],
);
case DemoEffectKind.sakura:
return SakuraEffect(
intensity: _mapSakuraIntensity(_intensity),
speed: _speed * speedMultiplier,
opacity: 0.94 * _opacity,
particleCount: _particleCountOverride,
minSize: 8,
maxSize: _intensity == DemoIntensity.heavy ? 20 : 17,
sway: 1.18,
rotationSpeed: 0.92,
palette: const <Color>[
Color(0xFFFFD8E6),
Color(0xFFFFC7DD),
Color(0xFFFFE3EE),
Color(0xFFF7B7CF),
],
);
}
}
Color _sceneColor() {
return switch (_effectKind) {
DemoEffectKind.rain => const Color(0xFF213547),
DemoEffectKind.snow => const Color(0xFF99B7C8),
DemoEffectKind.leaf => const Color(0xFF7B5135),
DemoEffectKind.sakura => const Color(0xFF88576B),
};
}
LinearGradient _panelGradient() {
return switch (_effectKind) {
DemoEffectKind.rain => const LinearGradient(
colors: <Color>[Color(0xFFD3E4F0), Color(0xFFF6F9FB)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
DemoEffectKind.snow => const LinearGradient(
colors: <Color>[Color(0xFFE6F4FA), Color(0xFFFDFEFF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
DemoEffectKind.leaf => const LinearGradient(
colors: <Color>[Color(0xFFF3D9BF), Color(0xFFFBF2E8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
DemoEffectKind.sakura => const LinearGradient(
colors: <Color>[Color(0xFFFFE0EA), Color(0xFFFFF4F8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
};
}
@override
Widget build(BuildContext context) {
final effect = _buildEffect();
return AmbientEffectsContainer(
effect: effect,
enabled: _enabled,
performanceMode: _performanceMode,
renderStyle: _renderStyle,
backgroundColor: _sceneColor(),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildHero(context),
const SizedBox(height: 20),
LayoutBuilder(
builder:
(BuildContext context, BoxConstraints constraints) {
final isWide = constraints.maxWidth >= 860;
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 3,
child: _buildPreviewPanel(effect),
),
const SizedBox(width: 20),
Expanded(
flex: 2,
child: _buildControlPanel(context),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildPreviewPanel(effect),
const SizedBox(height: 20),
_buildControlPanel(context),
],
);
},
),
],
),
),
),
),
),
),
);
}
Widget _buildHero(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: _panelGradient(),
borderRadius: BorderRadius.circular(28),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x22000000),
blurRadius: 28,
offset: Offset(0, 18),
),
],
),
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ambient Effects Container',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF1F2A33),
),
),
const SizedBox(height: 12),
Text(
'A reusable animated background container that can wrap any widget. This demo shows both a full-page background and a local card background so you can quickly compare particle density, speed, and opacity.',
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
color: const Color(0xFF405261),
),
),
],
),
),
);
}
Widget _buildPreviewPanel(AmbientEffectConfig effect) {
return Column(
children: <Widget>[
_PreviewCard(
title: 'Full-Screen Background Demo',
subtitle:
'Keep the Scaffold background transparent so the effect layer stays behind your content.',
child: SizedBox(
height: 300,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: <Color>[
Colors.white.withAlpha(41),
Colors.white.withAlpha(10),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(color: Colors.white.withAlpha(46)),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_headlineText(),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 10),
const Text(
'You can wrap the entire page with AmbientEffectsContainer and keep the content layer organized the same way as before.',
style: TextStyle(
fontSize: 15,
height: 1.45,
color: Color(0xFFE5EDF2),
),
),
const Spacer(),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
_MetricChip(label: 'Effect', value: _effectKind.name),
_MetricChip(
label: 'Performance',
value: _performanceMode.name,
),
_MetricChip(label: 'Style', value: _renderStyle.name),
_MetricChip(
label: 'Particles',
value: _particleCountOverride?.toString() ?? 'auto',
),
],
),
],
),
),
),
),
),
const SizedBox(height: 20),
_PreviewCard(
title: 'Local Container Demo',
subtitle:
'Local regions scale particle density by actual size so smaller cards do not look overcrowded.',
child: AmbientEffectsContainer(
effect: effect,
enabled: _enabled,
performanceMode: _performanceMode,
renderStyle: _renderStyle,
backgroundColor: Colors.white.withAlpha(20),
child: Container(
height: 230,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withAlpha(46)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Now Playing',
style: TextStyle(
fontSize: 13,
letterSpacing: 1.2,
color: Color(0xFFDCE8EF),
),
),
const SizedBox(height: 10),
const Text(
'Silent Horizon',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 6),
const Text(
'A compact demo card with the same effect pipeline.',
style: TextStyle(fontSize: 14, color: Color(0xFFD7E4EC)),
),
const Spacer(),
Row(
children: const <Widget>[
Icon(Icons.graphic_eq, color: Colors.white),
SizedBox(width: 12),
Expanded(
child: LinearProgressIndicator(
value: 0.62,
color: Colors.white,
backgroundColor: Color(0x55FFFFFF),
),
),
],
),
],
),
),
),
),
],
);
}
Widget _buildControlPanel(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(230),
borderRadius: BorderRadius.circular(24),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 16),
),
],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Control Panel',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
_SectionTitle(title: 'Effect Type'),
SegmentedButton<DemoEffectKind>(
segments: const <ButtonSegment<DemoEffectKind>>[
ButtonSegment(value: DemoEffectKind.rain, label: Text('Rain')),
ButtonSegment(value: DemoEffectKind.snow, label: Text('Snow')),
ButtonSegment(value: DemoEffectKind.leaf, label: Text('Leaf')),
ButtonSegment(
value: DemoEffectKind.sakura,
label: Text('Sakura'),
),
],
selected: <DemoEffectKind>{_effectKind},
onSelectionChanged: (Set<DemoEffectKind> value) {
setState(() {
_effectKind = value.first;
});
},
),
const SizedBox(height: 16),
_SectionTitle(title: 'Intensity'),
SegmentedButton<DemoIntensity>(
segments: const <ButtonSegment<DemoIntensity>>[
ButtonSegment(value: DemoIntensity.light, label: Text('Light')),
ButtonSegment(
value: DemoIntensity.medium,
label: Text('Medium'),
),
ButtonSegment(value: DemoIntensity.heavy, label: Text('Heavy')),
],
selected: <DemoIntensity>{_intensity},
onSelectionChanged: (Set<DemoIntensity> value) {
setState(() {
_intensity = value.first;
});
},
),
const SizedBox(height: 16),
_SectionTitle(title: 'Performance Mode'),
SegmentedButton<PerformanceMode>(
segments: const <ButtonSegment<PerformanceMode>>[
ButtonSegment(value: PerformanceMode.low, label: Text('Low')),
ButtonSegment(
value: PerformanceMode.balanced,
label: Text('Balanced'),
),
ButtonSegment(value: PerformanceMode.high, label: Text('High')),
],
selected: <PerformanceMode>{_performanceMode},
onSelectionChanged: (Set<PerformanceMode> value) {
setState(() {
_performanceMode = value.first;
});
},
),
const SizedBox(height: 16),
_SectionTitle(title: 'Render Style'),
SegmentedButton<EffectRenderStyle>(
segments: const <ButtonSegment<EffectRenderStyle>>[
ButtonSegment(
value: EffectRenderStyle.flat,
label: Text('Flat'),
),
ButtonSegment(
value: EffectRenderStyle.layered,
label: Text('Layered'),
),
ButtonSegment(
value: EffectRenderStyle.cinematic,
label: Text('Cinematic'),
),
],
selected: <EffectRenderStyle>{_renderStyle},
onSelectionChanged: (Set<EffectRenderStyle> value) {
setState(() {
_renderStyle = value.first;
});
},
),
const SizedBox(height: 16),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _enabled,
title: const Text('Enable Animation'),
subtitle: const Text(
'Turning this off stops the controller to avoid unnecessary repaints.',
),
onChanged: (bool value) {
setState(() {
_enabled = value;
});
},
),
const SizedBox(height: 12),
_ValueSlider(
label: 'Speed Multiplier',
value: _speed,
min: 0.5,
max: 1.8,
divisions: 13,
valueText: _speed.toStringAsFixed(2),
onChanged: (double value) {
setState(() {
_speed = value;
});
},
),
const SizedBox(height: 12),
_ValueSlider(
label: 'Opacity Multiplier',
value: _opacity,
min: 0.35,
max: 1.0,
divisions: 13,
valueText: _opacity.toStringAsFixed(2),
onChanged: (double value) {
setState(() {
_opacity = value;
});
},
),
const SizedBox(height: 12),
_ValueSlider(
label: 'Particle Count Override',
value: _particleSliderValue,
min: 0,
max: 160,
divisions: 16,
valueText: _particleCountOverride?.toString() ?? 'auto',
onChanged: (double value) {
setState(() {
_particleSliderValue = value;
});
},
),
],
),
),
);
}
String _headlineText() {
return switch (_effectKind) {
DemoEffectKind.rain => 'Rain over the bay',
DemoEffectKind.snow => 'Snow above the ridge',
DemoEffectKind.leaf => 'Leaves across the trail',
DemoEffectKind.sakura => 'Petals over the garden',
};
}
RainIntensity _mapRainIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => RainIntensity.light,
DemoIntensity.medium => RainIntensity.medium,
DemoIntensity.heavy => RainIntensity.heavy,
};
}
SnowIntensity _mapSnowIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => SnowIntensity.light,
DemoIntensity.medium => SnowIntensity.medium,
DemoIntensity.heavy => SnowIntensity.heavy,
};
}
LeafIntensity _mapLeafIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => LeafIntensity.light,
DemoIntensity.medium => LeafIntensity.medium,
DemoIntensity.heavy => LeafIntensity.heavy,
};
}
SakuraIntensity _mapSakuraIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => SakuraIntensity.light,
DemoIntensity.medium => SakuraIntensity.medium,
DemoIntensity.heavy => SakuraIntensity.heavy,
};
}
}
class _PreviewCard extends StatelessWidget {
const _PreviewCard({
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(15),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.white.withAlpha(36)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(
fontSize: 14,
height: 1.45,
color: Color(0xFFD6E2E9),
),
),
const SizedBox(height: 16),
child,
],
),
),
);
}
}
class _MetricChip extends StatelessWidget {
const _MetricChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(41),
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
child: Text(
'$label: $value',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
);
}
}
class _ValueSlider extends StatelessWidget {
const _ValueSlider({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.valueText,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final String valueText;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.titleSmall),
const Spacer(),
Text(valueText),
],
),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
label: valueText,
onChanged: onChanged,
),
],
);
}
}