날짜 형식 처리 안정성 강화 및 트랜잭션 삭제 시 앱 먹통 문제 해결
This commit is contained in:
42
.windsurfrules
Normal file
42
.windsurfrules
Normal 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
BIN
Untitled.afdesign
Normal file
Binary file not shown.
@@ -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 호출
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable/zellyy.png
Normal file
BIN
android/app/src/main/res/drawable/zellyy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
android/app/src/main/res/mipmap/zellyy.png
Normal file
BIN
android/app/src/main/res/mipmap/zellyy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
android/app/src/main/res/raw/zellyy.png
Normal file
BIN
android/app/src/main/res/raw/zellyy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
5
android/app_version.json
Normal file
5
android/app_version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0.0",
|
||||
"buildNumber": 1
|
||||
}
|
||||
4
android/version.properties
Normal file
4
android/version.properties
Normal 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
BIN
public/zellyy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
src/assets/zellyy.png
Normal file
BIN
src/assets/zellyy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
1
src/assets/zellyy_base64.txt
Normal file
1
src/assets/zellyy_base64.txt
Normal file
File diff suppressed because one or more lines are too long
87
src/components/AvatarImageView.tsx
Normal file
87
src/components/AvatarImageView.tsx
Normal 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;
|
||||
76
src/components/NativeImage.tsx
Normal file
76
src/components/NativeImage.tsx
Normal 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;
|
||||
59
src/components/ResourceImage.tsx
Normal file
59
src/components/ResourceImage.tsx
Normal 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;
|
||||
0
src/components/SimpleAvatar.tsx
Normal file
0
src/components/SimpleAvatar.tsx
Normal file
50
src/components/ZellyAvatar.tsx
Normal file
50
src/components/ZellyAvatar.tsx
Normal 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;
|
||||
@@ -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
109
src/next-steps-plan.md
Normal 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. **앱 성능 테스트**
|
||||
- 로딩 시간 최적화
|
||||
- 메모리 사용량 분석
|
||||
- 배터리 소모 최적화
|
||||
43
src/plugins/imagePlugin.ts
Normal file
43
src/plugins/imagePlugin.ts
Normal 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
36
src/utils/imageUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 '날짜 오류';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user