ambient_effects_container 0.2.0
ambient_effects_container: ^0.2.0 copied to clipboard
A reusable Flutter ambient background container with rain, snow, leaf, petal, and firefly 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.white,
useMaterial3: true,
),
home: const AmbientEffectsDemoHomePage(),
);
}
}
enum DemoEffectKind { rain, snow, leaf, sakura, wisteria, firefly }
enum DemoIntensity { light, medium, heavy }
/// 首页卡片和详情页共用同一套效果构建逻辑。
///
/// 这样做的原因不是“为了抽象而抽象”,而是避免首页看起来是一套参数、
/// 详情页调起来又是另一套参数。后续继续新增效果时,只需要补这一处映射,
/// demo 的维护成本会低很多。
AmbientEffectConfig _buildDemoEffect(
DemoEffectKind kind,
DemoIntensity intensity, {
required double speed,
required double opacity,
required int? particleCount,
}) {
switch (kind) {
case DemoEffectKind.rain:
return RainEffect(
intensity: _mapRainIntensity(intensity),
speed: speed,
opacity: 0.55 * opacity,
particleCount: particleCount,
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,
opacity: 0.88 * opacity,
particleCount: particleCount,
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,
opacity: 0.92 * opacity,
particleCount: particleCount,
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,
opacity: 0.94 * opacity,
particleCount: particleCount,
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),
],
);
case DemoEffectKind.wisteria:
return PetalEffect(
style: PetalStyle.wisteria,
intensity: _mapPetalIntensity(intensity),
speed: speed * 0.78,
opacity: 0.9 * opacity,
particleCount: particleCount,
minSize: 7,
maxSize: intensity == DemoIntensity.heavy ? 16 : 13,
sway: 0.94,
rotationSpeed: 0.72,
shapeIds: const <String>[
PetalShapeIds.wisteriaStandard,
PetalShapeIds.wisteriaNarrow,
PetalShapeIds.wisteriaFolded,
],
);
case DemoEffectKind.firefly:
return FireflyEffect(
intensity: _mapFireflyIntensity(intensity),
speed: speed,
opacity: 0.96 * opacity,
particleCount: particleCount,
minSize: 1.8,
maxSize: intensity == DemoIntensity.heavy ? 4.2 : 3.8,
wander: 1.0,
blinkSpeed: 1.0,
glowColor: const Color(0xFFF3F08A),
coreColor: const Color(0xFFFFF9D1),
);
}
}
/// 前景层示例使用单独一套更克制的参数。
///
/// 前景特效的目的不是把内容盖住,而是补一点近景层次感。
/// 所以这里统一走“数量更少、体积略大、透明度更克制”的方向,
/// 避免直接照搬背景层参数后,把文字和按钮压得发脏。
AmbientEffectConfig _buildForegroundDemoEffect(
DemoEffectKind kind,
DemoIntensity intensity, {
required double speed,
required double opacity,
}) {
switch (kind) {
case DemoEffectKind.rain:
return RainEffect(
intensity: _mapRainIntensity(intensity),
speed: speed * 1.35,
opacity: 0.34 * opacity,
particleCount: 14,
color: const Color(0xFFEAF4FF),
angle: -0.24,
wind: 0.18,
strokeWidth: 1.75,
);
case DemoEffectKind.snow:
return SnowEffect(
intensity: _mapSnowIntensity(intensity),
speed: speed * 0.92,
opacity: 0.68 * opacity,
particleCount: 12,
minSize: 4.0,
maxSize: intensity == DemoIntensity.heavy ? 9.0 : 7.2,
drift: 1.25,
color: const Color(0xFFFFFFFF),
);
case DemoEffectKind.leaf:
return LeafEffect(
intensity: _mapLeafIntensity(intensity),
speed: speed * 0.94,
opacity: 0.82 * opacity,
particleCount: 10,
minSize: 14,
maxSize: intensity == DemoIntensity.heavy ? 26 : 22,
sway: 1.18,
rotationSpeed: 1.08,
palette: const <Color>[
Color(0xFFE28739),
Color(0xFFD45A1F),
Color(0xFFF0B067),
Color(0xFFB84720),
],
);
case DemoEffectKind.sakura:
return SakuraEffect(
intensity: _mapSakuraIntensity(intensity),
speed: speed * 0.9,
opacity: 0.84 * opacity,
particleCount: 10,
minSize: 10,
maxSize: intensity == DemoIntensity.heavy ? 22 : 18,
sway: 1.24,
rotationSpeed: 0.96,
palette: const <Color>[
Color(0xFFFFD7E6),
Color(0xFFFFC3DB),
Color(0xFFFFE7F0),
Color(0xFFF5AFC8),
],
);
case DemoEffectKind.wisteria:
return PetalEffect(
style: PetalStyle.wisteria,
intensity: _mapPetalIntensity(intensity),
speed: speed * 0.7,
opacity: 0.78 * opacity,
particleCount: 9,
minSize: 8,
maxSize: intensity == DemoIntensity.heavy ? 17 : 14,
sway: 0.88,
rotationSpeed: 0.64,
shapeIds: const <String>[
PetalShapeIds.wisteriaStandard,
PetalShapeIds.wisteriaFolded,
],
);
case DemoEffectKind.firefly:
return FireflyEffect(
intensity: _mapFireflyIntensity(intensity),
speed: speed * 0.92,
opacity: 0.92 * opacity,
particleCount: 10,
minSize: 2.2,
maxSize: intensity == DemoIntensity.heavy ? 4.6 : 4.0,
wander: 0.92,
blinkSpeed: 1.08,
glowColor: const Color(0xFFF6F39B),
coreColor: const Color(0xFFFFFCE0),
);
}
}
String _effectLabel(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => 'Rain',
DemoEffectKind.snow => 'Snow',
DemoEffectKind.leaf => 'Leaf',
DemoEffectKind.sakura => 'Sakura',
DemoEffectKind.wisteria => 'Wisteria',
DemoEffectKind.firefly => 'Firefly',
};
}
String _effectSummary(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain =>
'Fast-moving streaks for stormy headers, hero sections, and immersive weather scenes.',
DemoEffectKind.snow =>
'Soft drifting flakes that work well for landing pages, winter cards, and festive moments.',
DemoEffectKind.leaf =>
'Warm falling leaves for autumn scenes, seasonal promotions, and cozy editorial layouts.',
DemoEffectKind.sakura =>
'Floating petals for romantic views, spring themes, and soft decorative transitions.',
DemoEffectKind.wisteria =>
'Layered wisteria petals with mixed narrow and folded shapes for softer, hanging floral motion.',
DemoEffectKind.firefly =>
'Slow wandering glow points for gardens, night scenes, and calm ambient storytelling.',
};
}
String _effectCardHint(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => 'Linear streaks with wind control',
DemoEffectKind.snow => 'Dots and crystal flakes with drift',
DemoEffectKind.leaf => 'Layered leaves with sway and rotation',
DemoEffectKind.sakura => 'Petals with gentle swing and rotation',
DemoEffectKind.wisteria => 'Mixed petal shapes with softer swing',
DemoEffectKind.firefly => 'Free-flight glow with soft blinking',
};
}
String _headlineText(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => 'Rain over the bay',
DemoEffectKind.snow => 'Snow above the ridge',
DemoEffectKind.leaf => 'Leaves across the trail',
DemoEffectKind.sakura => 'Petals over the garden',
DemoEffectKind.wisteria => 'Wisteria through the courtyard',
DemoEffectKind.firefly => 'Fireflies in the grove',
};
}
String _detailDescription(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain =>
'Tune density, speed, and opacity for a rain scene while comparing full-page and local-container usage.',
DemoEffectKind.snow =>
'Adjust the snow scene and compare how the same effect behaves behind the page and inside a compact card.',
DemoEffectKind.leaf =>
'Explore an autumn setup with the same effect pipeline applied to both broad layouts and local containers.',
DemoEffectKind.sakura =>
'Refine a petal scene and verify that the same configuration still reads well inside a compact widget.',
DemoEffectKind.wisteria =>
'Preview the current wisteria petal mix and compare how the same petal system behaves as both a page effect and a local card overlay.',
DemoEffectKind.firefly =>
'Control a natural firefly scene and compare full-page ambience with a smaller content card preview.',
};
}
IconData _effectIcon(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => Icons.grain,
DemoEffectKind.snow => Icons.ac_unit,
DemoEffectKind.leaf => Icons.park,
DemoEffectKind.sakura => Icons.local_florist,
DemoEffectKind.wisteria => Icons.spa,
DemoEffectKind.firefly => Icons.wb_twilight,
};
}
Color _sceneColor(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => const Color(0xFF213547),
DemoEffectKind.snow => const Color(0xFF99B7C8),
DemoEffectKind.leaf => const Color(0xFF7B5135),
DemoEffectKind.sakura => const Color(0xFF88576B),
DemoEffectKind.wisteria => const Color(0xFF65517F),
DemoEffectKind.firefly => const Color(0xFF18221A),
};
}
Color _foregroundSurfaceColor(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => const Color(0xFF31485E),
DemoEffectKind.snow => const Color(0xFFEAF3F8),
DemoEffectKind.leaf => const Color(0xFFF8E8DA),
DemoEffectKind.sakura => const Color(0xFFFFEEF4),
DemoEffectKind.wisteria => const Color(0xFFF2EEFB),
DemoEffectKind.firefly => const Color(0xFF243227),
};
}
Color _foregroundPrimaryTextColor(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => Colors.white,
DemoEffectKind.snow => const Color(0xFF1F2A33),
DemoEffectKind.leaf => const Color(0xFF3D2A1E),
DemoEffectKind.sakura => const Color(0xFF4E3340),
DemoEffectKind.wisteria => const Color(0xFF47385F),
DemoEffectKind.firefly => const Color(0xFFF2F6E8),
};
}
Color _foregroundSecondaryTextColor(DemoEffectKind kind) {
return switch (kind) {
DemoEffectKind.rain => const Color(0xFFDCE8F2),
DemoEffectKind.snow => const Color(0xFF5A6B78),
DemoEffectKind.leaf => const Color(0xFF6A5042),
DemoEffectKind.sakura => const Color(0xFF7A5E6C),
DemoEffectKind.wisteria => const Color(0xFF75688E),
DemoEffectKind.firefly => const Color(0xFFD5E0C7),
};
}
LinearGradient _panelGradient(DemoEffectKind kind) {
return switch (kind) {
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,
),
DemoEffectKind.wisteria => const LinearGradient(
colors: <Color>[Color(0xFFE2D8F8), Color(0xFFF7F3FD)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
DemoEffectKind.firefly => const LinearGradient(
colors: <Color>[Color(0xFFE6F0D7), Color(0xFFF7FAF0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
};
}
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,
};
}
PetalIntensity _mapPetalIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => PetalIntensity.light,
DemoIntensity.medium => PetalIntensity.medium,
DemoIntensity.heavy => PetalIntensity.heavy,
};
}
FireflyIntensity _mapFireflyIntensity(DemoIntensity intensity) {
return switch (intensity) {
DemoIntensity.light => FireflyIntensity.light,
DemoIntensity.medium => FireflyIntensity.medium,
DemoIntensity.heavy => FireflyIntensity.heavy,
};
}
class AmbientEffectsDemoHomePage extends StatelessWidget {
const AmbientEffectsDemoHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Ambient Effects Demo'),
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.white,
foregroundColor: const Color(0xFF1F2A33),
),
body: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Tap a card to open the dedicated demo page for that effect.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.55,
color: const Color(0xFF536574),
),
),
const SizedBox(height: 20),
for (final DemoEffectKind kind
in DemoEffectKind.values) ...<Widget>[
_EffectShowcaseCard(effectKind: kind),
if (kind != DemoEffectKind.values.last)
const SizedBox(height: 18),
],
],
),
),
),
),
),
);
}
}
/// 首页卡片专门用来强调“局部包裹任意 widget”这个能力。
///
/// 所以这里故意保持页面背景纯白,只让卡片自身带效果层。用户一眼就能看出
/// 插件不只适合整页背景,也能稳定放进局部组件里。
class _EffectShowcaseCard extends StatelessWidget {
const _EffectShowcaseCard({required this.effectKind});
final DemoEffectKind effectKind;
@override
Widget build(BuildContext context) {
final effect = _buildDemoEffect(
effectKind,
DemoIntensity.medium,
speed: 1.0,
opacity: 1.0,
particleCount: null,
);
return ClipRRect(
borderRadius: BorderRadius.circular(28),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
AmbientEffectDetailPage(effectKind: effectKind),
),
);
},
child: AmbientEffectsContainer(
effect: effect,
enabled: true,
performanceMode: PerformanceMode.balanced,
renderStyle: EffectRenderStyle.layered,
backgroundColor: _sceneColor(effectKind),
child: Container(
constraints: const BoxConstraints(minHeight: 208),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: Colors.white.withAlpha(46)),
gradient: LinearGradient(
colors: <Color>[
Colors.white.withAlpha(28),
Colors.white.withAlpha(10),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withAlpha(38),
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
_effectIcon(effectKind),
size: 18,
color: Colors.white,
),
const SizedBox(width: 8),
Text(
_effectLabel(effectKind),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
const Spacer(),
const Icon(
Icons.arrow_forward_rounded,
color: Colors.white,
),
],
),
const SizedBox(height: 18),
Text(
_headlineText(effectKind),
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 10),
Text(
_effectSummary(effectKind),
style: const TextStyle(
fontSize: 15,
height: 1.5,
color: Color(0xFFE1EBF0),
),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
const _MetricChip(
label: 'Usage',
value: 'Local container demo',
),
_MetricChip(
label: 'Focus',
value: _effectCardHint(effectKind),
),
],
),
],
),
),
),
),
),
);
}
}
class AmbientEffectDetailPage extends StatefulWidget {
const AmbientEffectDetailPage({super.key, required this.effectKind});
final DemoEffectKind effectKind;
@override
State<AmbientEffectDetailPage> createState() =>
_AmbientEffectDetailPageState();
}
class _AmbientEffectDetailPageState extends State<AmbientEffectDetailPage> {
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;
/// slider 用 0 表示“交给效果自己决定默认粒子数”,
/// 而不是强行把 0 也当成真正的粒子数量传下去。
///
/// 这样一来,示例页既能演示自动档,也能演示手动覆盖,不需要额外再做一套
/// “是否启用 override”的开关。
int? get _particleCountOverride {
final value = _particleSliderValue.round();
return value <= 0 ? null : value;
}
AmbientEffectConfig get _effect => _buildDemoEffect(
widget.effectKind,
_intensity,
speed: _speed,
opacity: _opacity,
particleCount: _particleCountOverride,
);
AmbientEffectConfig get _foregroundEffect => _buildForegroundDemoEffect(
widget.effectKind,
_intensity,
speed: _speed,
opacity: _opacity,
);
@override
Widget build(BuildContext context) {
return AmbientEffectsContainer(
effect: _effect,
enabled: _enabled,
performanceMode: _performanceMode,
renderStyle: _renderStyle,
backgroundColor: _sceneColor(widget.effectKind),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
foregroundColor: Colors.white,
title: Text('${_effectLabel(widget.effectKind)} Demo'),
),
body: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 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(),
),
const SizedBox(width: 20),
Expanded(
flex: 2,
child: _buildControlPanel(),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildPreviewPanel(),
const SizedBox(height: 20),
_buildControlPanel(),
],
);
},
),
],
),
),
),
),
),
),
);
}
Widget _buildHero(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: _panelGradient(widget.effectKind),
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(
_effectLabel(widget.effectKind),
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.1,
color: const Color(0xFF4D5F6E),
),
),
const SizedBox(height: 10),
Text(
'Ambient Effects Container',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF1F2A33),
),
),
const SizedBox(height: 12),
Text(
_detailDescription(widget.effectKind),
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
color: const Color(0xFF405261),
),
),
],
),
),
);
}
Widget _buildPreviewPanel() {
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(widget.effectKind),
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: _effectLabel(widget.effectKind),
),
_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: 'Background + Foreground Demo',
subtitle:
'A compact card with both layers enabled so you can preview how near-field particles sit above the content.',
child: AmbientEffectsContainer(
effect: _effect,
foregroundEffect: _foregroundEffect,
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),
),
),
],
),
],
),
),
),
),
const SizedBox(height: 20),
_PreviewCard(
title: 'Foreground Overlay Demo',
subtitle:
'The content stays fully opaque while the effect renders above it and still lets all gestures pass through.',
child: AmbientEffectsContainer(
foregroundEffect: _foregroundEffect,
enabled: _enabled,
performanceMode: _performanceMode,
renderStyle: _renderStyle,
child: Builder(
builder: (BuildContext context) {
final surfaceColor = _foregroundSurfaceColor(widget.effectKind);
final primaryTextColor = _foregroundPrimaryTextColor(
widget.effectKind,
);
final secondaryTextColor = _foregroundSecondaryTextColor(
widget.effectKind,
);
return Container(
constraints: const BoxConstraints(minHeight: 220),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: surfaceColor,
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Foreground overlay',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: primaryTextColor,
),
),
const SizedBox(height: 10),
Text(
'This card does not rely on transparent surfaces. The overlay is painted above the widget tree, so it works for solid cards and still keeps interactions intact.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: secondaryTextColor,
),
),
const SizedBox(height: 24),
OverflowBar(
spacing: 12,
overflowSpacing: 12,
alignment: MainAxisAlignment.start,
children: <Widget>[
FilledButton.tonal(
onPressed: () {},
child: const Text('Primary action'),
),
OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: primaryTextColor,
side: BorderSide(
color: primaryTextColor.withAlpha(117),
),
),
child: const Text('Secondary'),
),
],
),
],
),
);
},
),
),
),
],
);
}
/// 详情页不再提供“效果类型”切换。
///
/// 进入某个详情页,本身就意味着用户正在观察单一效果;如果这里还保留切换,
/// 路由层级和当前展示的内容就会互相打架,后续新增效果时也会让 demo 导航变得
/// 越来越混乱。
Widget _buildControlPanel() {
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: '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;
});
},
),
],
),
),
);
}
}
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,
),
],
);
}
}