feat: 로컬 알림 — 가계부 기록 리마인더 + 구독 만료 임박 (앱 전용)
CI / build (push) Failing after 14m29s

- @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:
ByungCheol
2026-06-28 17:42:00 +09:00
parent fa3b12b369
commit 127551da12
7 changed files with 264 additions and 2 deletions
+7
View File
@@ -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 {
+3
View File
@@ -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) {
+106 -1
View File
@@ -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;