Merge branch 'dev'
Deploy / deploy (push) Failing after 12m58s

This commit is contained in:
ByungCheol
2026-06-28 17:42:02 +09:00
7 changed files with 264 additions and 2 deletions
+10
View File
@@ -14,6 +14,7 @@
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2", "@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1", "@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
@@ -1202,6 +1203,15 @@
"@capacitor/core": ">=8.0.0" "@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": { "node_modules/@capacitor/preferences": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
+1
View File
@@ -47,6 +47,7 @@
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2", "@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1", "@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
+13 -1
View File
@@ -10,6 +10,7 @@ import SignupModal from '@/components/SignupModal.vue'
import WebOnlyNotice from '@/components/WebOnlyNotice.vue' import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
import AppDialog from '@/components/ui/AppDialog.vue' import AppDialog from '@/components/ui/AppDialog.vue'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo, exitDemo } from '@/demo' import { demo, exitDemo } from '@/demo'
@@ -51,7 +52,18 @@ onMounted(() => {
window.addEventListener('premium:required', onPremiumRequired) window.addEventListener('premium:required', onPremiumRequired)
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영) 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(() => { onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized) window.removeEventListener('auth:unauthorized', onUnauthorized)
+124
View File
@@ -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])
},
}
+7
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { ID_LOGIN_ENABLED } from '@/config/features' import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo' import { enterDemo } from '@/demo'
@@ -125,6 +126,12 @@ async function load() {
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0) budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0) spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
entries.value = list || [] 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) { } catch (e) {
error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.' error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.'
} finally { } finally {
+3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
@@ -577,6 +578,8 @@ async function submit() {
await accountApi.create(payload) await accountApi.create(payload)
} }
formOpen.value = false formOpen.value = false
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
await load() await load()
await loadPendingCount() await loadPendingCount()
} catch (e) { } catch (e) {
+106 -1
View File
@@ -1,17 +1,66 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { exportBackup, importBackup } from '@/utils/backup' import { exportBackup, importBackup } from '@/utils/backup'
import { reminders } from '@/native/reminders'
const dialog = useDialog() const dialog = useDialog()
const ui = useUiStore() const ui = useUiStore()
const auth = useAuthStore() const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용 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) const exporting = ref(false)
async function doExport() { async function doExport() {
if (exporting.value) return if (exporting.value) return
@@ -125,6 +174,30 @@ async function clearAppData() {
</div> </div>
</section> </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(데스크톱) 전용: 크기 (가로·세로 각각) --> <!-- PC(데스크톱) 전용: 크기 (가로·세로 각각) -->
<section v-if="isDesktop" class="card"> <section v-if="isDesktop" class="card">
<div class="row"> <div class="row">
@@ -312,6 +385,38 @@ async function clearAppData() {
.hidden-file { .hidden-file {
display: none; 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 { .seg-btn {
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;
border: 0; border: 0;