- @capacitor/local-notifications 추가, src/native/reminders.js - 매일 설정 시각(기본 21시) 가계부 미기록 시 알림(오늘 기록하면 오늘자 제외, 7일 롤링) - 구독 해지 상태 만료 3일 전 알림 - 설정에 알림 받기 토글 + 시간 선택(앱에서만), 권한 요청 - 앱 시작/홈/내역에서 스케줄 보정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-1
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
@@ -577,6 +578,8 @@ async function submit() {
|
||||
await accountApi.create(payload)
|
||||
}
|
||||
formOpen.value = false
|
||||
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
|
||||
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
|
||||
await load()
|
||||
await loadPendingCount()
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,17 +1,66 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { exportBackup, importBackup } from '@/utils/backup'
|
||||
import { reminders } from '@/native/reminders'
|
||||
|
||||
const dialog = useDialog()
|
||||
const ui = useUiStore()
|
||||
const auth = useAuthStore()
|
||||
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
|
||||
|
||||
// ===== 알림(로컬) 설정 — 앱에서만 =====
|
||||
const notifSupported = reminders.isNative()
|
||||
const notifEnabled = ref(false)
|
||||
const notifHour = ref(21)
|
||||
const notifSaving = ref(false)
|
||||
|
||||
function subFromUser() {
|
||||
return { premium: auth.isPremium, autoRenew: !!auth.user?.planAutoRenew, expiresAt: auth.user?.planExpiresAt }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!notifSupported) return
|
||||
const s = await reminders.getSettings()
|
||||
notifEnabled.value = s.enabled
|
||||
notifHour.value = s.hour
|
||||
})
|
||||
|
||||
async function toggleNotif() {
|
||||
if (notifSaving.value) return
|
||||
const next = !notifEnabled.value
|
||||
notifSaving.value = true
|
||||
try {
|
||||
if (next) {
|
||||
const ok = await reminders.ensurePermission()
|
||||
if (!ok) {
|
||||
await dialog.alert('알림 권한이 꺼져 있어요. 기기 설정에서 알림을 허용해 주세요.')
|
||||
return
|
||||
}
|
||||
}
|
||||
notifEnabled.value = next
|
||||
await reminders.setSettings({ enabled: next, hour: notifHour.value })
|
||||
if (next) {
|
||||
await reminders.scheduleEntryReminder(false)
|
||||
await reminders.scheduleExpiryReminder(subFromUser())
|
||||
} else {
|
||||
await reminders.cancelAll()
|
||||
}
|
||||
} finally {
|
||||
notifSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeNotifHour(v) {
|
||||
notifHour.value = Number(v)
|
||||
await reminders.setSettings({ enabled: notifEnabled.value, hour: notifHour.value })
|
||||
if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
|
||||
}
|
||||
|
||||
const exporting = ref(false)
|
||||
async function doExport() {
|
||||
if (exporting.value) return
|
||||
@@ -125,6 +174,30 @@ async function clearAppData() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 알림 (앱 전용 — 로컬 알림) -->
|
||||
<section v-if="notifSupported" class="card">
|
||||
<div class="row">
|
||||
<div class="row-main">
|
||||
<span class="row-label">알림 받기</span>
|
||||
<span class="row-sub">가계부 기록 리마인더 · 구독 만료 안내</span>
|
||||
</div>
|
||||
<button
|
||||
type="button" class="switch" :class="{ on: notifEnabled }"
|
||||
:disabled="notifSaving" role="switch" :aria-checked="notifEnabled"
|
||||
@click="toggleNotif"
|
||||
><span class="knob"></span></button>
|
||||
</div>
|
||||
<div v-if="notifEnabled" class="row">
|
||||
<div class="row-main">
|
||||
<span class="row-label">리마인더 시간</span>
|
||||
<span class="row-sub">오늘 기록이 없으면 이 시각에 알려드려요</span>
|
||||
</div>
|
||||
<select class="res-select" :value="notifHour" @change="changeNotifHour($event.target.value)">
|
||||
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ String(h - 1).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PC(데스크톱) 전용: 창 크기 (가로·세로 각각) -->
|
||||
<section v-if="isDesktop" class="card">
|
||||
<div class="row">
|
||||
@@ -312,6 +385,38 @@ async function clearAppData() {
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #b0b0b0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s;
|
||||
padding: 0;
|
||||
}
|
||||
.switch.on {
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.switch .knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
.seg-btn {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 0;
|
||||
|
||||
Reference in New Issue
Block a user