Polish navigation, Quran flows, and sharing UX
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// 5-tab bottom navigation bar per PRD §5.1.
|
||||
/// Uses Material Symbols outlined (inactive) and filled (active).
|
||||
class AppBottomNavBar extends StatelessWidget {
|
||||
/// 5-tab bottom navigation bar with luxury active treatment.
|
||||
class AppBottomNavBar extends ConsumerStatefulWidget {
|
||||
const AppBottomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
@@ -17,86 +22,207 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
ConsumerState<AppBottomNavBar> createState() => _AppBottomNavBarState();
|
||||
}
|
||||
|
||||
class _AppBottomNavBarState extends ConsumerState<AppBottomNavBar>
|
||||
with TickerProviderStateMixin {
|
||||
static const double _toggleRevealWidth = 88;
|
||||
static const double _dragThreshold = 38;
|
||||
static const double _inactiveIconSize = 27;
|
||||
static const double _activeIconSize = 22;
|
||||
static const double _navIconStrokeWidth = 1.9;
|
||||
late final AnimationController _shineController;
|
||||
late final AnimationController _revealController;
|
||||
late final Animation<double> _revealAnimation;
|
||||
bool _isThemeToggleOpen = false;
|
||||
double _dragDeltaX = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shineController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 5200),
|
||||
)..repeat();
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 340),
|
||||
);
|
||||
_revealAnimation = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _syncAnimation({required bool reducedMotion}) {
|
||||
if (reducedMotion) {
|
||||
if (_shineController.isAnimating) {
|
||||
_shineController.stop(canceled: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_shineController.isAnimating) {
|
||||
_shineController.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
void _openThemeToggle({bool withHaptics = true}) {
|
||||
if (_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = true;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
_revealController.animateTo(1);
|
||||
}
|
||||
|
||||
void _closeThemeToggle({bool withHaptics = true}) {
|
||||
if (!_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = false;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
_revealController.animateBack(0);
|
||||
}
|
||||
|
||||
void _handleNavTap(int index) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
widget.onTap(index);
|
||||
}
|
||||
|
||||
void _handleHorizontalDragStart(DragStartDetails _) {
|
||||
_dragDeltaX = 0;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
|
||||
_dragDeltaX += details.delta.dx;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragEnd(DragEndDetails details) {
|
||||
final velocityX = details.primaryVelocity ?? 0;
|
||||
final shouldOpen = velocityX < -220 || _dragDeltaX <= -_dragThreshold;
|
||||
final shouldClose = velocityX > 220 || _dragDeltaX >= _dragThreshold;
|
||||
|
||||
if (shouldOpen && !_isThemeToggleOpen) {
|
||||
_openThemeToggle();
|
||||
} else if (shouldClose && _isThemeToggleOpen) {
|
||||
_closeThemeToggle();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleThemeMode(BuildContext context) async {
|
||||
final isDarkNow = Theme.of(context).brightness == Brightness.dark;
|
||||
final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
|
||||
settings.themeModeIndex = nextMode == ThemeMode.dark ? 2 : 1;
|
||||
|
||||
if (settings.isInBox) {
|
||||
await settings.save();
|
||||
} else {
|
||||
await box.put('default', settings);
|
||||
}
|
||||
|
||||
ref.read(themeProvider.notifier).state = nextMode;
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (mounted) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shineController.dispose();
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.maybeOf(context);
|
||||
final reducedMotion = (media?.disableAnimations ?? false) ||
|
||||
(media?.accessibleNavigation ?? false);
|
||||
_syncAnimation(reducedMotion: reducedMotion);
|
||||
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||
builder: (context, box, _) {
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final systemBottomInset =
|
||||
MediaQueryData.fromView(View.of(context)).viewPadding.bottom;
|
||||
|
||||
final simpleItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
activeIcon: Icon(LucideIcons.bookOpen),
|
||||
label: 'Tilawah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.headphones),
|
||||
activeIcon: Icon(LucideIcons.headphones),
|
||||
label: 'Murattal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.sparkles),
|
||||
activeIcon: Icon(LucideIcons.sparkles),
|
||||
label: 'Zikir',
|
||||
),
|
||||
];
|
||||
final items = isSimpleMode
|
||||
? const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.quran, "Al-Qur'an"),
|
||||
_NavDef(AppIcons.dzikir, 'Dzikir'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
]
|
||||
: const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.ibadah, 'Ibadah'),
|
||||
_NavDef(AppIcons.laporan, 'Laporan'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
];
|
||||
|
||||
final fullItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.listChecks),
|
||||
activeIcon: Icon(LucideIcons.listChecks),
|
||||
label: 'Ibadah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.barChart3),
|
||||
activeIcon: Icon(LucideIcons.barChart3),
|
||||
label: 'Laporan',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.wand2),
|
||||
activeIcon: Icon(LucideIcons.wand2),
|
||||
label: 'Alat',
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: currentIndex,
|
||||
onTap: onTap,
|
||||
items: isSimpleMode ? simpleItems : fullItems,
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
Colors.transparent,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onHorizontalDragStart: _handleHorizontalDragStart,
|
||||
onHorizontalDragUpdate: _handleHorizontalDragUpdate,
|
||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return IgnorePointer(
|
||||
ignoring: reveal < 0.55,
|
||||
child: _ThemeUnderlayBoard(
|
||||
isDark: isDark,
|
||||
reveal: reveal,
|
||||
onTap: () => _toggleThemeMode(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return Transform.translate(
|
||||
offset: Offset(-_toggleRevealWidth * reveal, 0),
|
||||
child: _MainNavBoard(
|
||||
items: items,
|
||||
currentIndex: widget.currentIndex,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
animation: _shineController,
|
||||
onTap: _handleNavTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,3 +231,477 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeUnderlayBoard extends StatelessWidget {
|
||||
const _ThemeUnderlayBoard({
|
||||
required this.isDark,
|
||||
required this.reveal,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isDark;
|
||||
final double reveal;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = reveal.clamp(0.0, 1.0).toDouble();
|
||||
return Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
width: _AppBottomNavBarState._toggleRevealWidth,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Transform.translate(
|
||||
offset: Offset((1 - t) * 10, 0),
|
||||
child: Transform.scale(
|
||||
scale: 0.94 + (t * 0.06),
|
||||
child: AppIcon(
|
||||
glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon,
|
||||
size: 24,
|
||||
color: isDark
|
||||
? AppColors.navActiveGoldPale
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavDef {
|
||||
const _NavDef(this.icon, this.label);
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _MainNavBoard extends StatelessWidget {
|
||||
const _MainNavBoard({
|
||||
required this.items,
|
||||
required this.currentIndex,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.animation,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<_NavDef> items;
|
||||
final int currentIndex;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final Animation<double> animation;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final background =
|
||||
Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
(isDark ? AppColors.surfaceDark : AppColors.surfaceLight);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isSelected = index == currentIndex;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(index),
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: isSelected
|
||||
? _AnimatedLuxuryActiveIcon(
|
||||
animation: animation,
|
||||
icon: item.icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
iconSize: _AppBottomNavBarState._activeIconSize,
|
||||
)
|
||||
: _InactiveNavIcon(
|
||||
glyph: item.icon,
|
||||
isDark: isDark,
|
||||
iconSize: _AppBottomNavBarState._inactiveIconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InactiveNavIcon extends StatelessWidget {
|
||||
const _InactiveNavIcon({
|
||||
required this.glyph,
|
||||
required this.isDark,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph glyph;
|
||||
final bool isDark;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: glyph,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLuxuryActiveIcon extends StatelessWidget {
|
||||
const _AnimatedLuxuryActiveIcon({
|
||||
required this.animation,
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, __) {
|
||||
return _LuxuryActiveIcon(
|
||||
icon: icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
progress: reducedMotion ? 0.17 : animation.value,
|
||||
iconSize: iconSize,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryActiveIcon extends StatelessWidget {
|
||||
const _LuxuryActiveIcon({
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.progress,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double progress;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseShadow = isDark
|
||||
? <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: Color(0xB3000000),
|
||||
blurRadius: 9,
|
||||
spreadRadius: 0.2,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navGlowDark,
|
||||
blurRadius: 8,
|
||||
spreadRadius: -0.8,
|
||||
offset: Offset(0, 1.5),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossHighlight,
|
||||
blurRadius: 3.2,
|
||||
offset: Offset(-1.1, -1.1),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossShadow,
|
||||
blurRadius: 7,
|
||||
offset: Offset(2.3, 3.1),
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A),
|
||||
width: 0.85,
|
||||
),
|
||||
boxShadow: baseShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF1A202A)
|
||||
: AppColors.navActiveSurfaceLight,
|
||||
borderRadius: BorderRadius.circular(12.5),
|
||||
border: Border.all(
|
||||
color:
|
||||
isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: _LuxuryRingPainter(
|
||||
progress: progress,
|
||||
reducedMotion: reducedMotion,
|
||||
isDark: isDark,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: icon,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryRingPainter extends CustomPainter {
|
||||
const _LuxuryRingPainter({
|
||||
required this.progress,
|
||||
required this.reducedMotion,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final bool reducedMotion;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
final outerRect = rect.deflate(1.05);
|
||||
final ringRect = rect.deflate(2.5);
|
||||
final innerRect = rect.deflate(4.75);
|
||||
|
||||
final outerRRect =
|
||||
RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4));
|
||||
final ringRRect =
|
||||
RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8));
|
||||
final innerRRect =
|
||||
RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1));
|
||||
final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2;
|
||||
|
||||
void drawEmboss(
|
||||
RRect target, {
|
||||
required Offset shadowOffset,
|
||||
required Offset highlightOffset,
|
||||
required Color shadowColor,
|
||||
required Color highlightColor,
|
||||
required double shadowBlur,
|
||||
required double highlightBlur,
|
||||
required double strokeWidth,
|
||||
}) {
|
||||
final shadowPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = shadowColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
|
||||
|
||||
final highlightPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = highlightColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(shadowOffset.dx, shadowOffset.dy);
|
||||
canvas.drawRRect(target, shadowPaint);
|
||||
canvas.restore();
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(highlightOffset.dx, highlightOffset.dy);
|
||||
canvas.drawRRect(target, highlightPaint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
drawEmboss(
|
||||
outerRRect,
|
||||
shadowOffset: const Offset(0.7, 1.0),
|
||||
highlightOffset: const Offset(-0.6, -0.7),
|
||||
shadowColor: isDark
|
||||
? const Color(0xD6000000)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.22)
|
||||
: Colors.white.withValues(alpha: 0.92),
|
||||
shadowBlur: isDark ? 2.5 : 1.8,
|
||||
highlightBlur: isDark ? 1.6 : 1.1,
|
||||
strokeWidth: 1.05,
|
||||
);
|
||||
|
||||
drawEmboss(
|
||||
innerRRect,
|
||||
shadowOffset: const Offset(-0.45, -0.35),
|
||||
highlightOffset: const Offset(0.45, 0.55),
|
||||
shadowColor: isDark
|
||||
? Colors.black.withValues(alpha: 0.58)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: Colors.white.withValues(alpha: 0.78),
|
||||
shadowBlur: isDark ? 1.5 : 1.2,
|
||||
highlightBlur: isDark ? 1.1 : 0.9,
|
||||
strokeWidth: 0.88,
|
||||
);
|
||||
|
||||
final metallicRing = SweepGradient(
|
||||
startAngle: rotation,
|
||||
endAngle: rotation + math.pi * 2,
|
||||
colors: const [
|
||||
AppColors.navActiveGoldDeep,
|
||||
AppColors.navActiveGold,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldPale,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldDeep,
|
||||
],
|
||||
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
|
||||
);
|
||||
|
||||
final metallicPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.9
|
||||
..shader = metallicRing.createShader(ringRect)
|
||||
..isAntiAlias = true;
|
||||
|
||||
final chromaStrength = isDark ? 0.92 : 0.74;
|
||||
final chromaSweep = SweepGradient(
|
||||
startAngle: (rotation * 1.3) + 0.42,
|
||||
endAngle: (rotation * 1.3) + 0.42 + math.pi * 2,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.94 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.88 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [
|
||||
0.0,
|
||||
0.09,
|
||||
0.112,
|
||||
0.126,
|
||||
0.14,
|
||||
0.175,
|
||||
0.45,
|
||||
0.468,
|
||||
0.484,
|
||||
0.5,
|
||||
0.528,
|
||||
1.0,
|
||||
],
|
||||
);
|
||||
|
||||
final chromaPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.15
|
||||
..shader = chromaSweep.createShader(ringRect)
|
||||
..blendMode = BlendMode.screen;
|
||||
|
||||
canvas.drawRRect(ringRRect, metallicPaint);
|
||||
canvas.drawRRect(ringRRect, chromaPaint);
|
||||
|
||||
final ambientGold = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = isDark ? 1.0 : 0.85
|
||||
..color = isDark
|
||||
? AppColors.navActiveGold.withValues(alpha: 0.18)
|
||||
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
|
||||
..maskFilter = MaskFilter.blur(
|
||||
BlurStyle.normal,
|
||||
isDark ? 2.6 : 1.2,
|
||||
);
|
||||
canvas.drawRRect(ringRRect, ambientGold);
|
||||
|
||||
final innerEdge = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8
|
||||
..color = isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: const Color(0x330F172A);
|
||||
canvas.drawRRect(innerRRect, innerEdge);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) {
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.reducedMotion != reducedMotion ||
|
||||
oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user