diff --git a/mobile/lib/features/agents/agent_avatar_backdrop.dart b/mobile/lib/features/agents/agent_avatar_backdrop.dart new file mode 100644 index 0000000000..8c2d6c20c6 --- /dev/null +++ b/mobile/lib/features/agents/agent_avatar_backdrop.dart @@ -0,0 +1,104 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import 'agent_avatar_picker.dart'; + +/// A soft artwork tint behind the avatar editor, matching desktop creation. +class AgentAvatarBackdrop extends StatelessWidget { + const AgentAvatarBackdrop({super.key, required this.selection}); + + final AgentAvatarSelection selection; + + @override + Widget build(BuildContext context) { + if (MediaQuery.highContrastOf(context)) return const SizedBox.expand(); + return IgnorePointer( + child: ExcludeSemantics( + child: ClipRect( + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Keep the wash near the portrait; the lower controls sit clear. + colors: [Colors.white, Color(0x66FFFFFF), Colors.transparent], + stops: [0, 0.25, 0.68], + ).createShader(bounds), + child: AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 240), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [...previous, ?current], + ), + child: RepaintBoundary( + key: ValueKey(( + selection.url, + selection.bytes, + selection.color, + )), + child: Opacity( + opacity: selection.emoji != null ? 0.14 : 0.64, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur(sigmaX: 40, sigmaY: 40), + child: Transform.scale( + // Crop into the artwork so it reads as abstract color. + scale: 3.5, + alignment: const Alignment(0, 0.12), + child: selection.emoji != null + ? ColoredBox(color: Color(selection.color!)) + : ColorFiltered( + // The desktop backdrop uses saturate(4). + colorFilter: const ColorFilter.matrix([ + 3.3622, + -2.1456, + -0.2166, + 0, + 0, + -0.6378, + 1.8544, + -0.2166, + 0, + 0, + -0.6378, + -2.1456, + 3.7834, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + child: selection.bytes != null + ? Image.memory( + selection.bytes!, + fit: BoxFit.cover, + cacheWidth: 256, + excludeFromSemantics: true, + errorBuilder: (_, _, _) => + const SizedBox.expand(), + ) + : Image.network( + selection.url!, + fit: BoxFit.cover, + cacheWidth: 256, + excludeFromSemantics: true, + errorBuilder: (_, _, _) => + const SizedBox.expand(), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/agents/agent_avatar_editor_page.dart b/mobile/lib/features/agents/agent_avatar_editor_page.dart index 7729de4fd0..8e690a3c9d 100644 --- a/mobile/lib/features/agents/agent_avatar_editor_page.dart +++ b/mobile/lib/features/agents/agent_avatar_editor_page.dart @@ -5,6 +5,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import 'agent_avatar_choices.dart'; +import 'agent_avatar_backdrop.dart'; import 'agent_avatar_geometry.dart'; import 'agent_avatar_picker.dart'; @@ -56,36 +57,43 @@ class AgentAvatarEditorPage extends HookWidget { ), ], ), - body: SafeArea( - top: false, - minimum: const EdgeInsets.only(bottom: Grid.gutter), - child: Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - frostedAppBarHeight(context) + Grid.xxs, - Grid.gutter, - Grid.sm, + body: Stack( + children: [ + Positioned.fill( + child: AgentAvatarBackdrop(selection: selection.value), ), - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - clipBehavior: Clip.none, - child: AgentAvatarPicker( - // Fill the available height so the tray shares the page gutters. - controlsHeight: - (constraints.maxHeight - - agentAvatarChoicesHeight(context) - - Grid.sm - - agentAvatarPreviewSpace) - .clamp(180, double.infinity), - selection: selection.value, - heroTag: heroTag, - onChanged: (value) => selection.value = value, - disabled: false, - onBusyChanged: (value) => busy.value = value, + SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: Grid.gutter), + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + frostedAppBarHeight(context) + Grid.xxs, + Grid.gutter, + Grid.sm, + ), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + clipBehavior: Clip.none, + child: AgentAvatarPicker( + // Fill the available height so the tray shares the page gutters. + controlsHeight: + (constraints.maxHeight - + agentAvatarChoicesHeight(context) - + Grid.sm - + agentAvatarPreviewSpace) + .clamp(180, double.infinity), + selection: selection.value, + heroTag: heroTag, + onChanged: (value) => selection.value = value, + disabled: false, + onBusyChanged: (value) => busy.value = value, + ), + ), ), ), ), - ), + ], ), ), ); diff --git a/mobile/test/features/agents/agent_avatar_backdrop_test.dart b/mobile/test/features/agents/agent_avatar_backdrop_test.dart new file mode 100644 index 0000000000..929df2b3da --- /dev/null +++ b/mobile/test/features/agents/agent_avatar_backdrop_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:buzz/features/agents/agent_avatar_backdrop.dart'; +import 'package:buzz/features/agents/agent_avatar_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ); + final first = AgentAvatarSelection.emoji(png, '😊', 0xFFFFCC00); + final second = AgentAvatarSelection.emoji(png, '😊', 0xFF0090FF); + + Widget host( + AgentAvatarSelection selection, { + bool reduced = false, + bool contrast = false, + }) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced, highContrast: contrast), + child: SizedBox( + width: 390, + height: 800, + child: AgentAvatarBackdrop(selection: selection), + ), + ), + ); + + testWidgets('rapid selection changes settle on the latest artwork tint', ( + tester, + ) async { + await tester.pumpWidget(host(first)); + await tester.pumpWidget(host(second)); + await tester.pump(const Duration(milliseconds: 80)); + expect( + find.descendant( + of: find.byType(AgentAvatarBackdrop), + matching: find.byType(ColoredBox), + ), + findsNWidgets(2), + ); + await tester.pumpWidget(host(first)); + await tester.pumpAndSettle(); + final colors = tester + .widgetList( + find.descendant( + of: find.byType(AgentAvatarBackdrop), + matching: find.byType(ColoredBox), + ), + ) + .map((box) => box.color); + expect(colors, [Color(first.color!)]); + expect(tester.takeException(), isNull); + }); + + testWidgets('reduced motion replaces the tint without a crossfade', ( + tester, + ) async { + await tester.pumpWidget(host(first, reduced: true)); + await tester.pumpWidget(host(second, reduced: true)); + await tester.pump(); + expect(tester.binding.transientCallbackCount, 0); + final colors = tester + .widgetList( + find.descendant( + of: find.byType(AgentAvatarBackdrop), + matching: find.byType(ColoredBox), + ), + ) + .map((box) => box.color); + expect(colors, [Color(second.color!)]); + }); + + testWidgets( + 'decorative artwork cannot intercept taps or add image semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => taps++, + child: const ColoredBox(color: Colors.white), + ), + ), + Positioned.fill( + child: AgentAvatarBackdrop( + selection: AgentAvatarSelection.image(png), + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(100, 100)); + expect(taps, 1); + expect(find.bySemanticsLabel('Selected agent avatar'), findsNothing); + semantics.dispose(); + await tester.pumpWidget(host(first, contrast: true)); + expect(find.byType(ImageFiltered), findsNothing); + }, + ); +}