Files
jamshalat-diary/lib/core/widgets/ayat_share_sheet.dart
2026-03-18 00:07:10 +07:00

687 lines
23 KiB
Dart

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