Polish navigation, Quran flows, and sharing UX

This commit is contained in:
Dwindi Ramadhana
2026-03-18 00:07:10 +07:00
parent a049129a35
commit 2d09b5b356
59 changed files with 11835 additions and 3184 deletions

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
/// Arabic text widget that reacts to [AppSettings.arabicFontSize].
///
/// `baseFontSize` keeps per-screen visual hierarchy while still following
/// global user preference from Settings.
class ArabicText extends StatelessWidget {
const ArabicText(
this.data, {
super.key,
this.baseFontSize = 24,
this.fontWeight = FontWeight.w400,
this.height,
this.color,
this.textAlign,
this.maxLines,
this.overflow,
this.textDirection,
this.fontStyle,
this.letterSpacing,
});
final String data;
final double baseFontSize;
final FontWeight fontWeight;
final double? height;
final Color? color;
final TextAlign? textAlign;
final int? maxLines;
final TextOverflow? overflow;
final TextDirection? textDirection;
final FontStyle? fontStyle;
final double? letterSpacing;
static const double _explicitLineHeightCompression = 0.9;
static const double _defaultArabicLineHeight = 1.8;
static const String _primaryArabicFontFamily = 'ScheherazadeNew';
static const List<String> _arabicFallbackFamilies = <String>[
'UthmanTahaNaskh',
'KFGQPCUthmanicHafs',
'Amiri',
'Noto Naskh Arabic',
'Noto Sans Arabic',
'Droid Arabic Naskh',
'sans-serif',
];
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<AppSettings>>(
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings)
.listenable(keys: ['default']),
builder: (_, box, __) {
final preferredSize = box.get('default')?.arabicFontSize ?? 24.0;
final adjustedSize = (baseFontSize + (preferredSize - 24.0))
.clamp(12.0, 56.0)
.toDouble();
final effectiveHeight = height == null
? _defaultArabicLineHeight
: (height! * _explicitLineHeightCompression)
.clamp(1.6, 2.35)
.toDouble();
return Text(
data,
textAlign: textAlign,
maxLines: maxLines,
overflow: overflow,
textDirection: textDirection,
strutStyle: StrutStyle(
fontFamily: _primaryArabicFontFamily,
fontSize: adjustedSize,
height: effectiveHeight,
leading: 0.08,
forceStrutHeight: true,
),
style: TextStyle(
fontFamily: _primaryArabicFontFamily,
fontFamilyFallback: _arabicFallbackFamilies,
fontSize: adjustedSize,
fontWeight: fontWeight,
height: effectiveHeight,
color: color,
fontStyle: fontStyle,
letterSpacing: letterSpacing,
),
);
},
);
}
}

View File

@@ -0,0 +1,686 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:share_plus/share_plus.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../core/widgets/arabic_text.dart';
String buildAyatShareText(Map<String, dynamic> ayat) {
final arabic = (ayat['teksArab'] ?? '').toString().trim();
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
final surahName = (ayat['surahName'] ?? '').toString().trim();
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
? 'QS. $surahName: $verseNumber'
: 'Ayat Hari Ini';
final parts = <String>[
if (arabic.isNotEmpty) arabic,
if (translation.isNotEmpty) '"$translation"',
reference,
'Dibagikan dari Jam Shalat Diary',
];
return parts.join('\n\n');
}
Future<void> showAyatShareSheet(
BuildContext context,
Map<String, dynamic> ayat,
) async {
final shareText = buildAyatShareText(ayat);
final isDark = Theme.of(context).brightness == Brightness.dark;
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) {
Future<void> handleShareImage() async {
Navigator.of(sheetContext).pop();
try {
final pngBytes = await _captureAyatShareCardPng(context, ayat);
final file = await _writeAyatShareImage(pngBytes);
await Share.shareXFiles(
[XFile(file.path)],
text: 'Ayat Hari Ini',
subject: 'Ayat Hari Ini',
);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Gagal menyiapkan gambar ayat'),
duration: Duration(seconds: 2),
),
);
}
}
Future<void> handleShareText() async {
Navigator.of(sheetContext).pop();
await Share.share(
shareText,
subject: 'Ayat Hari Ini',
);
}
Future<void> handleCopyText() async {
await Clipboard.setData(ClipboardData(text: shareText));
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Teks ayat disalin ke clipboard'),
duration: Duration(seconds: 2),
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bagikan Ayat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Pilih cara tercepat untuk membagikan ayat hari ini.',
style: TextStyle(
fontSize: 13,
height: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 18),
_AyatShareActionTile(
icon: const Icon(
LucideIcons.image,
size: 20,
color: AppColors.primary,
),
title: 'Bagikan Gambar',
subtitle: 'Kirim kartu ayat yang siap dibagikan',
badge: 'Utama',
onTap: handleShareImage,
),
const SizedBox(height: 10),
_AyatShareActionTile(
icon: const AppIcon(
glyph: AppIcons.share,
size: 20,
color: AppColors.primary,
),
title: 'Bagikan Teks',
subtitle: 'Kirim ayat dan terjemahan ke aplikasi lain',
onTap: handleShareText,
),
const SizedBox(height: 10),
_AyatShareActionTile(
icon: const Icon(
LucideIcons.copy,
size: 20,
color: AppColors.primary,
),
title: 'Salin Teks',
subtitle: 'Simpan ke clipboard untuk ditempel manual',
onTap: handleCopyText,
),
],
),
);
},
);
}
Future<Uint8List> _captureAyatShareCardPng(
BuildContext context,
Map<String, dynamic> ayat,
) async {
final overlay = Overlay.of(context, rootOverlay: true);
final boundaryKey = GlobalKey();
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final textDirection = Directionality.of(context);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => IgnorePointer(
child: Material(
color: Colors.transparent,
child: Align(
alignment: Alignment.topCenter,
child: Opacity(
opacity: 0.01,
child: MediaQuery(
data: mediaQuery,
child: Theme(
data: theme,
child: Directionality(
textDirection: textDirection,
child: UnconstrainedBox(
constrainedAxis: Axis.horizontal,
child: RepaintBoundary(
key: boundaryKey,
child: _AyatShareCard(
ayat: ayat,
isDark: theme.brightness == Brightness.dark,
),
),
),
),
),
),
),
),
),
),
);
overlay.insert(entry);
try {
await Future<void>.delayed(const Duration(milliseconds: 20));
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 20));
final boundary = boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) {
throw StateError('Ayat share card is not ready');
}
final image = await boundary.toImage(pixelRatio: 3);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
throw StateError('Failed to encode ayat share card');
}
return byteData.buffer.asUint8List();
} finally {
entry.remove();
}
}
Future<File> _writeAyatShareImage(Uint8List pngBytes) async {
final directory = await Directory.systemTemp.createTemp('jamshalat_ayat_');
final file = File('${directory.path}/ayat_hari_ini.png');
await file.writeAsBytes(pngBytes, flush: true);
return file;
}
class _AyatShareCard extends StatelessWidget {
const _AyatShareCard({
required this.ayat,
required this.isDark,
});
final Map<String, dynamic> ayat;
final bool isDark;
@override
Widget build(BuildContext context) {
final arabic = (ayat['teksArab'] ?? '').toString().trim();
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
final surahName = (ayat['surahName'] ?? '').toString().trim();
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
? 'QS. $surahName: $verseNumber'
: 'Ayat Hari Ini';
final isLongArabic = arabic.length > 120;
final isVeryLongArabic = arabic.length > 180;
final isLongTranslation = translation.length > 140;
final isVeryLongTranslation = translation.length > 220;
final arabicFontSize = isVeryLongArabic
? 22.0
: isLongArabic
? 24.0
: 28.0;
final arabicHeight = isVeryLongArabic
? 1.55
: isLongArabic
? 1.62
: 1.75;
final translationFontSize = isVeryLongTranslation
? 13.0
: isLongTranslation
? 14.0
: 15.0;
final translationHeight = isVeryLongTranslation ? 1.5 : 1.6;
final verticalPadding = isVeryLongTranslation ? 22.0 : 24.0;
return SizedBox(
width: 360,
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? const [
Color(0xFF102028),
Color(0xFF0F1217),
Color(0xFF16343A),
]
: const [
Color(0xFFF6FBFB),
Color(0xFFFFFFFF),
Color(0xFFEAF7F7),
],
),
boxShadow: [
BoxShadow(
color:
AppColors.primary.withValues(alpha: isDark ? 0.24 : 0.12),
blurRadius: 28,
offset: const Offset(0, 18),
),
],
),
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.all(14),
child: CustomPaint(
painter: _AyatFramePainter(isDark: isDark),
),
),
),
),
Positioned(
top: -38,
right: -34,
child: Container(
width: 116,
height: 116,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.05),
),
),
),
Positioned(
bottom: -46,
left: -28,
child: Container(
width: 132,
height: 132,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.navActiveGold.withValues(alpha: 0.05),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
28,
28,
28,
verticalPadding,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
LucideIcons.bookMarked,
size: 20,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ayat Hari Ini',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.3,
color: AppColors.primary,
),
),
const SizedBox(height: 3),
Text(
reference,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? Colors.white
: AppColors.textPrimaryLight,
),
),
],
),
),
],
),
if (arabic.isNotEmpty) ...[
const SizedBox(height: 28),
ArabicText(
arabic,
baseFontSize: arabicFontSize,
height: arabicHeight,
textAlign: TextAlign.right,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
],
if (translation.isNotEmpty) ...[
const SizedBox(height: 24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: isDark ? 0.05 : 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: 0.08),
),
),
child: Text(
'"$translation"',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: translationFontSize,
height: translationHeight,
fontStyle: FontStyle.italic,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
),
),
],
const SizedBox(height: 22),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color:
AppColors.navActiveGold.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'Jam Shalat Diary',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.navActiveGoldDeep,
),
),
),
const Spacer(),
Flexible(
child: Text(
'Bagikan kebaikan',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _AyatShareActionTile extends StatelessWidget {
const _AyatShareActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.badge,
});
final Widget icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final String? badge;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.12)
: AppColors.cream,
),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Center(child: icon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
if (badge != null) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
AppColors.navActiveGold.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge!,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: AppColors.navActiveGoldDeep,
),
),
),
],
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
height: 1.4,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 18,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
],
),
),
),
);
}
}
class _AyatFramePainter extends CustomPainter {
const _AyatFramePainter({required this.isDark});
final bool isDark;
@override
void paint(Canvas canvas, Size size) {
final outerRect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(22),
);
final outerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.6
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.navActiveGoldPale,
AppColors.navActiveGold,
AppColors.navActiveGoldDeep,
],
).createShader(Offset.zero & size);
final outerGlow = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 5
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.08 : 0.05);
final innerBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
final innerFrame = RRect.fromRectAndRadius(
innerBounds,
const Radius.circular(18),
);
final innerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: isDark ? 0.08 : 0.10);
canvas.drawRRect(outerRect, outerGlow);
canvas.drawRRect(outerRect, outerPaint);
canvas.drawRRect(innerFrame, innerPaint);
_drawMidMotif(canvas, size, top: true);
_drawMidMotif(canvas, size, top: false);
}
void _drawMidMotif(Canvas canvas, Size size, {required bool top}) {
final y = top ? 14.0 : size.height - 14.0;
final centerX = size.width / 2;
final linePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.9
..strokeCap = StrokeCap.round
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.26 : 0.22);
final diamondPaint = Paint()
..style = PaintingStyle.fill
..color = AppColors.primary.withValues(alpha: isDark ? 0.34 : 0.22);
final diamondStroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.9
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.58 : 0.48);
canvas.drawLine(
Offset(centerX - 26, y),
Offset(centerX - 10, y),
linePaint,
);
canvas.drawLine(
Offset(centerX + 10, y),
Offset(centerX + 26, y),
linePaint,
);
final diamondPath = Path()
..moveTo(centerX, y - 5)
..lineTo(centerX + 5, y)
..lineTo(centerX, y + 5)
..lineTo(centerX - 5, y)
..close();
canvas.drawPath(diamondPath, diamondPaint);
canvas.drawPath(diamondPath, diamondStroke);
}
@override
bool shouldRepaint(covariant _AyatFramePainter oldDelegate) {
return oldDelegate.isDark != isDark;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../data/services/muslim_api_service.dart';
import 'arabic_text.dart';
import 'ayat_share_sheet.dart';
class AyatTodayCard extends StatefulWidget {
const AyatTodayCard({
super.key,
required this.headerText,
required this.headerStyle,
});
final String headerText;
final TextStyle headerStyle;
@override
State<AyatTodayCard> createState() => _AyatTodayCardState();
}
class _AyatTodayCardState extends State<AyatTodayCard> {
Map<String, dynamic>? _dailyAyat;
Map<String, dynamic>? _activeAyat;
bool _isLoading = true;
bool _isRandomizing = false;
bool _showingRandomAyat = false;
@override
void initState() {
super.initState();
_loadDailyAyat();
}
Future<void> _loadDailyAyat() async {
final ayat = await MuslimApiService.instance.getDailyAyat();
if (!mounted) return;
setState(() {
_dailyAyat = ayat;
_activeAyat = ayat;
_showingRandomAyat = false;
_isLoading = false;
});
}
Future<void> _showRandomAyat() async {
if (_isRandomizing || _activeAyat == null) return;
setState(() => _isRandomizing = true);
final randomAyat = await MuslimApiService.instance.getRandomAyat(
excludeSurahNumber: _asInt(_activeAyat?['nomorSurah']),
excludeAyahNumber: _asInt(_activeAyat?['nomorAyat']),
);
if (!mounted) return;
setState(() {
_isRandomizing = false;
if (randomAyat != null) {
_activeAyat = randomAyat;
_showingRandomAyat = true;
}
});
}
void _restoreDailyAyat() {
if (_dailyAyat == null) return;
setState(() {
_activeAyat = _dailyAyat;
_showingRandomAyat = false;
});
}
int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final backgroundColor = isDark
? AppColors.primary.withValues(alpha: 0.08)
: const Color(0xFFF5F9F0);
if (_isLoading) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: const Center(child: CircularProgressIndicator()),
);
}
final data = _activeAyat;
if (data == null) return const SizedBox.shrink();
final secondaryColor =
isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(widget.headerText, style: widget.headerStyle)),
IconButton(
icon: AppIcon(
glyph: AppIcons.share,
size: 18,
color: secondaryColor,
),
tooltip: 'Bagikan ayat',
onPressed: () => showAyatShareSheet(context, data),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: ArabicText(
data['teksArab'] ?? '',
baseFontSize: 24,
fontWeight: FontWeight.w400,
height: 1.8,
textAlign: TextAlign.right,
),
),
const SizedBox(height: 16),
Text(
'"${data['teksIndonesia'] ?? ''}"',
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'QS. ${data['surahName']}: ${data['nomorAyat']}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
TextButton.icon(
onPressed: _isRandomizing ? null : _showRandomAyat,
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
backgroundColor: AppColors.primary.withValues(
alpha: isDark ? 0.16 : 0.12,
),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
),
icon: _isRandomizing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
)
: const AppIcon(
glyph: AppIcons.shuffle,
size: 16,
color: AppColors.primary,
),
label: Text(_showingRandomAyat ? 'Acak Lagi' : 'Ayat Lain'),
),
if (_showingRandomAyat)
TextButton(
onPressed: _restoreDailyAyat,
style: TextButton.styleFrom(
foregroundColor: secondaryColor,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
),
child: const Text('Kembali ke Hari Ini'),
),
],
),
],
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,166 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
import '../../data/services/notification_service.dart';
import '../../data/services/notification_inbox_service.dart';
import '../../features/dashboard/data/prayer_times_provider.dart';
class NotificationBellButton extends StatelessWidget {
const NotificationBellButton({
super.key,
this.iconColor,
this.iconSize = 22,
this.onPressed,
this.showBadge = true,
});
final Color? iconColor;
final double iconSize;
final VoidCallback? onPressed;
final bool showBadge;
@override
Widget build(BuildContext context) {
final inbox = NotificationInboxService.instance;
return ValueListenableBuilder(
valueListenable: inbox.listenable(),
builder: (context, _, __) {
final unread = showBadge ? inbox.unreadCount() : 0;
return IconButton(
onPressed: onPressed ??
() {
context.push('/notifications');
},
onLongPress: () => _showQuickActions(context),
icon: Stack(
clipBehavior: Clip.none,
children: [
AppIcon(
glyph: AppIcons.notification,
color: iconColor,
size: iconSize,
),
if (unread > 0)
Positioned(
right: -6,
top: -4,
child: Container(
constraints:
const BoxConstraints(minWidth: 16, minHeight: 16),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 1.4,
),
),
child: Center(
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
height: 1,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
);
},
);
}
Future<void> _showQuickActions(BuildContext context) async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
final alarmsOn = settings.adhanEnabled.values.any((v) => v);
final isDark = Theme.of(context).brightness == Brightness.dark;
await showModalBottomSheet<void>(
context: context,
backgroundColor:
isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (sheetContext) {
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: isDark
? AppColors.textSecondaryDark.withValues(alpha: 0.4)
: AppColors.textSecondaryLight.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 10),
ListTile(
leading: Icon(
alarmsOn
? Icons.notifications_off_outlined
: Icons.notifications_active_outlined,
),
title: Text(alarmsOn
? 'Nonaktifkan Alarm Sholat'
: 'Aktifkan Alarm Sholat'),
onTap: () async {
final container =
ProviderScope.containerOf(context, listen: false);
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
await settings.save();
if (alarmsOn) {
await NotificationService.instance.cancelAllPending();
}
container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future));
if (sheetContext.mounted) Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.sync_rounded),
title: const Text('Sinkronkan Sekarang'),
onTap: () {
final container =
ProviderScope.containerOf(context, listen: false);
container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future));
if (sheetContext.mounted) Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Buka Pengaturan'),
onTap: () {
if (sheetContext.mounted) Navigator.pop(sheetContext);
context.push('/settings');
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
class ToolCard extends StatelessWidget {
final IconData icon;
final AppIconGlyph icon;
final String title;
final Color color;
final bool isDark;
@@ -28,9 +29,7 @@ class ToolCard extends StatelessWidget {
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? color.withValues(alpha: 0.15)
: AppColors.cream,
color: isDark ? color.withValues(alpha: 0.15) : AppColors.cream,
),
boxShadow: [
BoxShadow(
@@ -51,7 +50,14 @@ class ToolCard extends StatelessWidget {
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 24),
child: Padding(
padding: const EdgeInsets.all(8),
child: AppIcon(
glyph: icon,
color: color,
size: 24,
),
),
),
Text(
title,