diff --git a/App.tsx b/App.tsx index b6d92c3..b6fa08d 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { NavigationContainer } from '@react-navigation/native'; import { initDatabase } from './src/db/database'; import { checkRecurringDue, getSetting } from './src/db/repositories'; +import { scheduleDailyReminder } from './src/utils/notifications'; import { RootNavigator } from './src/navigation'; import { colors } from './src/theme/colors'; @@ -27,6 +28,15 @@ export default function App() { const onboarded = await getSetting('onboarded'); const userName = await getSetting('user_name'); setInitialRoute(onboarded === '1' && userName ? 'Main' : 'Onboarding'); + // Reschedule pengingat harian kalau aktif + try { + if ((await getSetting('reminder_enabled')) === '1') { + const h = Number(await getSetting('reminder_hour')); + await scheduleDailyReminder(Number.isFinite(h) ? h : 20, 0); + } + } catch { + // abaikan + } setReady(true); SplashScreen.hideAsync().catch(() => {}); }) diff --git a/package-lock.json b/package-lock.json index 3cde7ec..12fffd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.4", "expo-linear-gradient": "~57.0.1", + "expo-notifications": "~57.0.11", "expo-sharing": "~57.0.12", "expo-splash-screen": "~57.0.6", "expo-sqlite": "~57.0.1", @@ -2134,6 +2135,12 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2951,6 +2958,28 @@ } } }, + "node_modules/expo-application": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-constants": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.11.tgz", + "integrity": "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-document-picker": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz", @@ -3040,6 +3069,24 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.11.tgz", + "integrity": "sha512-hQz01re2eee9X4GyQ73TVohhDDq1vAe6wXDMFrLMdbcPtiPuvlr1EfNlT12zDwsc14n0KchaFnpETWk+fwUbTw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~57.0.2", + "expo-constants": "~57.0.11" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", @@ -3407,19 +3454,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-constants": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.11.tgz", - "integrity": "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.4.2" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz", diff --git a/package.json b/package.json index d8c3264..260c4de 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.4", "expo-linear-gradient": "~57.0.1", + "expo-notifications": "~57.0.11", "expo-sharing": "~57.0.12", "expo-splash-screen": "~57.0.6", "expo-sqlite": "~57.0.1", diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 434c63e..40b2d60 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -9,6 +9,7 @@ import { Pressable, ScrollView, StyleSheet, + Switch, Text, TextInput, View, @@ -17,11 +18,17 @@ import { File, Paths } from 'expo-file-system'; import * as Sharing from 'expo-sharing'; import { colors } from '../theme/colors'; import { getDb } from '../db/database'; +import { + cancelReminder, + ensureNotificationPermission, + scheduleDailyReminder, +} from '../utils/notifications'; import { deleteCategory, deleteRecurring, getCategories, getRecurring, + getSetting, getSettingNumber, setSetting, } from '../db/repositories'; @@ -77,12 +84,26 @@ export function SettingsScreen() { }> >([]); const [categories, setCategories] = useState([]); + const [reminderEnabled, setReminderEnabled] = useState(false); + const [reminderHour, setReminderHour] = useState(20); + + const loadSettings = useCallback(async () => { + const [p, en, hr] = await Promise.all([ + getSettingNumber('auto_save_percent', 10), + getSetting('reminder_enabled'), + getSetting('reminder_hour'), + ]); + setPercent(p); + setReminderEnabled(en === '1'); + const h = Number(hr); + setReminderHour(Number.isFinite(h) && h >= 0 && h <= 23 ? h : 20); + }, []); useFocusEffect( useCallback(() => { let active = true; - getSettingNumber('auto_save_percent', 10).then((p) => { - if (active) setPercent(p); + loadSettings().then(() => { + if (!active) return; }); getRecurring().then((r) => { if (active) setRecurring(r); @@ -93,9 +114,38 @@ export function SettingsScreen() { return () => { active = false; }; - }, []) + }, [loadSettings]) ); + const handleToggleReminder = async (enabled: boolean) => { + if (enabled) { + const ok = await ensureNotificationPermission(); + if (!ok) { + Alert.alert( + 'Izin notifikasi ditolak', + 'Aktifkan izin notifikasi di pengaturan HP untuk memakai pengingat.' + ); + return; + } + await scheduleDailyReminder(reminderHour, 0); + await setSetting('reminder_enabled', '1'); + await setSetting('reminder_hour', String(reminderHour)); + setReminderEnabled(true); + } else { + await cancelReminder(); + await setSetting('reminder_enabled', '0'); + setReminderEnabled(false); + } + }; + + const handlePickHour = async (hour: number) => { + setReminderHour(hour); + await setSetting('reminder_hour', String(hour)); + if (reminderEnabled) { + await scheduleDailyReminder(hour, 0); + } + }; + const handleDeleteCategory = (category: Category) => { Alert.alert( 'Hapus kategori?', @@ -307,6 +357,53 @@ export function SettingsScreen() { + {/* Pengingat */} + Pengingat + + + + + + + Pengingat catat harian + + {reminderEnabled + ? `Aktif setiap hari jam ${reminderHour}:00` + : 'Ingatkan saya untuk mencatat setiap hari'} + + + + + {reminderEnabled && ( + + {[19, 20, 21, 22].map((h) => ( + handlePickHour(h)} + > + + {h}:00 + + + ))} + + )} + + {/* Transaksi Berulang */} Transaksi Berulang @@ -649,6 +746,30 @@ const styles = StyleSheet.create({ gap: 12, paddingVertical: 8, }, + reminderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + reminderHours: { + flexDirection: 'row', + gap: 8, + marginTop: 12, + paddingLeft: 50, + }, + hourChip: { + backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 8, + }, + hourChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + hourChipText: { fontSize: 13, color: colors.textMuted, fontWeight: '600' }, dataRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 6 }, dataIconCircle: { width: 44, diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 0000000..3cdc9c0 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,34 @@ +import * as Notifications from 'expo-notifications'; + +export async function ensureNotificationPermission(): Promise { + try { + const { status } = await Notifications.getPermissionsAsync(); + if (status === 'granted') return true; + const req = await Notifications.requestPermissionsAsync(); + return req.status === 'granted'; + } catch { + return false; + } +} + +export async function scheduleDailyReminder( + hour: number, + minute: number +): Promise { + await Notifications.cancelAllScheduledNotificationsAsync(); + await Notifications.scheduleNotificationAsync({ + content: { + title: 'Kantongku 👛', + body: 'Belum catat hari ini? 5 detik aja, kak — biar saldo & tabunganmu tetap kejaga!', + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DAILY, + hour, + minute, + }, + }); +} + +export async function cancelReminder(): Promise { + await Notifications.cancelAllScheduledNotificationsAsync(); +}