Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
implementation project(':capacitor-text-zoom')
implementation project(':capgo-capacitor-social-login')
implementation project(':team-lepisode-capacitor-kakao-login')
implementation project(':capacitor-native-settings')

}

Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@

<uses-permission android:name="android.permission.INTERNET" />

<!-- 회고 음성 입력(STT) — WebView getUserMedia가 런타임 권한 다이얼로그를 띄우려면 선언 필요 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<!-- 광고 미사용: 일부 SDK(Firebase 등)가 자동 병합하는 광고 ID 권한 제거 -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
</manifest>
11 changes: 10 additions & 1 deletion android/app/src/main/java/kr/ai/didit/MainActivity.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
package kr.ai.didit;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;

public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
// 커스텀 하단 내비게이션 바 플러그인 등록 (super.onCreate 이전에 호출해야 함)
registerPlugin(NavigationBarPlugin.class);
super.onCreate(savedInstanceState);
}
}
46 changes: 46 additions & 0 deletions android/app/src/main/java/kr/ai/didit/NavigationBarPlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kr.ai.didit;

import android.graphics.Color;
import android.os.Build;
import android.view.View;

import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

// 하단 내비게이션 바 색을 런타임에 현재 화면 배경색으로 칠하기 위한 커스텀 플러그인.
// (@capacitor/status-bar는 상단 상태바만 다뤄서, 하단 내비바는 직접 구현한다)
@CapacitorPlugin(name = "NavigationBar")
public class NavigationBarPlugin extends Plugin {

// setColor({ color: '#RRGGBB', darkButtons: boolean })
@PluginMethod
public void setColor(PluginCall call) {
final String color = call.getString("color");
final boolean darkButtons = Boolean.TRUE.equals(call.getBoolean("darkButtons", true));
if (color == null) {
call.reject("color is required");
return;
}
getActivity().runOnUiThread(() -> {
try {
getActivity().getWindow().setNavigationBarColor(Color.parseColor(color));
// 밝은 배경이면 어두운 버튼(LIGHT_NAVIGATION_BAR) — API 26+ 에서만 지원
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
View decor = getActivity().getWindow().getDecorView();
int flags = decor.getSystemUiVisibility();
if (darkButtons) {
flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
} else {
flags &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
}
decor.setSystemUiVisibility(flags);
}
call.resolve();
} catch (Exception e) {
call.reject("failed to set navigation bar color: " + e.getMessage());
}
});
}
}
11 changes: 11 additions & 0 deletions android/app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,21 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<!-- 시스템 바 초기색: 첫 화면(스플래시/로그인)이 포인트컬러(초록)라 초록으로 시작.
WebView 로드 후 status-bar.client.ts가 화면별 배경색으로 덮어쓴다. -->
<item name="android:statusBarColor">#3DDB99</item>
<item name="android:navigationBarColor">#3DDB99</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>


<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<!-- 네이티브 스플래시: 상·하단 바 포인트컬러(초록) + 어두운 아이콘 -->
<item name="android:statusBarColor">#3DDB99</item>
<item name="android:navigationBarColor">#3DDB99</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
3 changes: 3 additions & 0 deletions android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/

include ':team-lepisode-capacitor-kakao-login'
project(':team-lepisode-capacitor-kakao-login').projectDir = new File('../node_modules/@team-lepisode/capacitor-kakao-login/android')

include ':capacitor-native-settings'
project(':capacitor-native-settings').projectDir = new File('../node_modules/capacitor-native-settings/android')
4 changes: 3 additions & 1 deletion app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
html {
font-family: 'Pretendard', system-ui, sans-serif;
/* 비정수 DPR(예: 1.75) 기기에서 antialiased(grayscale AA)는 글자를 더 물러 보이게 해 제거 → WebView 기본 AA 사용 */
text-rendering: optimizeLegibility;
/* optimizeLegibility는 커닝/리가처로 글자를 소수 픽셀 위치에 얹어 작은 글자(pill·태그칩 11~12px)를 무르게 만든다.
optimizeSpeed로 그 경로를 꺼 작은 텍스트를 또렷하게 (DPR 1.75 서브픽셀 뭉개짐 완화) */
text-rendering: optimizeSpeed;
}

body {
Expand Down
12 changes: 8 additions & 4 deletions app/components/RetrospectVoiceRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
</template>

<script setup lang="ts">
const emit = defineEmits<{ done: [blob: Blob]; cancel: [] }>()
const emit = defineEmits<{ done: [blob: Blob]; cancel: []; blocked: [] }>()
const { show } = useToast()
const recorder = useVoiceRecorder()

Expand Down Expand Up @@ -121,9 +121,13 @@ function onCancel() {
onMounted(async () => {
const ok = await recorder.start()
if (!ok) {
// 마이크 권한 거부/장치 없음 — 웹·네이티브 공통으로 안내 후 닫기
show('마이크를 사용할 수 없어요. 권한을 확인해 주세요.')
emit('cancel')
// 권한이 영구 거부면 부모가 '설정으로 이동' 안내를 띄우도록 위임, 그 외(1회 거부·장치 없음)엔 일반 토스트
if (await recorder.isPermissionBlocked()) {
emit('blocked')
} else {
show('마이크를 사용할 수 없어요. 권한을 확인해 주세요.')
emit('cancel')
}
return
}
startWave()
Expand Down
1 change: 1 addition & 0 deletions app/components/layout/BottomTabBar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<nav
id="bottom-tab-bar"
class="shrink-0 bg-white w-full"
style="border-top: 1px solid #E8E8E8;"
>
Expand Down
14 changes: 14 additions & 0 deletions app/composables/useVoiceRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export function useVoiceRecorder() {
}
}

// 마이크 권한이 영구 거부('denied') 상태인지 — true면 OS 다이얼로그가 더는 안 떠서 설정 유도가 필요.
// Permissions API는 안드 WebView(Chromium)에서 지원. iOS WKWebView 등 미지원 환경은 false로 폴백.
async function isPermissionBlocked(): Promise<boolean> {
try {
const perms = navigator.permissions
if (!perms?.query) return false
const status = await perms.query({ name: 'microphone' as PermissionName })
return status.state === 'denied'
} catch {
return false
}
}

// 마이크 권한 요청 (성공 시 true). 네이티브에선 권한 다이얼로그로 대체.
async function requestPermission(): Promise<boolean> {
try {
Expand Down Expand Up @@ -117,6 +130,7 @@ export function useVoiceRecorder() {
isRecording: readonly(isRecording),
isPaused: readonly(isPaused),
elapsed: readonly(elapsed),
isPermissionBlocked,
requestPermission,
start,
stop,
Expand Down
15 changes: 13 additions & 2 deletions app/pages/my/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@
<img src="/icons/chevron-right.svg" alt="" class="w-6 h-6" />
</button>
<!-- 획득 배지 미리보기: 최대 2개. 열 고정 대신 카드 최소폭(140px) 기준 auto-fill → 넓은 화면에서 카드가 늘어나지 않음 -->
<div v-if="acquiredBadges.length > 0" class="mt-2 mb-2 px-2 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-[10px]">
<!-- 로딩 중엔 스켈레톤으로 자리를 예약 → 배지(로컬 이미지)가 뒤늦게 pop-in 하며 깜빡이는 현상 방지 -->
<div v-if="!badgesLoaded" class="mt-2 mb-2 px-2 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-[10px]">
<div
v-for="n in 2"
:key="`skeleton-${n}`"
class="rounded-[14px] bg-grey-3 flex flex-col items-center pt-3 gap-1 pb-5 animate-pulse"
>
<div class="h-24 w-24 rounded-xl bg-grey-4" />
<div class="h-3 w-12 rounded bg-grey-4" />
</div>
</div>
<div v-else-if="acquiredBadges.length > 0" class="mt-2 mb-2 px-2 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-[10px]">
<div
v-for="b in acquiredBadges.slice(0, 2)"
:key="b.code"
Expand Down Expand Up @@ -148,7 +159,7 @@ const jobLabel = computed(() => (profile.value?.job ? jobLabels[profile.value.jo
const showLevelBadge = computed(() => (profile.value?.currentLevel ?? 0) >= 1)
const displayLevel = computed(() => profile.value?.currentLevel ?? 1)

const { badges, load: loadBadges } = useBadges()
const { badges, loaded: badgesLoaded, load: loadBadges } = useBadges()
const acquiredBadges = computed(() =>
badges.value
.filter(b => b.acquired)
Expand Down
64 changes: 62 additions & 2 deletions app/pages/retrospect/start.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,17 @@
aria-label="전송"
@click="onSend"
>
<Icon name="famicons:arrow-up-sharp" class="w-4 h-4 text-grey-1" />
<!-- 인라인 SVG(비동기 아이콘 resolve 제거) — 검은 원만 뜨는 첫 마운트 깜빡임 방지. 모양은 famicons:arrow-up-sharp와 동일 -->
<svg viewBox="0 0 512 512" class="w-4 h-4 text-grey-1" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="square"
stroke-miterlimit="10"
stroke-width="48"
d="M112 244l144-144 144 144M256 120v292"
/>
</svg>
</button>
</div>
</div>
Expand Down Expand Up @@ -213,7 +223,40 @@
</Teleport>

<!-- 음성 레코더 (figma 2225-7905) -->
<RetrospectVoiceRecorder v-if="showRecorder" @done="onRecorderDone" @cancel="onRecorderCancel" />
<RetrospectVoiceRecorder v-if="showRecorder" @done="onRecorderDone" @cancel="onRecorderCancel" @blocked="onMicBlocked" />

<!-- 마이크 권한 영구 거부 → 설정 유도 (OS 다이얼로그가 더는 안 뜨는 상태) -->
<Teleport to="#app-container">
<Transition name="mic-fade">
<div v-if="showMicBlockedPopup" class="absolute inset-0 z-50 flex items-center justify-center px-5" style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="absolute inset-0 bg-black/40" @click="showMicBlockedPopup = false" />
<div class="relative w-full max-w-[300px] bg-grey-1 rounded-2xl px-5 py-4 flex flex-col gap-[14px]">
<div class="flex flex-col items-center gap-2 py-3 text-center">
<p class="text-[17px] font-semibold text-grey-13 leading-[1.4] tracking-[-0.02em]">
마이크 권한이<br />꺼져 있어요.
</p>
<p class="text-[14px] font-normal text-grey-8 leading-[1.6] tracking-[-0.02em]">
음성으로 회고를 기록하려면<br />설정에서 마이크 권한을 켜주세요.
</p>
</div>
<div class="flex gap-2 pb-1">
<button
class="flex-1 h-[50px] rounded-xl border border-grey-5 bg-grey-1 text-[15px] font-semibold text-grey-13 active:bg-grey-3 transition-colors"
@click="showMicBlockedPopup = false"
>
닫기
</button>
<button
class="flex-1 h-[50px] rounded-xl bg-primary text-[15px] font-semibold text-grey-13 active:opacity-80 transition-opacity"
@click="onOpenAppSettings"
>
설정으로 이동
</button>
</div>
</div>
</div>
</Transition>
</Teleport>

<!-- 음성 → 텍스트 변환 중 (figma 2229-8319) -->
<Teleport to="#app-container">
Expand All @@ -236,6 +279,7 @@
import { Capacitor } from '@capacitor/core'
import type { PluginListenerHandle } from '@capacitor/core'
import { Keyboard } from '@capacitor/keyboard'
import { NativeSettings, AndroidSettings, IOSSettings } from 'capacitor-native-settings'
import type { QuestionType, CompleteRetrospectiveResponse } from '~/types/api'
import { getApiErrorCode, getApiErrorMessage, isAuthError } from '~/utils/api-error'

Expand Down Expand Up @@ -290,6 +334,7 @@ const showRestartPopup = ref(false)

// 음성 입력(STT) — 웹·네이티브 모두 지원
const showMicPopup = ref(false) // 마이크 권한 안내 팝업 (CHAT_001)
const showMicBlockedPopup = ref(false) // 마이크 권한 영구 거부 → 설정 유도 팝업
const micExplained = ref(false) // 안내 팝업을 이미 거쳤는지
const showRecorder = ref(false) // 녹음 레코더 노출
const isTranscribing = ref(false) // 음성 → 텍스트 변환 중
Expand Down Expand Up @@ -609,6 +654,21 @@ async function onRecorderDone(blob: Blob) {
function onRecorderCancel() {
showRecorder.value = false
}
// 마이크 권한 영구 거부 → 레코더 닫고 설정 유도 팝업 노출
function onMicBlocked() {
showRecorder.value = false
showMicBlockedPopup.value = true
}
// OS 앱 설정 화면으로 이동 (사용자가 직접 마이크 권한 토글). 웹에선 무동작.
async function onOpenAppSettings() {
showMicBlockedPopup.value = false
if (!isNative.value) return
if (Capacitor.getPlatform() === 'ios') {
await NativeSettings.openIOS({ option: IOSSettings.App })
} else {
await NativeSettings.openAndroid({ option: AndroidSettings.ApplicationDetails })
}
}

function onBack() {
if (messages.value.length === 0) {
Expand Down
Loading
Loading