diff --git a/package-lock.json b/package-lock.json index f472ed5..0e2f250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", "@capacitor/filesystem": "^8.1.2", + "@capacitor/local-notifications": "^8.2.0", "@capacitor/preferences": "^8.0.1", "@capacitor/share": "^8.0.1", "@capawesome/capacitor-google-sign-in": "^0.1.2", @@ -1202,6 +1203,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/local-notifications": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz", + "integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/preferences": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", diff --git a/package.json b/package.json index 4eb8998..f7609d0 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", "@capacitor/filesystem": "^8.1.2", + "@capacitor/local-notifications": "^8.2.0", "@capacitor/preferences": "^8.0.1", "@capacitor/share": "^8.0.1", "@capawesome/capacitor-google-sign-in": "^0.1.2", diff --git a/src/App.vue b/src/App.vue index 9d8d39d..87844a2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,6 +10,7 @@ import SignupModal from '@/components/SignupModal.vue' import WebOnlyNotice from '@/components/WebOnlyNotice.vue' import AppDialog from '@/components/ui/AppDialog.vue' import { Capacitor } from '@capacitor/core' +import { reminders } from '@/native/reminders' import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import { demo, exitDemo } from '@/demo' @@ -51,7 +52,18 @@ onMounted(() => { window.addEventListener('premium:required', onPremiumRequired) ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영) // 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영 - if (auth.isAuthenticated) auth.refreshProfile() + if (auth.isAuthenticated) { + auth.refreshProfile().then(() => { + if (reminders.isNative()) { + reminders.scheduleExpiryReminder({ + premium: auth.isPremium, + autoRenew: !!auth.user?.planAutoRenew, + expiresAt: auth.user?.planExpiresAt, + }) + } + }) + if (reminders.isNative()) reminders.scheduleEntryReminder(false) // 오늘 기록 여부는 홈/내역에서 보정 + } }) onUnmounted(() => { window.removeEventListener('auth:unauthorized', onUnauthorized) diff --git a/src/native/reminders.js b/src/native/reminders.js new file mode 100644 index 0000000..1ca9624 --- /dev/null +++ b/src/native/reminders.js @@ -0,0 +1,124 @@ +import { Capacitor } from '@capacitor/core' +import { Preferences } from '@capacitor/preferences' + +// 로컬 알림(앱 전용) — 가계부 기록 리마인더 + 구독 만료 임박 알림. +// 서버 푸시(FCM) 없이 기기에서 예약되며, 앱을 켜지 않아도 예약 시간에 표시된다. + +const PREF_ENABLED = 'notif_enabled' +const PREF_HOUR = 'notif_hour' +const ENTRY_BASE = 5000 // 가계부 리마인더 id 5000~5006 (7일 롤링) +const ENTRY_DAYS = 7 +const EXPIRY_ID = 5100 // 구독 만료 임박 id + +let LN = null +async function ln() { + if (!Capacitor.isNativePlatform()) return null + if (!LN) LN = (await import('@capacitor/local-notifications')).LocalNotifications + return LN +} + +async function cancelIds(ids) { + const n = await ln() + if (!n) return + try { + await n.cancel({ notifications: ids.map((id) => ({ id })) }) + } catch { + /* 무시 */ + } +} + +export const reminders = { + isNative() { + return Capacitor.isNativePlatform() + }, + + async getSettings() { + const e = (await Preferences.get({ key: PREF_ENABLED })).value + const h = (await Preferences.get({ key: PREF_HOUR })).value + return { enabled: e === '1', hour: h != null ? Number(h) : 21 } + }, + + async setSettings({ enabled, hour }) { + await Preferences.set({ key: PREF_ENABLED, value: enabled ? '1' : '0' }) + if (hour != null) await Preferences.set({ key: PREF_HOUR, value: String(hour) }) + }, + + /** 알림 권한 요청 (Android 13+). 허용되면 true */ + async ensurePermission() { + const n = await ln() + if (!n) return false + let p = await n.checkPermissions() + if (p.display !== 'granted') p = await n.requestPermissions() + return p.display === 'granted' + }, + + /** 가계부 리마인더 — 향후 7일 매일 예약(오늘 이미 기록했으면 오늘은 제외) */ + async scheduleEntryReminder(hasEntryToday = false) { + const n = await ln() + if (!n) return + await cancelIds(Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i)) + const { enabled, hour } = await this.getSettings() + if (!enabled) return + const now = new Date() + const list = [] + for (let i = 0; i < ENTRY_DAYS; i++) { + const at = new Date(now) + at.setDate(now.getDate() + i) + at.setHours(hour, 0, 0, 0) + if (at <= now) continue + if (i === 0 && hasEntryToday) continue + list.push({ + id: ENTRY_BASE + i, + title: '돈돼지 가계부', + body: '오늘 가계부 기록을 잊지 않으셨나요? 📝', + schedule: { at }, + }) + } + if (list.length) { + try { + await n.schedule({ notifications: list }) + } catch { + /* 무시 */ + } + } + }, + + /** 오늘 기록을 완료하면 오늘자 리마인더만 취소 */ + async clearTodayReminder() { + await cancelIds([ENTRY_BASE]) + }, + + /** + * 구독 만료 임박 알림 — 해지(자동갱신 off) 상태에서 만료 3일 전 예약. + * sub: { premium, autoRenew, expiresAt } + */ + async scheduleExpiryReminder(sub) { + const n = await ln() + if (!n) return + await cancelIds([EXPIRY_ID]) + const { enabled } = await this.getSettings() + if (!enabled || !sub || !sub.premium || sub.autoRenew || !sub.expiresAt) return + const exp = new Date(sub.expiresAt) + if (Number.isNaN(exp.getTime())) return + const at = new Date(exp.getTime() - 3 * 24 * 60 * 60 * 1000) + at.setHours(10, 0, 0, 0) + if (at <= new Date()) return + try { + await n.schedule({ + notifications: [{ + id: EXPIRY_ID, + title: '돈돼지 가계부', + body: `프리미엄 구독이 ${exp.getFullYear()}.${exp.getMonth() + 1}.${exp.getDate()}에 만료돼요. 계속 이용하려면 갱신해 주세요.`, + schedule: { at }, + }], + }) + } catch { + /* 무시 */ + } + }, + + /** 알림 전체 해제 (토글 off) */ + async cancelAll() { + await cancelIds([...Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i), EXPIRY_ID]) + }, +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 1b7b5e8..6a640e5 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import { accountApi } from '@/api/accountApi' +import { reminders } from '@/native/reminders' import { ID_LOGIN_ENABLED } from '@/config/features' import { enterDemo } from '@/demo' @@ -125,6 +126,12 @@ async function load() { budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0) spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0) entries.value = list || [] + // 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외) + if (reminders.isNative()) { + const t = new Date() + const today = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}` + reminders.scheduleEntryReminder((list || []).some((e) => e.entryDate === today)) + } } catch (e) { error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.' } finally { diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 92e98d4..9881f88 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -1,6 +1,7 @@