Kantongku: notifikasi pengingat catat harian (toggle + pilih jam)
This commit is contained in:
@@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import { initDatabase } from './src/db/database';
|
import { initDatabase } from './src/db/database';
|
||||||
import { checkRecurringDue, getSetting } from './src/db/repositories';
|
import { checkRecurringDue, getSetting } from './src/db/repositories';
|
||||||
|
import { scheduleDailyReminder } from './src/utils/notifications';
|
||||||
import { RootNavigator } from './src/navigation';
|
import { RootNavigator } from './src/navigation';
|
||||||
import { colors } from './src/theme/colors';
|
import { colors } from './src/theme/colors';
|
||||||
|
|
||||||
@@ -27,6 +28,15 @@ export default function App() {
|
|||||||
const onboarded = await getSetting('onboarded');
|
const onboarded = await getSetting('onboarded');
|
||||||
const userName = await getSetting('user_name');
|
const userName = await getSetting('user_name');
|
||||||
setInitialRoute(onboarded === '1' && userName ? 'Main' : 'Onboarding');
|
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);
|
setReady(true);
|
||||||
SplashScreen.hideAsync().catch(() => {});
|
SplashScreen.hideAsync().catch(() => {});
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+47
-13
@@ -17,6 +17,7 @@
|
|||||||
"expo-document-picker": "~57.0.1",
|
"expo-document-picker": "~57.0.1",
|
||||||
"expo-file-system": "~57.0.4",
|
"expo-file-system": "~57.0.4",
|
||||||
"expo-linear-gradient": "~57.0.1",
|
"expo-linear-gradient": "~57.0.1",
|
||||||
|
"expo-notifications": "~57.0.11",
|
||||||
"expo-sharing": "~57.0.12",
|
"expo-sharing": "~57.0.12",
|
||||||
"expo-splash-screen": "~57.0.6",
|
"expo-splash-screen": "~57.0.6",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
@@ -2134,6 +2135,12 @@
|
|||||||
"@babel/plugin-syntax-flow": "^7.12.1"
|
"@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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"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": {
|
"node_modules/expo-document-picker": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz",
|
||||||
@@ -3040,6 +3069,24 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-server": {
|
||||||
"version": "57.0.3",
|
"version": "57.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
||||||
@@ -3407,19 +3454,6 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo/node_modules/expo-keep-awake": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"expo-document-picker": "~57.0.1",
|
"expo-document-picker": "~57.0.1",
|
||||||
"expo-file-system": "~57.0.4",
|
"expo-file-system": "~57.0.4",
|
||||||
"expo-linear-gradient": "~57.0.1",
|
"expo-linear-gradient": "~57.0.1",
|
||||||
|
"expo-notifications": "~57.0.11",
|
||||||
"expo-sharing": "~57.0.12",
|
"expo-sharing": "~57.0.12",
|
||||||
"expo-splash-screen": "~57.0.6",
|
"expo-splash-screen": "~57.0.6",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
@@ -17,11 +18,17 @@ import { File, Paths } from 'expo-file-system';
|
|||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
import { colors } from '../theme/colors';
|
import { colors } from '../theme/colors';
|
||||||
import { getDb } from '../db/database';
|
import { getDb } from '../db/database';
|
||||||
|
import {
|
||||||
|
cancelReminder,
|
||||||
|
ensureNotificationPermission,
|
||||||
|
scheduleDailyReminder,
|
||||||
|
} from '../utils/notifications';
|
||||||
import {
|
import {
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
deleteRecurring,
|
deleteRecurring,
|
||||||
getCategories,
|
getCategories,
|
||||||
getRecurring,
|
getRecurring,
|
||||||
|
getSetting,
|
||||||
getSettingNumber,
|
getSettingNumber,
|
||||||
setSetting,
|
setSetting,
|
||||||
} from '../db/repositories';
|
} from '../db/repositories';
|
||||||
@@ -77,12 +84,26 @@ export function SettingsScreen() {
|
|||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
getSettingNumber('auto_save_percent', 10).then((p) => {
|
loadSettings().then(() => {
|
||||||
if (active) setPercent(p);
|
if (!active) return;
|
||||||
});
|
});
|
||||||
getRecurring().then((r) => {
|
getRecurring().then((r) => {
|
||||||
if (active) setRecurring(r);
|
if (active) setRecurring(r);
|
||||||
@@ -93,9 +114,38 @@ export function SettingsScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
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) => {
|
const handleDeleteCategory = (category: Category) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Hapus kategori?',
|
'Hapus kategori?',
|
||||||
@@ -307,6 +357,53 @@ export function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
|
{/* Pengingat */}
|
||||||
|
<Text style={styles.sectionTitle}>Pengingat</Text>
|
||||||
|
<View style={[styles.card, styles.dataCard]}>
|
||||||
|
<View style={styles.reminderRow}>
|
||||||
|
<View style={styles.dataIconCircle}>
|
||||||
|
<Ionicons name="notifications" size={18} color={colors.primaryDark} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dataInfo}>
|
||||||
|
<Text style={styles.dataTitle}>Pengingat catat harian</Text>
|
||||||
|
<Text style={styles.dataHint}>
|
||||||
|
{reminderEnabled
|
||||||
|
? `Aktif setiap hari jam ${reminderHour}:00`
|
||||||
|
: 'Ingatkan saya untuk mencatat setiap hari'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={reminderEnabled}
|
||||||
|
onValueChange={handleToggleReminder}
|
||||||
|
trackColor={{ true: colors.primary, false: colors.border }}
|
||||||
|
thumbColor={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{reminderEnabled && (
|
||||||
|
<View style={styles.reminderHours}>
|
||||||
|
{[19, 20, 21, 22].map((h) => (
|
||||||
|
<Pressable
|
||||||
|
key={h}
|
||||||
|
style={[
|
||||||
|
styles.hourChip,
|
||||||
|
reminderHour === h && styles.hourChipActive,
|
||||||
|
]}
|
||||||
|
onPress={() => handlePickHour(h)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.hourChipText,
|
||||||
|
reminderHour === h && { color: colors.white, fontWeight: '700' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{h}:00
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Transaksi Berulang */}
|
{/* Transaksi Berulang */}
|
||||||
<Text style={styles.sectionTitle}>Transaksi Berulang</Text>
|
<Text style={styles.sectionTitle}>Transaksi Berulang</Text>
|
||||||
<View style={[styles.card, styles.dataCard]}>
|
<View style={[styles.card, styles.dataCard]}>
|
||||||
@@ -649,6 +746,30 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
paddingVertical: 8,
|
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 },
|
dataRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 6 },
|
||||||
dataIconCircle: {
|
dataIconCircle: {
|
||||||
width: 44,
|
width: 44,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
|
||||||
|
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user