mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary Polish mobile Home and Solo: group Search/Inbox beside a separate profile avatar, move Inbox and Settings to bottom-up pages with Close controls, and place tabs opposite the plus/compose action. Morph plus to compose clockwise when entering Solo and reverse counterclockwise when returning Home, with a directional quarter-turn of the icon inside a level button and a subtle scale pulse, respecting reduced motion. Align Search’s Close control with Inbox, simplify its placeholder, and initialize selected filter contrast correctly. Collapse the plus menu when leaving Home. Center Solo’s native-glass New chat empty state and keep it stationary behind the keyboard. Standardize Tabler icons and neutral backgrounds, retain SF Back/Close on iOS, use native Liquid Glass headers, tab container, and actions (solid grey selected tab, slightly darker on iOS) on iOS and black actions on Android, and smooth the expanded menu corners. Repair native header safe-area/return layout and compose movement after cancellation. ### Related issue None found for this UI refinement; follows #8337. ### Testing - Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3 push-configuration tests passed. File-size checks passed. - Base UI built and installed on Pixel and iPhone in place. Pixel navigation/New chat cancellation checked. Subsequent glass and rotation refinements are previewed on iPhone; motion feel awaits human confirmation. - Draft pending human confirmation of the final Settings entrance/return behavior on iPhone. ### Pixel snapshots | Solo | Expanded actions | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png" width="280" alt="Solo empty state with New chat" /> | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png" width="280" alt="Expanded channel actions with smooth corners" /> | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
141 lines
4.3 KiB
Dart
141 lines
4.3 KiB
Dart
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import '../theme/buzz_icons.dart';
|
|
|
|
/// Tabler plus/edit vectors share one reversible, clockwise quarter-turn.
|
|
class ComposeMorphIcon extends StatelessWidget {
|
|
const ComposeMorphIcon({
|
|
super.key,
|
|
required this.compose,
|
|
required this.color,
|
|
this.surfaceBuilder,
|
|
});
|
|
final bool compose;
|
|
final Color color;
|
|
|
|
/// Includes the button surface in the same scale pulse as its icon.
|
|
final Widget Function(Widget icon)? surfaceBuilder;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final reduced = MediaQuery.disableAnimationsOf(context);
|
|
return TweenAnimationBuilder<double>(
|
|
tween: Tween(end: compose ? 1 : 0),
|
|
duration: reduced ? Duration.zero : const Duration(milliseconds: 240),
|
|
curve: Curves.easeInOutCubic,
|
|
builder: (context, animatedProgress, _) {
|
|
final progress = reduced ? (compose ? 1.0 : 0.0) : animatedProgress;
|
|
final lift = math.sin(progress * math.pi);
|
|
final Widget icon = progress == 0 || progress == 1
|
|
? Transform.rotate(
|
|
angle: -progress * math.pi / 2,
|
|
child: Icon(
|
|
progress == 0 ? BuzzIcons.plus : BuzzIcons.edit,
|
|
size: 24,
|
|
color: color,
|
|
),
|
|
)
|
|
: CustomPaint(
|
|
size: const Size.square(24),
|
|
painter: _ComposePainter(progress, color),
|
|
);
|
|
final rotatingIcon = Transform.rotate(
|
|
key: const ValueKey('compose-icon-rotation'),
|
|
angle: progress * math.pi / 2,
|
|
child: icon,
|
|
);
|
|
return Transform.scale(
|
|
key: const ValueKey('compose-surface-scale'),
|
|
scale: 1 + 0.08 * lift,
|
|
child: surfaceBuilder?.call(rotatingIcon) ?? rotatingIcon,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ComposePainter extends CustomPainter {
|
|
_ComposePainter(this.progress, this.color);
|
|
final double progress;
|
|
final Color color;
|
|
|
|
// Tabler edit.svg paths, sampled once to interpolate compatible contours.
|
|
static final contours = <List<Offset>>[
|
|
_sample(
|
|
Path()
|
|
..moveTo(7, 7)
|
|
..lineTo(6, 7)
|
|
..quadraticBezierTo(4, 7, 4, 9)
|
|
..lineTo(4, 18)
|
|
..quadraticBezierTo(4, 20, 6, 20)
|
|
..lineTo(15, 20)
|
|
..quadraticBezierTo(17, 20, 17, 18)
|
|
..lineTo(17, 17),
|
|
),
|
|
_sample(
|
|
Path()
|
|
..moveTo(20.385, 6.585)
|
|
..cubicTo(22.365, 4.605, 19.395, 1.635, 17.415, 3.615)
|
|
..lineTo(9, 12)
|
|
..lineTo(9, 15)
|
|
..lineTo(12, 15)
|
|
..lineTo(20.385, 6.585),
|
|
),
|
|
_sample(
|
|
Path()
|
|
..moveTo(16, 5)
|
|
..lineTo(19, 8),
|
|
),
|
|
];
|
|
|
|
static List<Offset> _sample(Path path) {
|
|
final metric = path.computeMetrics().single;
|
|
return List.generate(
|
|
49,
|
|
(i) => metric.getTangentForOffset(metric.length * i / 48)!.position,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
canvas.save();
|
|
canvas.scale(size.width / 24, size.height / 24);
|
|
canvas.translate(12, 12);
|
|
// The icon wrapper supplies the quarter-turn.
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
for (var stroke = 0; stroke < contours.length; stroke++) {
|
|
final path = Path();
|
|
for (var i = 0; i < contours[stroke].length; i++) {
|
|
final fraction = i / (contours[stroke].length - 1);
|
|
final start = stroke == 0
|
|
? Offset(0, -7 + 14 * fraction)
|
|
: stroke == 1
|
|
? Offset(-7 + 14 * fraction, 0)
|
|
: Offset.zero;
|
|
final end = contours[stroke][i] - const Offset(12, 12);
|
|
// Undo the final rotation in target space: edit ends upright.
|
|
final point = Offset.lerp(start, Offset(end.dy, -end.dx), progress)!;
|
|
if (i == 0) {
|
|
path.moveTo(point.dx, point.dy);
|
|
} else {
|
|
path.lineTo(point.dx, point.dy);
|
|
}
|
|
}
|
|
paint.color = color.withValues(
|
|
alpha: color.a * (stroke == 2 ? progress : 1),
|
|
);
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
canvas.restore();
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_ComposePainter oldDelegate) =>
|
|
progress != oldDelegate.progress || color != oldDelegate.color;
|
|
}
|