Polish navigation, Quran flows, and sharing UX
This commit is contained in:
94
lib/core/widgets/arabic_text.dart
Normal file
94
lib/core/widgets/arabic_text.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
686
lib/core/widgets/ayat_share_sheet.dart
Normal file
686
lib/core/widgets/ayat_share_sheet.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
214
lib/core/widgets/ayat_today_card.dart
Normal file
214
lib/core/widgets/ayat_today_card.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
166
lib/core/widgets/notification_bell_button.dart
Normal file
166
lib/core/widgets/notification_bell_button.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user