날짜 형식 처리 안정성 강화 및 트랜잭션 삭제 시 앱 먹통 문제 해결

This commit is contained in:
hansoo
2025-03-18 01:07:17 +09:00
parent 6f91afeebe
commit acb9ae3d70
23 changed files with 732 additions and 18 deletions

42
.windsurfrules Normal file
View File

@@ -0,0 +1,42 @@
# Zellyy Finance 개발 가이드라인
UI 개발은 Lovable에서 진행하고 있어 Lovable에서 문제가 발생하거나 충돌이 일어나지 않도록 주
## 트랜잭션 삭제 안전성
- 트랜잭션 삭제 작업은 UI 스레드를 차단하지 않도록 비동기로 처리할 것
- 상태 업데이트 전/후에 try-catch 블록으로 오류 처리할 것
- 가능한 requestAnimationFrame 또는 queueMicrotask를 사용하여 UI 업데이트 최적화할 것
- 컴포넌트 언마운트 상태를 추적하여 메모리 누수 방지할 것
- 이벤트 핸들러는 성능 병목 지점이 될 수 있으므로 디바운스/스로틀링 적용할 것
## Android 네이티브 통합
- BuildInfo와 같은 네이티브 플러그인은 반드시 MainActivity에 등록할 것
- 안드로이드 빌드 정보는 Capacitor 플러그인을 통해 JS로 전달할 것
- 플러그인 호출 시 항상 오류 처리 로직 포함할 것
- 네이티브 기능 실패 시 대체 방법(fallback) 제공할 것
## 상태 관리 최적화
- 컴포넌트 간 상태 공유는 Context API나 상태 관리 라이브러리 사용할 것
- 큰 상태 객체는 여러 작은 조각으로 분리하여 불필요한 리렌더링 방지할 것
- 불변성을 유지하여 React의 상태 업데이트 최적화 활용할 것
- useCallback, useMemo를 적극 활용하여 함수와 값 메모이제이션할 것
- 기본 데이터 로딩은 상위 컴포넌트에서 처리하고 하위 컴포넌트로 전달할 것
## 디버깅 및 로깅
- 중요 작업(특히 트랜잭션 삭제와 같은 위험 작업)은 상세한 로그 남길 것
- 개발 모드에서는 상태 변화를 추적할 수 있는 로그 포함할 것
- 사용자에게 영향을 주는 오류는 UI 피드백(토스트 등)으로 표시할 것
- 백그라운드 작업 실패는 적절히 로깅하고 필요시 재시도 메커니즘 구현할 것
## 버전 관리
- 모든 빌드는 자동으로 빌드 번호가 증가되도록 설정할 것
- 릴리즈 빌드는 versionCode와 buildNumber 모두 증가할 것
- 디버그 빌드는 buildNumber만 증가할 것
- 버전 정보는 항상 설정 페이지에 표시하여 사용자와 개발자가 확인 가능하게 할 것
## 코드 스타일
- 모든 컴포넌트는 함수형 컴포넌트로 작성할 것
- Hook 명명 규칙은 'use'로 시작하는 camelCase 사용할 것
- 비즈니스 로직은 훅으로 분리하여 재사용성 높일 것
- 주석은 한국어로 작성하여 가독성 높일 것
- prop 타입은 모두 TypeScript 인터페이스로 정의할 것

BIN
Untitled.afdesign Normal file

Binary file not shown.

View File

@@ -0,0 +1,64 @@
package com.lovable.zellyfinance;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.PluginCall;
/**
* 애플리케이션 빌드 정보를 제공하는 Capacitor 플러그인
*/
@CapacitorPlugin(name = "BuildInfo")
public class BuildInfoPlugin extends Plugin {
private static final String TAG = "BuildInfoPlugin";
/**
* 빌드 정보를 가져오는 메서드
* @param call 플러그인 호출 정보
*/
@PluginMethod
public void getBuildInfo(PluginCall call) {
try {
Log.d(TAG, "빌드 정보 요청 수신됨");
JSObject ret = new JSObject();
// 빌드 정보 수집
String versionName = BuildConfig.VERSION_NAME;
int versionCode = BuildConfig.VERSION_CODE;
int buildNumber = BuildConfig.BUILD_NUMBER;
String packageName = getContext().getPackageName();
// 디버깅을 위한 로그 출력
Log.d(TAG, "버전명: " + versionName);
Log.d(TAG, "버전 코드: " + versionCode);
Log.d(TAG, "빌드 번호: " + buildNumber);
Log.d(TAG, "패키지명: " + packageName);
// 결과 객체에 값 설정
ret.put("versionName", versionName);
ret.put("versionCode", versionCode);
ret.put("buildNumber", buildNumber);
ret.put("packageName", packageName);
ret.put("androidVersion", Build.VERSION.RELEASE);
ret.put("androidSDK", Build.VERSION.SDK_INT);
// 현재 날짜를 디버깅 정보로 추가
ret.put("buildDate", new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date()));
Log.d(TAG, "빌드 정보 요청 성공 처리");
call.resolve(ret);
} catch (Exception e) {
Log.e(TAG, "빌드 정보 가져오기 실패", e);
JSObject errorResult = new JSObject();
errorResult.put("versionName", "1.0.0");
errorResult.put("versionCode", 1);
errorResult.put("buildNumber", 1);
errorResult.put("error", e.getMessage());
call.resolve(errorResult); // 에러가 발생해도 앱이 중단되지 않도록 resolve 호출
}
}
}

View File

@@ -0,0 +1,55 @@
package com.lovable.zellyfinance;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Base64;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.PluginCall;
import java.io.ByteArrayOutputStream;
@CapacitorPlugin(name = "ImagePlugin")
public class ImagePlugin extends Plugin {
@PluginMethod
public void getResourceImage(PluginCall call) {
String resourceName = call.getString("resourceName");
if (resourceName == null) {
call.reject("Resource name is required");
return;
}
try {
// 리소스 ID 찾기
int resourceId = getContext().getResources().getIdentifier(
resourceName, "drawable", getContext().getPackageName());
if (resourceId == 0) {
call.reject("Resource not found: " + resourceName);
return;
}
// 비트맵으로 변환
Bitmap bitmap = BitmapFactory.decodeResource(getContext().getResources(), resourceId);
// Base64로 인코딩
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream);
byte[] byteArray = byteArrayOutputStream.toByteArray();
String base64Image = Base64.encodeToString(byteArray, Base64.DEFAULT);
// 응답 생성
JSObject ret = new JSObject();
ret.put("base64Image", "data:image/png;base64," + base64Image);
call.resolve(ret);
} catch (Exception e) {
call.reject("Error loading image", e);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

5
android/app_version.json Normal file
View File

@@ -0,0 +1,5 @@
{
"versionCode": 1,
"versionName": "1.0.0",
"buildNumber": 1
}

View File

@@ -0,0 +1,4 @@
#Tue Mar 18 00:16:17 KST 2025
buildNumber=10
versionCode=1
versionName=1.0.0

BIN
public/zellyy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
src/assets/zellyy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react';
import { Capacitor } from '@capacitor/core';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
interface AvatarImageViewProps {
className?: string;
fallback?: string;
}
const AvatarImageView: React.FC<AvatarImageViewProps> = ({
className = "h-12 w-12",
fallback = "ZY"
}) => {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [imageSrc, setImageSrc] = useState<string>('/zellyy.png');
useEffect(() => {
const loadImage = async () => {
try {
// 플랫폼 체크
if (Capacitor.isNativePlatform()) {
const platform = Capacitor.getPlatform();
if (platform === 'android') {
// Android에서는 res/mipmap 리소스 사용
setImageSrc('file:///android_asset/public/zellyy.png');
// 다른 가능한 경로들
const possiblePaths = [
'file:///android_asset/public/zellyy.png',
'file:///android_res/mipmap/zellyy.png',
'@mipmap/zellyy',
'mipmap/zellyy',
'res/mipmap/zellyy.png',
'/zellyy.png',
'./zellyy.png',
'android.resource://com.lovable.zellyfinance/mipmap/zellyy',
];
// 하드코딩된 Base64 이미지
const fallbackBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg==';
// 마지막 수단으로 Base64 사용
setImageSrc(fallbackBase64);
} else if (platform === 'ios') {
// iOS 경로 처리
setImageSrc('/zellyy.png');
}
} else {
// 웹에서는 일반 경로 사용
setImageSrc('/zellyy.png');
}
setLoaded(true);
} catch (err) {
console.error('이미지 로드 오류:', err);
setError(true);
}
};
loadImage();
}, []);
return (
<Avatar className={className}>
{!loaded ? (
<div className="h-full w-full flex items-center justify-center">
<Skeleton className="h-full w-full rounded-full" />
</div>
) : (
<>
<img
src={imageSrc}
alt="Zellyy"
className="h-full w-full object-cover"
onError={() => setError(true)}
/>
{error && <AvatarFallback delayMs={100}>{fallback}</AvatarFallback>}
</>
)}
</Avatar>
);
};
export default AvatarImageView;

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react';
import { Capacitor } from '@capacitor/core';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
// 네이티브 이미지를 보여주는 컴포넌트
interface NativeImageProps {
resourceName: string; // 안드로이드 리소스 이름 (확장자 없이)
className?: string;
alt?: string;
fallback?: string;
}
const NativeImage: React.FC<NativeImageProps> = ({
resourceName,
className,
alt = "이미지",
fallback = "ZY"
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [imageSrc, setImageSrc] = useState<string>("");
useEffect(() => {
const loadImage = async () => {
try {
if (Capacitor.isNativePlatform()) {
// 안드로이드에서는 리소스 ID를 사용
if (Capacitor.getPlatform() === 'android') {
// 웹뷰가 resource:// 프로토콜을 지원하는 경우를 위한 코드
setImageSrc(`file:///android_res/drawable/${resourceName}`);
} else {
// iOS - 다른 방식 적용 (추후 구현)
setImageSrc('/zellyy.png');
}
} else {
// 웹에서는 일반 경로 사용
setImageSrc(`/${resourceName}.png`);
}
setLoading(false);
} catch (err) {
console.error('이미지 로드 오류:', err);
setError(true);
setLoading(false);
}
};
loadImage();
}, [resourceName]);
return (
<Avatar className={className}>
{loading ? (
<div className="h-full w-full flex items-center justify-center">
<Skeleton className="h-full w-full rounded-full" />
</div>
) : (
<>
{!error && (
<img
src={imageSrc}
alt={alt}
className="h-full w-full object-cover"
onError={() => setError(true)}
/>
)}
{error && (
<AvatarFallback delayMs={100}>{fallback}</AvatarFallback>
)}
</>
)}
</Avatar>
);
};
export default NativeImage;

View File

@@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { getResourceImage } from '@/plugins/imagePlugin';
interface ResourceImageProps {
resourceName: string;
className?: string;
alt?: string;
fallback?: string;
}
const ResourceImage: React.FC<ResourceImageProps> = ({
resourceName,
className = "h-12 w-12",
alt = "이미지",
fallback = "ZY"
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [imageSrc, setImageSrc] = useState<string>("");
useEffect(() => {
const loadResourceImage = async () => {
try {
const imgSrc = await getResourceImage(resourceName);
setImageSrc(imgSrc);
setLoading(false);
} catch (err) {
console.error('이미지 로드 실패:', err);
setError(true);
setLoading(false);
}
};
loadResourceImage();
}, [resourceName]);
return (
<Avatar className={className}>
{loading ? (
<div className="h-full w-full flex items-center justify-center">
<Skeleton className="h-full w-full rounded-full" />
</div>
) : error ? (
<AvatarFallback delayMs={0}>{fallback}</AvatarFallback>
) : (
<img
src={imageSrc}
alt={alt}
className="h-full w-full object-cover"
onError={() => setError(true)}
/>
)}
</Avatar>
);
};
export default ResourceImage;

View File

View File

@@ -0,0 +1,50 @@
import React, { useState, useEffect } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Capacitor } from '@capacitor/core';
/**
* 젤리 아바타 컴포넌트
* 웹과 앱 환경 모두에서 올바르게 표시되는 아바타 컴포넌트
*/
const ZellyAvatar: React.FC<{ className?: string }> = ({ className = "h-12 w-12 mr-3" }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [imageSrc, setImageSrc] = useState<string>('/zellyy.png');
useEffect(() => {
// 앱 환경에서는 Base64 인코딩된 이미지를 사용
if (Capacitor.isNativePlatform()) {
setImageSrc('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg==');
} else {
// 웹 환경에서는 일반 경로 사용
setImageSrc('/zellyy.png');
}
setImageLoaded(true);
}, []);
return (
<Avatar className={className}>
{!imageLoaded && !imageError ? (
<div className="h-full w-full flex items-center justify-center">
<Skeleton className="h-full w-full rounded-full" />
</div>
) : (
<>
<AvatarImage
src={imageSrc}
alt="Zellyy"
className={imageLoaded ? 'opacity-100' : 'opacity-0'}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
{(imageError || !imageLoaded) && (
<AvatarFallback delayMs={100}>ZY</AvatarFallback>
)}
</>
)}
</Avatar>
);
};
export default ZellyAvatar;

View File

@@ -94,9 +94,16 @@ export const useTransactionsOperations = (
// 사용자 인터페이스 응답성 감소 전에 이벤트 발생 처리
try {
window.dispatchEvent(new Event('transactionUpdated'));
// 상태 업데이트 바로 후 크로스 스레드 통신 방지
setTimeout(() => {
try {
window.dispatchEvent(new Event('transactionUpdated'));
} catch (innerError) {
console.warn('이벤트 발생 중 비치명적 오류:', innerError);
}
}, 0);
} catch (eventError) {
console.warn('이벤트 발생 중 비치명적 오류:', eventError);
console.warn('이벤트 디스패치 설정 오류:', eventError);
}
// UI 스레드 블록하지 않는 너비로 requestAnimationFrame 사용
@@ -146,9 +153,39 @@ export const useTransactionsOperations = (
// 삭제되었던 트랜잭션 다시 추가
const newState = [...prevState, transactionToDelete];
// 날짜 기준 정렬
// 날짜 기준 정렬 - 안전한 경로
return newState.sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
try {
// 날짜 형식이 다양할 수 있으므로 안전하게 처리
let dateA = new Date();
let dateB = new Date();
// 타입 안전성 확보
if (a.date && typeof a.date === 'string') {
// 이미 포맷팅된 날짜 문자열 감지
if (!a.date.includes('오늘,') && !a.date.includes('년')) {
const testDate = new Date(a.date);
if (!isNaN(testDate.getTime())) {
dateA = testDate;
}
}
}
if (b.date && typeof b.date === 'string') {
// 이미 포맷팅된 날짜 문자열 감지
if (!b.date.includes('오늘,') && !b.date.includes('년')) {
const testDate = new Date(b.date);
if (!isNaN(testDate.getTime())) {
dateB = testDate;
}
}
}
return dateB.getTime() - dateA.getTime();
} catch (error) {
console.error('날짜 정렬 오류:', error);
return 0; // 오류 발생 시 순서 유지
}
});
});
}

109
src/next-steps-plan.md Normal file
View File

@@ -0,0 +1,109 @@
# 젤리의 적자탈출 앱 - 다음 작업 계획
## 1. 앱 배포 준비 완료
### 1.1 앱 배포 가이드 문서 완성
- 현재 작성 중인 app-deployment-guide.md 문서 완성
- 실제 배포 경험을 바탕으로 가이드 업데이트
- 배포 중 발생할 수 있는 문제 해결 방법 추가
### 1.2 Android 앱 빌드 및 테스트
- 안드로이드 빌드 환경 최종 점검
- 다양한 안드로이드 기기에서 호환성 테스트
- 성능 및 안정성 테스트
### 1.3 iOS 환경 설정 및 빌드 (필요시)
- Mac 환경에서 iOS 빌드 설정
- iOS 앱 아이콘 및 스플래시 스크린 준비
- TestFlight를 통한 베타 테스트
## 2. 앱 최적화
### 2.1 성능 최적화
- 앱 로딩 시간 개선
- 메모리 사용량 최적화
- 배터리 소모 최적화
- 알림 시스템 안정화 (현재 발생 중인 버그 수정)
### 2.2 사용자 경험 개선
- UI/UX 일관성 확보
- 애니메이션 및 전환 효과 최적화
- 접근성 개선
### 2.3 오프라인 기능 강화
- 오프라인 상태에서의 데이터 처리 개선
- 동기화 메커니즘 강화
## 3. 추가 기능 개발
### 3.1 알림 시스템 구현
- 푸시 알림 기능 구현
- 알림 설정 페이지 개선
- 알림 스케줄링 기능
### 3.2 데이터 백업 및 복원 기능
- 사용자 데이터 백업 기능
- 데이터 복원 메커니즘
- 클라우드 백업 옵션
### 3.3 다국어 지원 확장
- 다국어 지원 시스템 구현
- 언어 설정 페이지 추가
- 번역 리소스 준비
## 4. 테스트 및 품질 보증
### 4.1 다양한 기기에서의 호환성 테스트
- 다양한 안드로이드 버전 테스트
- 다양한 화면 크기 및 해상도 테스트
- 저사양 기기에서의 성능 테스트
### 4.2 사용자 피드백 수집 및 반영
- 인앱 피드백 시스템 구현
- 사용자 테스트 세션 진행
- 피드백 기반 개선사항 우선순위 설정
### 4.3 버그 수정 및 안정성 개선
- 알려진 버그 수정
- 크래시 리포트 분석 및 대응
- 자동화된 테스트 추가
## 5. 스토어 등록 준비
### 5.1 Google Play 스토어 등록 자료 준비
- 스토어 리스팅 정보 작성
- 스크린샷 및 프로모션 이미지 준비
- 개인정보 처리방침 문서 작성
### 5.2 App Store 등록 자료 준비 (필요시)
- App Store Connect 설정
- 앱 심사 준비
- 마케팅 자료 준비
### 5.3 출시 후 모니터링 계획
- 앱 성능 모니터링 시스템 구축
- 사용자 피드백 수집 채널 설정
- 정기 업데이트 일정 수립
## 즉시 진행 가능한 작업
1. **알림 시스템 버그 수정**
```bash
# 수정된 코드 테스트
npm run build
npx cap sync
npx cap open android
```
2. **앱 배포 가이드 문서 완성**
- 현재 작성 중인 app-deployment-guide.md 문서 완성
- 실제 배포 경험을 바탕으로 가이드 업데이트
3. **앱 아이콘 및 스플래시 스크린 최적화**
- 다양한 해상도의 아이콘 준비
- 스플래시 스크린 디자인 개선
4. **앱 성능 테스트**
- 로딩 시간 최적화
- 메모리 사용량 분석
- 배터리 소모 최적화

View File

@@ -0,0 +1,43 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
/**
* 네이티브 이미지 플러그인 인터페이스
*/
export interface ImagePluginInterface {
getResourceImage(options: { resourceName: string }): Promise<{ base64Image: string }>;
}
// 네이티브 플러그인을 사용할 수 없을 때 대체할 웹 구현
const ImagePluginWeb: ImagePluginInterface = {
async getResourceImage(options: { resourceName: string }): Promise<{ base64Image: string }> {
// 웹에서는 일반 경로 사용
return { base64Image: `/${options.resourceName}.png` };
},
};
// 네이티브 플러그인 등록
const ImagePlugin = registerPlugin<ImagePluginInterface>('ImagePlugin', {
web: ImagePluginWeb,
});
/**
* 이미지 리소스를 가져오는 함수
*
* @param resourceName 리소스 이름 (확장자 제외)
* @returns 플랫폼에 맞는 이미지 경로 또는 Base64 문자열
*/
export async function getResourceImage(resourceName: string): Promise<string> {
try {
if (Capacitor.isNativePlatform()) {
// 네이티브 환경에서는 플러그인 사용
const result = await ImagePlugin.getResourceImage({ resourceName });
return result.base64Image;
} else {
// 웹 환경에서는 일반 URL 반환
return `/${resourceName}.png`;
}
} catch (error) {
console.error('이미지 리소스 로드 오류:', error);
return `/${resourceName}.png`; // 오류 시 기본 경로 사용
}
}

36
src/utils/imageUtils.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Capacitor } from '@capacitor/core';
/**
* 이미지 URL을 환경에 맞게 변환하는 유틸리티 함수
*
* 웹과 앱 환경에서 모두 정상적으로 이미지가 표시되도록 경로를 처리합니다.
*
* @param imagePath 이미지 경로 (예: '/zellyy.png')
* @returns 환경에 맞게 변환된 이미지 URL
*/
export function getImageUrl(imagePath: string): string {
// 이미지 경로가 이미 http로 시작하면 그대로 반환
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
// 경로의 첨 글자가 '/'이 아니면 추가
const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
// Capacitor 앱에서 실행 중인 경우
if (Capacitor.isNativePlatform()) {
// 안드로이드 플랫폼 확인
const platform = Capacitor.getPlatform();
if (platform === 'android') {
// 안드로이드 환경에서는 assets 폴더 경로 사용
return `file:///android_asset/public${normalizedPath}`;
} else if (platform === 'ios') {
// iOS 환경에서는 다른 경로 사용
return `${normalizedPath}`;
}
}
// 웹 환경에서는 일반 경로 사용
return normalizedPath;
}

View File

@@ -47,11 +47,32 @@ export const normalizeDate = (dateStr: string): string => {
* ISO 형식의 날짜 문자열을 사용자 친화적인 형식으로 변환
*/
export const formatDateForDisplay = (isoDateStr: string): string => {
// 입력값이 유효한지 보호 처리
if (!isoDateStr || typeof isoDateStr !== 'string') {
console.warn('유효하지 않은 날짜 입력:', isoDateStr);
return '날짜 없음';
}
try {
// 이미 포맷된 날짜 문자열(예: "오늘, 14:30")이면 그대로 반환
if (isoDateStr.includes('오늘,') ||
isoDateStr.includes('년') && isoDateStr.includes('월') && isoDateStr.includes('일')) {
return isoDateStr;
}
// 유효한 ISO 날짜인지 확인
const date = parseISO(isoDateStr);
if (!isValid(date)) {
return isoDateStr; // 유효하지 않으면 원본 반환
let date;
if (isoDateStr.match(/^\d{4}-\d{2}-\d{2}T/)) {
// ISO 형식인 경우
date = parseISO(isoDateStr);
} else {
// ISO 형식이 아닌 경우 일반 Date 생성자 시도
date = new Date(isoDateStr);
}
if (!isValid(date) || isNaN(date.getTime())) {
console.warn('유효하지 않은 날짜 형식:', isoDateStr);
return '유효하지 않은 날짜';
}
// 현재 날짜와 비교
@@ -68,7 +89,8 @@ export const formatDateForDisplay = (isoDateStr: string): string => {
// 그 외의 경우 YYYY년 MM월 DD일 형식으로 반환
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} catch (error) {
console.error('날짜 포맷 변환 오류:', error);
return isoDateStr; // 오류 발생 시 원본 반환
console.error('날짜 포맷 변환 오류:', error, isoDateStr);
// 오류 발생 시 기본값 반환
return '날짜 오류';
}
};

View File

@@ -31,15 +31,39 @@ export const downloadTransactions = async (userId: string): Promise<void> => {
console.log(`서버에서 ${data.length}개의 트랜잭션 다운로드`);
// 서버 데이터를 로컬 형식으로 변환
const serverTransactions = data.map(t => ({
id: t.transaction_id || t.id,
title: t.title,
amount: t.amount,
date: t.date ? formatDateForDisplay(t.date) : '날짜 없음',
category: t.category,
type: t.type,
notes: t.notes
}));
const serverTransactions = data.map(t => {
// 날짜 형식 변환 시 오류 방지 처리
let formattedDate = '날짜 없음';
try {
if (t.date) {
// ISO 형식이 아닌 경우 기본 변환 수행
if (!t.date.match(/^\d{4}-\d{2}-\d{2}T/)) {
console.log(`비표준 날짜 형식 감지: ${t.date}, ID: ${t.transaction_id || t.id}`);
// 유효한 Date 객체로 변환 가능한지 확인
const testDate = new Date(t.date);
if (isNaN(testDate.getTime())) {
console.warn(`잘못된 날짜 형식 감지, 현재 날짜 사용: ${t.date}`);
t.date = new Date().toISOString(); // 잘못된 날짜는 현재 날짜로 대체
}
}
formattedDate = formatDateForDisplay(t.date);
}
} catch (err) {
console.error(`날짜 변환 오류 (ID: ${t.transaction_id || t.id}):`, err);
// 오류 발생 시 기본값 사용
formattedDate = new Date().toLocaleString('ko-KR');
}
return {
id: t.transaction_id || t.id,
title: t.title,
amount: t.amount,
date: formattedDate,
category: t.category,
type: t.type,
notes: t.notes
};
});
// 기존 로컬 데이터 불러오기
const localDataStr = localStorage.getItem('transactions');