2026-09-05 20:09:39 +08:00
|
|
|
import 'dart:math' as math;
|
|
|
|
|
import 'dart:ui' show ClipOp, lerpDouble;
|
|
|
|
|
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
abstract final class AppCorner {
|
|
|
|
|
static const double none = 0;
|
|
|
|
|
static const double xs = 4;
|
|
|
|
|
static const double sm = 8;
|
|
|
|
|
static const double md = 16;
|
|
|
|
|
static const double lg = 20;
|
|
|
|
|
static const double xl = 24;
|
|
|
|
|
static const double xxl = 28;
|
2026-10-16 22:42:17 +08:00
|
|
|
|
|
|
|
|
/// UIAlertController's corner on iOS 27, rounder than a sheet's.
|
|
|
|
|
static const double alert = 34;
|
2026-09-05 20:09:39 +08:00
|
|
|
static const double full = 1000;
|
|
|
|
|
|
|
|
|
|
static double fit(double shortestSide) {
|
|
|
|
|
final limit = shortestSide / 3;
|
|
|
|
|
if (limit >= xxl) return xxl;
|
|
|
|
|
if (limit >= xl) return xl;
|
|
|
|
|
if (limit >= lg) return lg;
|
|
|
|
|
if (limit >= md) return md;
|
|
|
|
|
if (limit >= sm) return sm;
|
|
|
|
|
if (limit >= xs) return xs;
|
|
|
|
|
return none;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abstract final class AppRadius {
|
|
|
|
|
static const BorderRadius none = BorderRadius.zero;
|
|
|
|
|
static const BorderRadius xs = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.xs),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius sm = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.sm),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius md = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.md),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius lg = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.lg),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius xl = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.xl),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius xxl = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.xxl),
|
|
|
|
|
);
|
|
|
|
|
static const BorderRadius full = BorderRadius.all(
|
|
|
|
|
Radius.circular(AppCorner.full),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
static BorderRadius all(double corner) => BorderRadius.circular(corner);
|
|
|
|
|
|
|
|
|
|
static BorderRadius top(double corner) =>
|
|
|
|
|
BorderRadius.vertical(top: Radius.circular(corner));
|
|
|
|
|
|
|
|
|
|
static BorderRadius vertical({
|
|
|
|
|
double top = AppCorner.none,
|
|
|
|
|
double bottom = AppCorner.none,
|
|
|
|
|
}) => BorderRadius.vertical(
|
|
|
|
|
top: Radius.circular(top),
|
|
|
|
|
bottom: Radius.circular(bottom),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abstract final class AppShape {
|
|
|
|
|
static const RoundedSuperellipseBorder none = RoundedSuperellipseBorder();
|
|
|
|
|
static const RoundedSuperellipseBorder xs = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.xs,
|
|
|
|
|
);
|
|
|
|
|
static const RoundedSuperellipseBorder sm = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.sm,
|
|
|
|
|
);
|
|
|
|
|
static const RoundedSuperellipseBorder md = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.md,
|
|
|
|
|
);
|
|
|
|
|
static const RoundedSuperellipseBorder lg = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.lg,
|
|
|
|
|
);
|
|
|
|
|
static const RoundedSuperellipseBorder xl = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.xl,
|
|
|
|
|
);
|
|
|
|
|
static const RoundedSuperellipseBorder xxl = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.xxl,
|
|
|
|
|
);
|
2026-09-25 03:57:04 +08:00
|
|
|
static const RoundedSuperellipseBorder full = RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.full,
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
static const CircleBorder circle = CircleBorder();
|
|
|
|
|
static const AppInputBorder input = AppInputBorder();
|
|
|
|
|
|
|
|
|
|
static RoundedSuperellipseBorder all(double corner) =>
|
|
|
|
|
RoundedSuperellipseBorder(borderRadius: AppRadius.all(corner));
|
|
|
|
|
|
|
|
|
|
static RoundedSuperellipseBorder top(double corner) =>
|
|
|
|
|
RoundedSuperellipseBorder(borderRadius: AppRadius.top(corner));
|
|
|
|
|
|
|
|
|
|
static RoundedSuperellipseBorder vertical({
|
|
|
|
|
double top = AppCorner.none,
|
|
|
|
|
double bottom = AppCorner.none,
|
|
|
|
|
}) => RoundedSuperellipseBorder(
|
|
|
|
|
borderRadius: AppRadius.vertical(top: top, bottom: bottom),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
static RoundedSuperellipseBorder of(BorderRadius borderRadius) =>
|
|
|
|
|
RoundedSuperellipseBorder(borderRadius: borderRadius);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class AppInputBorder extends InputBorder {
|
|
|
|
|
const AppInputBorder({
|
|
|
|
|
super.borderSide = const BorderSide(),
|
|
|
|
|
this.borderRadius = AppRadius.md,
|
|
|
|
|
this.gapPadding = 4.0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final BorderRadius borderRadius;
|
|
|
|
|
final double gapPadding;
|
|
|
|
|
|
|
|
|
|
RoundedSuperellipseBorder get _shape =>
|
|
|
|
|
RoundedSuperellipseBorder(borderRadius: borderRadius, side: borderSide);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
bool get isOutline => true;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
bool get preferPaintInterior => true;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
EdgeInsetsGeometry get dimensions => EdgeInsets.all(borderSide.width);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
AppInputBorder copyWith({
|
|
|
|
|
BorderSide? borderSide,
|
|
|
|
|
BorderRadius? borderRadius,
|
|
|
|
|
double? gapPadding,
|
|
|
|
|
}) {
|
|
|
|
|
return AppInputBorder(
|
|
|
|
|
borderSide: borderSide ?? this.borderSide,
|
|
|
|
|
borderRadius: borderRadius ?? this.borderRadius,
|
|
|
|
|
gapPadding: gapPadding ?? this.gapPadding,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
AppInputBorder scale(double t) {
|
|
|
|
|
return AppInputBorder(
|
|
|
|
|
borderSide: borderSide.scale(t),
|
|
|
|
|
borderRadius: borderRadius * t,
|
|
|
|
|
gapPadding: gapPadding * t,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
|
|
|
|
|
_shape.getInnerPath(rect, textDirection: textDirection);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
|
|
|
|
|
_shape.getOuterPath(rect, textDirection: textDirection);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void paintInterior(
|
|
|
|
|
Canvas canvas,
|
|
|
|
|
Rect rect,
|
|
|
|
|
Paint paint, {
|
|
|
|
|
TextDirection? textDirection,
|
|
|
|
|
}) => _shape.paintInterior(canvas, rect, paint, textDirection: textDirection);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
|
|
|
|
|
if (a is AppInputBorder) {
|
|
|
|
|
return AppInputBorder(
|
|
|
|
|
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
|
|
|
|
|
borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!,
|
|
|
|
|
gapPadding: a.gapPadding,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return super.lerpFrom(a, t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ShapeBorder? lerpTo(ShapeBorder? b, double t) {
|
|
|
|
|
if (b is AppInputBorder) {
|
|
|
|
|
return AppInputBorder(
|
|
|
|
|
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
|
|
|
|
|
borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!,
|
|
|
|
|
gapPadding: b.gapPadding,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return super.lerpTo(b, t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void paint(
|
|
|
|
|
Canvas canvas,
|
|
|
|
|
Rect rect, {
|
|
|
|
|
double? gapStart,
|
|
|
|
|
double gapExtent = 0.0,
|
|
|
|
|
double gapPercentage = 0.0,
|
|
|
|
|
TextDirection? textDirection,
|
|
|
|
|
}) {
|
|
|
|
|
final outline = rect.deflate(borderSide.width / 2);
|
|
|
|
|
if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) {
|
2026-10-16 22:42:17 +08:00
|
|
|
_shape.paint(canvas, rect, textDirection: textDirection);
|
2026-09-05 20:09:39 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final extent = lerpDouble(
|
|
|
|
|
0.0,
|
|
|
|
|
gapExtent + gapPadding * 2.0,
|
|
|
|
|
gapPercentage,
|
|
|
|
|
)!;
|
|
|
|
|
final start = switch (textDirection ?? TextDirection.ltr) {
|
|
|
|
|
TextDirection.rtl => gapStart + gapPadding - extent,
|
|
|
|
|
TextDirection.ltr => gapStart - gapPadding,
|
|
|
|
|
};
|
|
|
|
|
final radii = borderRadius.resolve(textDirection);
|
|
|
|
|
final left = outline.left + math.max(0.0, start);
|
|
|
|
|
final depth =
|
|
|
|
|
math.max(radii.topLeft.y, radii.topRight.y) + borderSide.width;
|
|
|
|
|
canvas
|
|
|
|
|
..save()
|
|
|
|
|
..clipRect(
|
|
|
|
|
Rect.fromLTRB(
|
|
|
|
|
left,
|
|
|
|
|
outline.top - borderSide.width,
|
|
|
|
|
left + extent,
|
|
|
|
|
outline.top + depth,
|
|
|
|
|
),
|
|
|
|
|
clipOp: ClipOp.difference,
|
|
|
|
|
);
|
2026-10-16 22:42:17 +08:00
|
|
|
_shape.paint(canvas, rect, textDirection: textDirection);
|
2026-09-05 20:09:39 +08:00
|
|
|
canvas.restore();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
bool operator ==(Object other) {
|
|
|
|
|
if (identical(this, other)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return other is AppInputBorder &&
|
|
|
|
|
other.borderSide == borderSide &&
|
|
|
|
|
other.borderRadius == borderRadius &&
|
|
|
|
|
other.gapPadding == gapPadding;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension AppShapeThemeExt on ThemeData {
|
|
|
|
|
ThemeData get withAppShapes => copyWith(
|
|
|
|
|
cardTheme: cardTheme.copyWith(shape: AppShape.md),
|
2026-10-16 22:42:17 +08:00
|
|
|
dialogTheme: dialogTheme.copyWith(shape: AppShape.all(AppCorner.alert)),
|
2026-09-05 20:09:39 +08:00
|
|
|
bottomSheetTheme: bottomSheetTheme.copyWith(
|
|
|
|
|
shape: AppShape.top(AppCorner.xxl),
|
|
|
|
|
),
|
|
|
|
|
snackBarTheme: snackBarTheme.copyWith(shape: AppShape.xs),
|
2026-10-08 17:58:43 +08:00
|
|
|
tooltipTheme: tooltipTheme.copyWith(
|
|
|
|
|
// A decoration replaces Material's tooltip colours too, so restate them.
|
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
|
shape: AppShape.xs,
|
|
|
|
|
color: switch (brightness) {
|
|
|
|
|
Brightness.dark => Colors.white.withAlpha(230),
|
|
|
|
|
Brightness.light => Colors.grey[700]!.withAlpha(230),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-09-05 20:09:39 +08:00
|
|
|
chipTheme: chipTheme.copyWith(shape: AppShape.sm),
|
|
|
|
|
popupMenuTheme: popupMenuTheme.copyWith(shape: AppShape.md),
|
|
|
|
|
menuTheme: MenuThemeData(
|
|
|
|
|
style: (menuTheme.style ?? const MenuStyle()).copyWith(
|
|
|
|
|
shape: const WidgetStatePropertyAll(AppShape.md),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
inputDecorationTheme: inputDecorationTheme.copyWith(border: AppShape.input),
|
|
|
|
|
floatingActionButtonTheme: floatingActionButtonTheme.copyWith(
|
|
|
|
|
shape: AppShape.md,
|
|
|
|
|
),
|
|
|
|
|
navigationBarTheme: navigationBarTheme.copyWith(
|
|
|
|
|
indicatorShape: AppShape.full,
|
|
|
|
|
),
|
|
|
|
|
navigationRailTheme: navigationRailTheme.copyWith(
|
|
|
|
|
indicatorShape: AppShape.full,
|
|
|
|
|
),
|
|
|
|
|
progressIndicatorTheme: progressIndicatorTheme.copyWith(
|
|
|
|
|
borderRadius: AppRadius.full,
|
|
|
|
|
),
|
2026-09-25 03:57:04 +08:00
|
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
|
|
|
style: filledButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
|
|
|
|
),
|
|
|
|
|
textButtonTheme: TextButtonThemeData(
|
|
|
|
|
style: textButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
|
|
|
|
),
|
|
|
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
|
|
|
style: outlinedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
|
|
|
|
),
|
|
|
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
|
|
|
style: elevatedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
|
|
|
|
),
|
|
|
|
|
iconButtonTheme: IconButtonThemeData(
|
|
|
|
|
style: iconButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
|
|
|
|
),
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
|
|
|
|
}
|
2026-09-25 03:57:04 +08:00
|
|
|
|
|
|
|
|
const _pillButton = ButtonStyle(shape: WidgetStatePropertyAll(AppShape.full));
|