diff --git a/.claude/commands/init-workflow.md b/.claude/commands/init-workflow.md index 35d87a3c..ebc9f8fc 100644 --- a/.claude/commands/init-workflow.md +++ b/.claude/commands/init-workflow.md @@ -4,7 +4,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명을 입력받아 자동으로: 1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) -2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +2. 브랜치가 없으면 리모트(origin) 확인 → 있으면 tracking 브랜치로 가져오기, 없으면 현재 브랜치에서 새로 생성 3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) 5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) diff --git a/.claude/scripts/worktree_manager.py b/.claude/scripts/worktree_manager.py index 6e474e6a..6c33414b 100644 --- a/.claude/scripts/worktree_manager.py +++ b/.claude/scripts/worktree_manager.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Git Worktree Manager v1.0.4 +Git Worktree Manager v1.1.0 Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. -브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. +브랜치가 없으면 리모트(origin) 확인 후 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. 사용법: macOS/Linux: @@ -32,21 +32,21 @@ # Windows 인코딩 문제 해결 - stdout/stderr를 UTF-8로 래핑 if platform.system() == 'Windows': - try: - # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 - if hasattr(sys.stdout, 'buffer'): - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - if hasattr(sys.stderr, 'buffer'): - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') - except Exception: - pass # 래핑 실패 시 무시 + try: + # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 + if hasattr(sys.stdout, 'buffer'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'buffer'): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 래핑 실패 시 무시 # =================================================================== # 상수 정의 # =================================================================== -VERSION = "1.0.4" +VERSION = "1.1.0" # Windows 환경 감지 IS_WINDOWS = platform.system() == 'Windows' @@ -64,89 +64,108 @@ # =================================================================== def get_branch_name() -> str: - """ - 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) - - Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 - 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. - - Returns: - str: 브랜치명 (비어있을 수 있음) - """ - if IS_WINDOWS: - # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) - branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') - if branch_name_raw: - try: - branch_name = branch_name_raw.strip() - if branch_name: - return branch_name - except Exception: - pass - - # 방법 2: 임시 파일에서 읽기 - temp_file = os.environ.get('BRANCH_NAME_FILE', '') - if temp_file and os.path.exists(temp_file): - try: - encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] - for encoding in encodings: - try: - with open(temp_file, 'r', encoding=encoding) as f: - branch_name = f.read().strip() - if branch_name: - return branch_name - except (UnicodeDecodeError, UnicodeError): - continue - except Exception: - pass - - # 방법 3: stdin에서 읽기 시도 - if not sys.stdin.isatty(): - try: - branch_name = sys.stdin.read().strip() - if branch_name: - return branch_name - except Exception: - pass - - # 기본: sys.argv에서 받기 - if len(sys.argv) >= 2: - return sys.argv[1].strip() - - return '' + """ + 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) + + Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 + 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. + + Returns: + str: 브랜치명 (비어있을 수 있음) + """ + if IS_WINDOWS: + # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) + # Windows에서 환경 변수는 시스템 기본 인코딩을 사용하므로 UTF-8로 디코딩 시도 + branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') + if branch_name_raw: + try: + # 환경 변수가 이미 올바른 인코딩인 경우 + branch_name = branch_name_raw.strip() + # 한글이 깨져있는지 확인 (깨진 경우 복구 시도) + if '\xef\xbf\xbd' in branch_name.encode('utf-8', errors='replace').decode('utf-8', errors='replace'): + # 깨진 경우, 시스템 인코딩으로 디코딩 후 UTF-8로 재인코딩 시도 + import locale + sys_encoding = locale.getpreferredencoding() + branch_name = branch_name_raw.encode(sys_encoding, errors='replace').decode('utf-8', errors='replace').strip() + else: + branch_name = branch_name.strip() + if branch_name: + return branch_name + except Exception: + # 인코딩 변환 실패 시 원본 사용 + branch_name = branch_name_raw.strip() + if branch_name: + return branch_name + + # 방법 2: 임시 파일에서 읽기 (init-workflow에서 파일 생성 후 전달) + temp_file = os.environ.get('BRANCH_NAME_FILE', '') + if temp_file and os.path.exists(temp_file): + try: + # 여러 인코딩 시도: UTF-8, UTF-8 with BOM, 시스템 기본 인코딩 + encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] + branch_name = None + for encoding in encodings: + try: + with open(temp_file, 'r', encoding=encoding) as f: + branch_name = f.read().strip() + if branch_name and not any(ord(c) > 0xFFFF for c in branch_name if ord(c) > 0x7F): + # 한글이 제대로 읽혔는지 확인 (깨진 문자가 없는지) + break + except (UnicodeDecodeError, UnicodeError): + continue + + if branch_name: + return branch_name + except Exception as e: + print_warning(f"브랜치명 파일 읽기 실패: {e}") + + # 방법 3: stdin에서 읽기 시도 (파이프 입력인 경우) + if not sys.stdin.isatty(): + try: + branch_name = sys.stdin.read().strip() + if branch_name: + return branch_name + except Exception: + pass + + # 기본: sys.argv에서 받기 (macOS/Linux 또는 Windows에서도 인자로 전달된 경우) + if len(sys.argv) >= 2: + return sys.argv[1].strip() + + return '' def print_header(): - """헤더 출력""" - print("━" * 60) - print(f"🌿 Git Worktree Manager v{VERSION}") - print("━" * 60) - print() + """헤더 출력""" + print("━" * 60) + print(f"🌿 Git Worktree Manager v{VERSION}") + print("━" * 60) + print() def print_step(emoji: str, message: str): - """단계별 메시지 출력""" - print(f"{emoji} {message}") + """단계별 메시지 출력""" + print(f"{emoji} {message}") def print_error(message: str): - """에러 메시지 출력""" - print(f"❌ 에러: {message}", file=sys.stderr) + """에러 메시지 출력""" + print(f"❌ 에러: {message}", file=sys.stderr) def print_success(message: str): - """성공 메시지 출력""" - print(f"✅ {message}") + """성공 메시지 출력""" + print(f"✅ {message}") def print_info(message: str): - """정보 메시지 출력""" - print(f"ℹ️ {message}") + """정보 메시지 출력""" + print(f"ℹ️ {message}") def print_warning(message: str): - """경고 메시지 출력""" - print(f"⚠️ {message}") + """경고 메시지 출력""" + print(f"⚠️ {message}") # =================================================================== @@ -154,239 +173,294 @@ def print_warning(message: str): # =================================================================== def run_git_command(args: list, check: bool = True) -> Tuple[bool, str, str]: - """ - Git 명령어 실행 - - Args: - args: Git 명령어 인자 리스트 (예: ['branch', '--list']) - check: 에러 발생 시 예외를 발생시킬지 여부 - - Returns: - Tuple[bool, str, str]: (성공 여부, stdout, stderr) - """ - try: - result = subprocess.run( - ['git'] + args, - capture_output=True, - text=True, - encoding='utf-8', - check=check - ) - return True, result.stdout.strip(), result.stderr.strip() - except subprocess.CalledProcessError as e: - return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" - except Exception as e: - return False, "", str(e) + """ + Git 명령어 실행 + + Args: + args: Git 명령어 인자 리스트 (예: ['branch', '--list']) + check: 에러 발생 시 예외를 발생시킬지 여부 + + Returns: + Tuple[bool, str, str]: (성공 여부, stdout, stderr) + """ + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + encoding='utf-8', + check=check + ) + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" + except Exception as e: + return False, "", str(e) def check_and_enable_longpaths() -> bool: - """ - Windows에서 Git 긴 경로 지원 확인 및 활성화 - - Returns: - bool: 긴 경로 지원이 활성화되어 있으면 True - """ - if not IS_WINDOWS: - return True - - # 현재 설정 확인 - success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) - if success and stdout.strip().lower() == 'true': - return True - - # 긴 경로 지원 활성화 - print_info("Windows 긴 경로 지원을 활성화합니다...") - success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) - if success: - print_success("긴 경로 지원이 활성화되었습니다.") - return True - else: - print_warning(f"긴 경로 지원 활성화 실패: {stderr}") - print_warning("수동으로 실행하세요: git config --global core.longpaths true") - return False + """ + Windows에서 Git 긴 경로 지원 확인 및 활성화 + + Returns: + bool: 긴 경로 지원이 활성화되어 있으면 True + """ + if not IS_WINDOWS: + return True + + # 현재 설정 확인 + success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) + if success and stdout.strip().lower() == 'true': + return True + + # 긴 경로 지원 활성화 + print_info("Windows 긴 경로 지원을 활성화합니다...") + success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) + if success: + print_success("긴 경로 지원이 활성화되었습니다.") + return True + else: + print_warning(f"긴 경로 지원 활성화 실패: {stderr}") + print_warning("수동으로 실행하세요: git config --global core.longpaths true") + return False def is_git_repository() -> bool: - """현재 디렉토리가 Git 저장소인지 확인""" - success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) - return success + """현재 디렉토리가 Git 저장소인지 확인""" + success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) + return success def get_git_root() -> Optional[Path]: - """Git 저장소 루트 경로 반환""" - success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) - if success and stdout: - return Path(stdout) - return None + """Git 저장소 루트 경로 반환""" + success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) + if success and stdout: + return Path(stdout) + return None def get_current_branch() -> Optional[str]: - """현재 체크아웃된 브랜치명 반환""" - success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) - if success and stdout: - return stdout - return None + """현재 체크아웃된 브랜치명 반환""" + success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) + if success and stdout: + return stdout + return None def branch_exists(branch_name: str) -> bool: - """ - 브랜치 존재 여부 확인 - - Args: - branch_name: 확인할 브랜치명 - - Returns: - bool: 브랜치가 존재하면 True - """ - success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) - if success and stdout: - # 출력 형식: " branch_name" 또는 "* branch_name" - branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] - return branch_name in branches - return False + """ + 로컬 브랜치 존재 여부 확인 + + Args: + branch_name: 확인할 브랜치명 + + Returns: + bool: 로컬 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) + if success and stdout: + # 출력 형식: " branch_name" 또는 "* branch_name" + branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] + return branch_name in branches + return False + + +def remote_branch_exists(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트에 브랜치가 존재하는지 확인 + + Args: + branch_name: 확인할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 리모트에 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '-r', '--list', f'{remote}/{branch_name}'], check=False) + if success and stdout: + branches = [line.strip() for line in stdout.split('\n')] + return f'{remote}/{branch_name}' in branches + return False + + +def fetch_remote(remote: str = 'origin') -> bool: + """ + 리모트에서 최신 정보를 가져옵니다 (git fetch) + + Args: + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + print_step("🔄", f"리모트({remote}) 최신 정보 가져오는 중...") + success, _, stderr = run_git_command(['fetch', remote], check=False) + if not success: + print_warning(f"리모트 fetch 실패: {stderr}") + return success def create_branch(branch_name: str) -> bool: - """ - 현재 브랜치에서 새 브랜치 생성 - - Args: - branch_name: 생성할 브랜치명 - - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['branch', branch_name], check=False) - if not success: - print_error(f"브랜치 생성 실패: {stderr}") - return success + """ + 새 브랜치 생성 (현재 브랜치에서 분기) + + Args: + branch_name: 생성할 브랜치명 + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['branch', branch_name], check=False) + if not success: + print_error(f"브랜치 생성 실패: {stderr}") + return success + + +def create_branch_from_remote(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트 브랜치를 기반으로 로컬 tracking 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command( + ['branch', '--track', branch_name, f'{remote}/{branch_name}'], + check=False + ) + if not success: + print_error(f"리모트 브랜치 기반 로컬 브랜치 생성 실패: {stderr}") + return success def get_worktree_list() -> Dict[str, str]: - """ - 현재 등록된 worktree 목록 반환 - - Returns: - Dict[str, str]: {worktree_path: branch_name} - """ - success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) - if not success: - return {} - - worktrees = {} - current_path = None - - for line in stdout.split('\n'): - if line.startswith('worktree '): - current_path = line.replace('worktree ', '') - elif line.startswith('branch '): - branch = line.replace('branch ', '').replace('refs/heads/', '') - if current_path: - worktrees[current_path] = branch - current_path = None - - return worktrees + """ + 현재 등록된 worktree 목록 반환 + + Returns: + Dict[str, str]: {worktree_path: branch_name} + """ + success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) + if not success: + return {} + + worktrees = {} + current_path = None + + for line in stdout.split('\n'): + if line.startswith('worktree '): + current_path = line.replace('worktree ', '') + elif line.startswith('branch '): + branch = line.replace('branch ', '').replace('refs/heads/', '') + if current_path: + worktrees[current_path] = branch + current_path = None + + return worktrees def prune_worktrees() -> bool: - """ - 유효하지 않은 worktree 정리 (git worktree prune) + """ + 유효하지 않은 worktree 정리 (git worktree prune) - Returns: - bool: 성공 여부 - """ - success, _, stderr = run_git_command(['worktree', 'prune'], check=False) - if not success: - print_warning(f"Worktree prune 실패: {stderr}") - return success + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['worktree', 'prune'], check=False) + if not success: + print_warning(f"Worktree prune 실패: {stderr}") + return success def is_worktree_exists(worktree_path: Path) -> bool: - """ - 특정 경로에 worktree가 이미 존재하는지 확인 - - Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. - prunable 상태의 worktree는 자동으로 정리합니다. - - Args: - worktree_path: 확인할 worktree 경로 - - Returns: - bool: worktree가 유효하게 존재하면 True - """ - # 먼저 prunable worktree 정리 - prune_worktrees() - - worktrees = get_worktree_list() - worktree_path_resolved = worktree_path.resolve() - - for path in worktrees.keys(): - if Path(path).resolve() == worktree_path_resolved: - # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 - if Path(path).exists(): - return True - else: - # 디렉토리가 없으면 다시 prune 실행 - print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") - prune_worktrees() - return False - - # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 - if worktree_path_resolved.exists(): - # .git 파일이 있는지 확인 (worktree의 특징) - git_file = worktree_path_resolved / '.git' - if git_file.exists(): - print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") - return True + """ + 특정 경로에 worktree가 이미 존재하는지 확인 - return False + Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. + prunable 상태의 worktree는 자동으로 정리합니다. + + Args: + worktree_path: 확인할 worktree 경로 + + Returns: + bool: worktree가 유효하게 존재하면 True + """ + # 먼저 prunable worktree 정리 + prune_worktrees() + + worktrees = get_worktree_list() + worktree_path_resolved = worktree_path.resolve() + + for path in worktrees.keys(): + if Path(path).resolve() == worktree_path_resolved: + # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 + if Path(path).exists(): + return True + else: + # 디렉토리가 없으면 다시 prune 실행 + print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") + prune_worktrees() + return False + + # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 + if worktree_path_resolved.exists(): + # .git 파일이 있는지 확인 (worktree의 특징) + git_file = worktree_path_resolved / '.git' + if git_file.exists(): + print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") + return True + + return False def create_worktree(branch_name: str, worktree_path: Path) -> Dict: - """ - Git worktree 생성 - - Args: - branch_name: 체크아웃할 브랜치명 - worktree_path: worktree를 생성할 경로 - - Returns: - Dict: { - 'success': bool, - 'path': str, - 'message': str, - 'is_existing': bool - } - """ - # 이미 존재하는지 확인 - if is_worktree_exists(worktree_path): - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree가 이미 존재합니다.', - 'is_existing': True - } - - # worktree 생성 - success, stdout, stderr = run_git_command( - ['worktree', 'add', str(worktree_path), branch_name], - check=False - ) - - if success: - return { - 'success': True, - 'path': str(worktree_path.resolve()), - 'message': 'Worktree 생성 완료!', - 'is_existing': False - } - else: - return { - 'success': False, - 'path': str(worktree_path.resolve()), - 'message': f'Worktree 생성 실패: {stderr}', - 'is_existing': False - } + """ + Git worktree 생성 + + Args: + branch_name: 체크아웃할 브랜치명 + worktree_path: worktree를 생성할 경로 + + Returns: + Dict: { + 'success': bool, + 'path': str, + 'message': str, + 'is_existing': bool + } + """ + # 이미 존재하는지 확인 + if is_worktree_exists(worktree_path): + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree가 이미 존재합니다.', + 'is_existing': True + } + + # worktree 생성 + success, stdout, stderr = run_git_command( + ['worktree', 'add', str(worktree_path), branch_name], + check=False + ) + + if success: + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree 생성 완료!', + 'is_existing': False + } + else: + return { + 'success': False, + 'path': str(worktree_path.resolve()), + 'message': f'Worktree 생성 실패: {stderr}', + 'is_existing': False + } # =================================================================== @@ -394,96 +468,96 @@ def create_worktree(branch_name: str, worktree_path: Path) -> Dict: # =================================================================== def normalize_branch_name(branch_name: str) -> str: - """ - 브랜치명을 폴더명으로 안전하게 변환 - - 특수문자 (#, /, \\\\, :, *, ?, ", <, >, |)를 _ 로 변환하고, - 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. - - Args: - branch_name: 원본 브랜치명 - - Returns: - str: 정규화된 폴더명 - - Example: - >>> normalize_branch_name("20260120_#163_Github_Projects") - "20260120_163_Github_Projects" - """ - # 특수문자를 _ 로 변환 - normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) - - # 연속된 _를 하나로 통합 - normalized = re.sub(r'_+', '_', normalized) - - # 앞뒤 _를 제거 - normalized = normalized.strip('_') - - return normalized + """ + 브랜치명을 폴더명으로 안전하게 변환 + + 특수문자 (#, /, \\, :, *, ?, ", <, >, |)를 _ 로 변환하고, + 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. + + Args: + branch_name: 원본 브랜치명 + + Returns: + str: 정규화된 폴더명 + + Example: + >>> normalize_branch_name("20260120_#163_Github_Projects") + "20260120_163_Github_Projects" + """ + # 특수문자를 _ 로 변환 + normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) + + # 연속된 _를 하나로 통합 + normalized = re.sub(r'_+', '_', normalized) + + # 앞뒤 _를 제거 + normalized = normalized.strip('_') + + return normalized def get_worktree_root() -> Path: - """ - Worktree 루트 경로 계산 - - 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - - Returns: - Path: Worktree 루트 경로 - - Example: - 현재: /Users/.../project/RomRom-FE - 반환: /Users/.../project/RomRom-FE-Worktree - """ - git_root = get_git_root() - if not git_root: - raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") - - # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) - project_name = git_root.name - - # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 - worktree_root_name = f"{project_name}-Worktree" - worktree_root = git_root.parent / worktree_root_name - - return worktree_root + """ + Worktree 루트 경로 계산 + + 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + + Returns: + Path: Worktree 루트 경로 + + Example: + 현재: /Users/.../project/RomRom-FE + 반환: /Users/.../project/RomRom-FE-Worktree + """ + git_root = get_git_root() + if not git_root: + raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") + + # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) + project_name = git_root.name + + # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + worktree_root_name = f"{project_name}-Worktree" + worktree_root = git_root.parent / worktree_root_name + + return worktree_root def get_worktree_path(branch_name: str) -> Path: - """ - 특정 브랜치의 worktree 전체 경로 반환 - - Args: - branch_name: 브랜치명 (정규화 전) - - Returns: - Path: Worktree 경로 - - Example: - >>> get_worktree_path("20260120_#163_Github_Projects") - Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") - """ - worktree_root = get_worktree_root() - folder_name = normalize_branch_name(branch_name) - return worktree_root / folder_name + """ + 특정 브랜치의 worktree 전체 경로 반환 + + Args: + branch_name: 브랜치명 (정규화 전) + + Returns: + Path: Worktree 경로 + + Example: + >>> get_worktree_path("20260120_#163_Github_Projects") + Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") + """ + worktree_root = get_worktree_root() + folder_name = normalize_branch_name(branch_name) + return worktree_root / folder_name def ensure_directory(path: Path) -> bool: - """ - 디렉토리가 존재하지 않으면 생성 - - Args: - path: 생성할 디렉토리 경로 - - Returns: - bool: 성공 여부 - """ - try: - path.mkdir(parents=True, exist_ok=True) - return True - except Exception as e: - print_error(f"디렉토리 생성 실패: {e}") - return False + """ + 디렉토리가 존재하지 않으면 생성 + + Args: + path: 생성할 디렉토리 경로 + + Returns: + bool: 성공 여부 + """ + try: + path.mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + print_error(f"디렉토리 생성 실패: {e}") + return False # =================================================================== @@ -491,131 +565,149 @@ def ensure_directory(path: Path) -> bool: # =================================================================== def main() -> int: - """ - 메인 워크플로우 - - Returns: - int: Exit code (0: 성공, 1: 실패) - """ - print_header() - - # 1. 브랜치명 받기 (Windows 환경 대응) - branch_name = get_branch_name() - - if not branch_name: - print_error("브랜치명이 제공되지 않았습니다.") - print() - print("사용법:") - if IS_WINDOWS: - print(" Windows 환경:") - print(" 방법 1: 환경 변수 사용") - print(f' $env:GIT_BRANCH_NAME = "브랜치명"') - print(f" python -X utf8 {sys.argv[0]}") - print() - print(" 방법 2: 인자로 전달") - print(f' python -X utf8 {sys.argv[0]} "브랜치명"') - else: - print(f" python {sys.argv[0]} ") - print() - print("예시:") - print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') - return 1 + """ + 메인 워크플로우 - print_step("📋", f"입력된 브랜치: {branch_name}") - - # 2. Git 저장소 확인 - if not is_git_repository(): - print_error("현재 디렉토리가 Git 저장소가 아닙니다.") - return 1 + Returns: + int: Exit code (0: 성공, 1: 실패) + """ + print_header() - # 2-1. Windows 긴 경로 지원 확인 및 활성화 - if IS_WINDOWS: - check_and_enable_longpaths() - print() + # 1. 브랜치명 받기 (Windows 환경 대응) + branch_name = get_branch_name() - # 3. 브랜치명 정규화 - folder_name = normalize_branch_name(branch_name) - print_step("📁", f"폴더명: {folder_name}") + if not branch_name: + print_error("브랜치명이 제공되지 않았습니다.") print() - - # 4. 브랜치 존재 확인 - print_step("🔍", "브랜치 확인 중...") - - if not branch_exists(branch_name): - print_warning("브랜치가 존재하지 않습니다.") - - current_branch = get_current_branch() - if current_branch: - print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") - else: - print_step("🔄", "새 브랜치 생성 중...") - - if not create_branch(branch_name): - print_error("브랜치 생성에 실패했습니다.") - return 1 - - print_success("브랜치 생성 완료!") + print("사용법:") + if IS_WINDOWS: + print(" Windows 환경:") + print(" 방법 1: 환경 변수 사용") + print(f' $env:GIT_BRANCH_NAME = "브랜치명"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 2: 파일 사용") + print(f' $env:BRANCH_NAME_FILE = "branch_name.txt"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 3: 인자로 전달 (한글 깨짐 가능)") + print(f' python {sys.argv[0]} "브랜치명"') else: - print_success("브랜치가 이미 존재합니다.") - + print(f" python {sys.argv[0]} ") print() - - # 5. Worktree 경로 계산 - try: - worktree_path = get_worktree_path(branch_name) - except RuntimeError as e: - print_error(str(e)) - return 1 - - print_step("📂", f"Worktree 경로: {worktree_path}") + print("예시:") + print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') + return 1 + + print_step("📋", f"입력된 브랜치: {branch_name}") + + # 2. Git 저장소 확인 + if not is_git_repository(): + print_error("현재 디렉토리가 Git 저장소가 아닙니다.") + return 1 + + # 2-1. Windows 긴 경로 지원 확인 및 활성화 + if IS_WINDOWS: + check_and_enable_longpaths() print() - - # 6. Worktree 존재 확인 - print_step("🔍", "Worktree 확인 중...") - - if is_worktree_exists(worktree_path): - print_info("Worktree가 이미 존재합니다.") - print() - print_step("📍", f"경로: {worktree_path.resolve()}") - return 0 - - # 7. Worktree 루트 디렉토리 생성 - worktree_root = get_worktree_root() - if not ensure_directory(worktree_root): + + # 3. 브랜치명 정규화 + folder_name = normalize_branch_name(branch_name) + print_step("📁", f"폴더명: {folder_name}") + print() + + # 4. 브랜치 존재 확인 (로컬 → 리모트 순서) + print_step("🔍", "브랜치 확인 중...") + + if branch_exists(branch_name): + print_success("로컬 브랜치가 이미 존재합니다.") + else: + print_warning("로컬 브랜치가 존재하지 않습니다.") + + # 리모트에서 최신 정보 가져오기 + fetch_remote() + + if remote_branch_exists(branch_name): + # 리모트에 브랜치가 있으면 tracking 브랜치로 생성 + print_step("🌐", f"리모트(origin/{branch_name})에서 브랜치를 가져옵니다...") + + if not create_branch_from_remote(branch_name): + print_error("리모트 브랜치 기반 로컬 브랜치 생성에 실패했습니다.") return 1 - - # 8. Worktree 생성 - print_step("🔄", "Worktree 생성 중...") - - result = create_worktree(branch_name, worktree_path) - - if result['success']: - if result['is_existing']: - print_info(result['message']) - else: - print_success(result['message']) - - print() - print_step("📍", f"경로: {result['path']}") - return 0 + + print_success(f"리모트 브랜치(origin/{branch_name})를 기반으로 로컬 브랜치 생성 완료!") else: - print_error(result['message']) + # 리모트에도 없으면 현재 브랜치에서 새로 생성 + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") return 1 + print_success("새 브랜치 생성 완료!") + + print() + + # 5. Worktree 경로 계산 + try: + worktree_path = get_worktree_path(branch_name) + except RuntimeError as e: + print_error(str(e)) + return 1 + + print_step("📂", f"Worktree 경로: {worktree_path}") + print() + + # 6. Worktree 존재 확인 + print_step("🔍", "Worktree 확인 중...") + + if is_worktree_exists(worktree_path): + print_info("Worktree가 이미 존재합니다.") + print() + print_step("📍", f"경로: {worktree_path.resolve()}") + return 0 + + # 7. Worktree 루트 디렉토리 생성 + worktree_root = get_worktree_root() + if not ensure_directory(worktree_root): + return 1 + + # 8. Worktree 생성 + print_step("🔄", "Worktree 생성 중...") + + result = create_worktree(branch_name, worktree_path) + + if result['success']: + if result['is_existing']: + print_info(result['message']) + else: + print_success(result['message']) + + print() + print_step("📍", f"경로: {result['path']}") + return 0 + else: + print_error(result['message']) + return 1 + # =================================================================== # 엔트리 포인트 # =================================================================== if __name__ == "__main__": - try: - exit_code = main() - sys.exit(exit_code) - except KeyboardInterrupt: - print() - print_warning("사용자에 의해 중단되었습니다.") - sys.exit(130) - except Exception as e: - print() - print_error(f"예상치 못한 오류가 발생했습니다: {e}") - sys.exit(1) + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print() + print_warning("사용자에 의해 중단되었습니다.") + sys.exit(130) + except Exception as e: + print() + print_error(f"예상치 못한 오류가 발생했습니다: {e}") + sys.exit(1) diff --git a/.cursor/commands/init-workflow.md b/.cursor/commands/init-workflow.md index 35d87a3c..6c23d473 100644 --- a/.cursor/commands/init-workflow.md +++ b/.cursor/commands/init-workflow.md @@ -4,7 +4,7 @@ Git worktree를 자동으로 생성하는 커맨드입니다. 브랜치명을 입력받아 자동으로: 1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) -2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +2. 브랜치가 없으면 리모트(origin) 확인 → 있으면 tracking 브랜치로 가져오기, 없으면 현재 브랜치에서 새로 생성 3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) 5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) @@ -38,9 +38,9 @@ Git worktree를 자동으로 생성하는 커맨드입니다. 1. 프로젝트 루트로 이동 2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) 3. 임시 Python 스크립트 파일 생성: - - 파일명: `init_worktree_temp_{timestamp}.py` - - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결, `#` 문자 유지) - - worktree 생성 로직 포함 + - 파일명: `init_worktree_temp_{timestamp}.py` + - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결, `#` 문자 유지) + - worktree 생성 로직 포함 4. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): ```bash python -X utf8 init_worktree_temp_{timestamp}.py @@ -94,18 +94,18 @@ exit_code = worktree_manager.main() # worktree 경로를 환경변수로 설정 (에이전트가 파일 복사에 사용) if exit_code == 0: - import subprocess - result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], + import subprocess + result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], capture_output=True, text=True, encoding='utf-8') - lines = result.stdout.split('\n') - worktree_path = None - for i, line in enumerate(lines): - if line.startswith(f'branch refs/heads/{branch_name}'): - worktree_path = lines[i-1].replace('worktree ', '') - break - - if worktree_path: - print(f'📍 WORKTREE_PATH={worktree_path}') + lines = result.stdout.split('\n') + worktree_path = None + for i, line in enumerate(lines): + if line.startswith(f'branch refs/heads/{branch_name}'): + worktree_path = lines[i-1].replace('worktree ', '') + break + + if worktree_path: + print(f'📍 WORKTREE_PATH={worktree_path}') sys.exit(exit_code) ``` diff --git a/.cursor/scripts/worktree_manager.py b/.cursor/scripts/worktree_manager.py index fe2dbe0b..61ee4cc8 100644 --- a/.cursor/scripts/worktree_manager.py +++ b/.cursor/scripts/worktree_manager.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -Git Worktree Manager v1.0.4 +Git Worktree Manager v1.1.0 Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. -브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. +브랜치가 없으면 리모트(origin) 확인 후 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. 사용법: macOS/Linux: @@ -46,7 +46,7 @@ # 상수 정의 # =================================================================== -VERSION = "1.0.4" +VERSION = "1.1.0" # Windows 환경 감지 IS_WINDOWS = platform.system() == 'Windows' @@ -249,13 +249,13 @@ def get_current_branch() -> Optional[str]: def branch_exists(branch_name: str) -> bool: """ - 브랜치 존재 여부 확인 + 로컬 브랜치 존재 여부 확인 Args: branch_name: 확인할 브랜치명 Returns: - bool: 브랜치가 존재하면 True + bool: 로컬 브랜치가 존재하면 True """ success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) if success and stdout: @@ -265,9 +265,44 @@ def branch_exists(branch_name: str) -> bool: return False +def remote_branch_exists(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트에 브랜치가 존재하는지 확인 + + Args: + branch_name: 확인할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 리모트에 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '-r', '--list', f'{remote}/{branch_name}'], check=False) + if success and stdout: + branches = [line.strip() for line in stdout.split('\n')] + return f'{remote}/{branch_name}' in branches + return False + + +def fetch_remote(remote: str = 'origin') -> bool: + """ + 리모트에서 최신 정보를 가져옵니다 (git fetch) + + Args: + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + print_step("🔄", f"리모트({remote}) 최신 정보 가져오는 중...") + success, _, stderr = run_git_command(['fetch', remote], check=False) + if not success: + print_warning(f"리모트 fetch 실패: {stderr}") + return success + + def create_branch(branch_name: str) -> bool: """ - 현재 브랜치에서 새 브랜치 생성 + 새 브랜치 생성 (현재 브랜치에서 분기) Args: branch_name: 생성할 브랜치명 @@ -281,6 +316,26 @@ def create_branch(branch_name: str) -> bool: return success +def create_branch_from_remote(branch_name: str, remote: str = 'origin') -> bool: + """ + 리모트 브랜치를 기반으로 로컬 tracking 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + remote: 리모트 이름 (기본: 'origin') + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command( + ['branch', '--track', branch_name, f'{remote}/{branch_name}'], + check=False + ) + if not success: + print_error(f"리모트 브랜치 기반 로컬 브랜치 생성 실패: {stderr}") + return success + + def get_worktree_list() -> Dict[str, str]: """ 현재 등록된 worktree 목록 반환 @@ -561,25 +616,39 @@ def main() -> int: print_step("📁", f"폴더명: {folder_name}") print() - # 4. 브랜치 존재 확인 + # 4. 브랜치 존재 확인 (로컬 → 리모트 순서) print_step("🔍", "브랜치 확인 중...") - if not branch_exists(branch_name): - print_warning("브랜치가 존재하지 않습니다.") - - current_branch = get_current_branch() - if current_branch: - print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") - else: - print_step("🔄", "새 브랜치 생성 중...") + if branch_exists(branch_name): + print_success("로컬 브랜치가 이미 존재합니다.") + else: + print_warning("로컬 브랜치가 존재하지 않습니다.") - if not create_branch(branch_name): - print_error("브랜치 생성에 실패했습니다.") - return 1 + # 리모트에서 최신 정보 가져오기 + fetch_remote() - print_success("브랜치 생성 완료!") - else: - print_success("브랜치가 이미 존재합니다.") + if remote_branch_exists(branch_name): + # 리모트에 브랜치가 있으면 tracking 브랜치로 생성 + print_step("🌐", f"리모트(origin/{branch_name})에서 브랜치를 가져옵니다...") + + if not create_branch_from_remote(branch_name): + print_error("리모트 브랜치 기반 로컬 브랜치 생성에 실패했습니다.") + return 1 + + print_success(f"리모트 브랜치(origin/{branch_name})를 기반으로 로컬 브랜치 생성 완료!") + else: + # 리모트에도 없으면 현재 브랜치에서 새로 생성 + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") + return 1 + + print_success("새 브랜치 생성 완료!") print() diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 201b341d..c42e1d3b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -218,37 +218,37 @@ SPEC CHECKSUMS: AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e - firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 - firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde + firebase_core: e6b8bb503b7d1d9856e698d4f193f7b414e6bf1f + firebase_messaging: fc7b6af84f4cd885a4999f51ea69ef20f380d70d FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_naver_map: dafe40026d3b4739fdff4ae32953021b1b52b0c2 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - geocoding_ios: 33776c9ebb98d037b5e025bb0e7537f6dd19646e - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_naver_map: efcc30f2bb0eaacdb14f41b5a26696b3ca1e3319 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416 + geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd + google_sign_in_ios: 7411fab6948df90490dc4620ecbcabdc3ca04017 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - kakao_flutter_sdk_common: 600d55b532da0bd37268a529e1add49302477710 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + kakao_flutter_sdk_common: 3dc8492c202af7853585d151490b1c5c6b7576cb nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NMapsGeometry: 4e02554fa9880ef02ed96b075dc84355d6352479 NMapsMap: 8e7e35c2446e7c4a88d6dd0d64aa0d1de903e4f0 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + patrol: d49fcd015892f19189a4cec572f21f3c3358e761 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 PODFILE CHECKSUM: 4f3e42c8f3570d837671ee684a72a97ba1484da8 diff --git a/lib/enums/notification_setting_type.dart b/lib/enums/notification_setting_type.dart new file mode 100644 index 00000000..298e97c8 --- /dev/null +++ b/lib/enums/notification_setting_type.dart @@ -0,0 +1,37 @@ +/// 알림 설정 타입 +enum NotificationSettingType { marketing, activity, chat, content, transaction } + +/// 알림 설정 타입 확장 +extension NotificationSettingTypeExtension on NotificationSettingType { + /// 설정 제목 + String get title { + switch (this) { + case NotificationSettingType.marketing: + return '마케팅 수신 동의'; + case NotificationSettingType.activity: + return '활동 알림'; + case NotificationSettingType.chat: + return '채팅 알림'; + case NotificationSettingType.content: + return '콘텐츠 알림'; + case NotificationSettingType.transaction: + return '거래 알림'; + } + } + + /// 설정 설명 + String get description { + switch (this) { + case NotificationSettingType.marketing: + return '이벤트, 혜택 등 광고성 정보 수신'; + case NotificationSettingType.activity: + return '작성한 글에 대한 좋아요 알림'; + case NotificationSettingType.chat: + return '메시지 도착 시 실시간 알림'; + case NotificationSettingType.content: + return '인기글, 정보성글 등의 콘텐츠 알림'; + case NotificationSettingType.transaction: + return '교환 요청 등의 알림'; + } + } +} diff --git a/lib/models/apis/objects/member.dart b/lib/models/apis/objects/member.dart index 62164bea..9c570232 100644 --- a/lib/models/apis/objects/member.dart +++ b/lib/models/apis/objects/member.dart @@ -20,6 +20,10 @@ class Member extends BaseEntity { final bool? isRequiredTermsAgreed; final bool? isMarketingInfoAgreed; final bool? isNotificationAgreed; + final bool? isActivityNotificationAgreed; + final bool? isChatNotificationAgreed; + final bool? isContentNotificationAgreed; + final bool? isTradeNotificationAgreed; final double? latitude; final double? longitude; final int? totalLikeCount; @@ -48,6 +52,10 @@ class Member extends BaseEntity { this.isRequiredTermsAgreed, this.isMarketingInfoAgreed, this.isNotificationAgreed, + this.isActivityNotificationAgreed, + this.isChatNotificationAgreed, + this.isContentNotificationAgreed, + this.isTradeNotificationAgreed, this.latitude, this.longitude, this.totalLikeCount, diff --git a/lib/models/apis/objects/member.g.dart b/lib/models/apis/objects/member.g.dart index 77736bbb..d5a19435 100644 --- a/lib/models/apis/objects/member.g.dart +++ b/lib/models/apis/objects/member.g.dart @@ -23,6 +23,10 @@ Member _$MemberFromJson(Map json) => Member( isRequiredTermsAgreed: json['isRequiredTermsAgreed'] as bool?, isMarketingInfoAgreed: json['isMarketingInfoAgreed'] as bool?, isNotificationAgreed: json['isNotificationAgreed'] as bool?, + isActivityNotificationAgreed: json['isActivityNotificationAgreed'] as bool?, + isChatNotificationAgreed: json['isChatNotificationAgreed'] as bool?, + isContentNotificationAgreed: json['isContentNotificationAgreed'] as bool?, + isTradeNotificationAgreed: json['isTradeNotificationAgreed'] as bool?, latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), totalLikeCount: (json['totalLikeCount'] as num?)?.toInt(), @@ -48,6 +52,10 @@ Map _$MemberToJson(Member instance) => { 'isRequiredTermsAgreed': instance.isRequiredTermsAgreed, 'isMarketingInfoAgreed': instance.isMarketingInfoAgreed, 'isNotificationAgreed': instance.isNotificationAgreed, + 'isActivityNotificationAgreed': instance.isActivityNotificationAgreed, + 'isChatNotificationAgreed': instance.isChatNotificationAgreed, + 'isContentNotificationAgreed': instance.isContentNotificationAgreed, + 'isTradeNotificationAgreed': instance.isTradeNotificationAgreed, 'latitude': instance.latitude, 'longitude': instance.longitude, 'totalLikeCount': instance.totalLikeCount, diff --git a/lib/screens/my_page_tab_screen.dart b/lib/screens/my_page_tab_screen.dart index 3ee5d79d..c94d7776 100644 --- a/lib/screens/my_page_tab_screen.dart +++ b/lib/screens/my_page_tab_screen.dart @@ -7,6 +7,7 @@ import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/screens/login_screen.dart'; import 'package:romrom_fe/screens/my_page/my_like_list_screen.dart'; +import 'package:romrom_fe/screens/notification_settings_screen.dart'; import 'package:romrom_fe/services/apis/member_api.dart'; import 'package:romrom_fe/services/apis/social_logout_service.dart'; import 'package:romrom_fe/services/auth_service.dart'; @@ -88,7 +89,7 @@ class _MyPageTabScreenState extends State { Text('마이페이지', style: CustomTextStyles.h1), GestureDetector( onTap: () { - // TODO: 설정 화면으로 이동 + context.navigateTo(screen: const NotificationSettingsScreen()); }, child: Icon(AppIcons.setting, size: 30.sp, color: AppColors.textColorWhite), ), diff --git a/lib/screens/notification_screen.dart b/lib/screens/notification_screen.dart index e6b24d11..9f250e53 100644 --- a/lib/screens/notification_screen.dart +++ b/lib/screens/notification_screen.dart @@ -6,6 +6,8 @@ import 'package:romrom_fe/enums/snack_bar_type.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/screens/notification_settings_screen.dart'; +import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; import 'package:romrom_fe/widgets/common/glass_header_delegate.dart'; import 'package:romrom_fe/widgets/notification_item_widget.dart'; @@ -141,7 +143,7 @@ class _NotificationScreenState extends State with SingleTick /// 설정 화면으로 이동 void _onSettingsTap() { - CommonSnackBar.show(context: context, message: '설정 기능 준비 중입니다.', type: SnackBarType.info); + context.navigateTo(screen: const NotificationSettingsScreen()); } /// 알림 끄기 diff --git a/lib/screens/notification_settings_screen.dart b/lib/screens/notification_settings_screen.dart new file mode 100644 index 00000000..c723de6c --- /dev/null +++ b/lib/screens/notification_settings_screen.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:romrom_fe/enums/notification_setting_type.dart'; +import 'package:romrom_fe/models/apis/objects/member.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/services/apis/member_api.dart'; +import 'package:romrom_fe/widgets/common/completed_toggle_switch.dart'; +import 'package:romrom_fe/widgets/common_app_bar.dart'; + +/// 알림 설정 화면 +class NotificationSettingsScreen extends StatefulWidget { + const NotificationSettingsScreen({super.key}); + + @override + State createState() => _NotificationSettingsScreenState(); +} + +class _NotificationSettingsScreenState extends State { + final MemberApi _memberApi = MemberApi(); + + // 각 설정 상태 + bool _isMarketingEnabled = false; + bool _isActivityEnabled = false; + bool _isChatEnabled = false; + bool _isContentEnabled = false; + bool _isTransactionEnabled = false; + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadNotificationSettings(); + } + + /// 서버에서 현재 알림 설정 값 로딩 + Future _loadNotificationSettings() async { + try { + final response = await _memberApi.getMemberInfo(); + final Member? member = response.member; + if (member != null && mounted) { + setState(() { + _isMarketingEnabled = member.isMarketingInfoAgreed ?? false; + _isActivityEnabled = member.isActivityNotificationAgreed ?? false; + _isChatEnabled = member.isChatNotificationAgreed ?? false; + _isContentEnabled = member.isContentNotificationAgreed ?? false; + _isTransactionEnabled = member.isTradeNotificationAgreed ?? false; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('알림 설정 로딩 실패: $e'); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.primaryBlack, + appBar: CommonAppBar(title: '설정', onBackPressed: () => Navigator.pop(context)), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(24.w, 16.h, 24.w, 24.h), + child: Column( + children: [ + // 상단 그룹 (마케팅, 활동, 채팅, 콘텐츠) + _buildSettingsGroup([ + NotificationSettingType.marketing, + NotificationSettingType.activity, + NotificationSettingType.chat, + NotificationSettingType.content, + ]), + + SizedBox(height: 16.h), // 그룹 간 간격 + // 하단 그룹 (거래) + _buildSettingsGroup([NotificationSettingType.transaction]), + ], + ), + ), + ), + ); + } + + /// 설정 그룹 박스 빌더 + Widget _buildSettingsGroup(List settingTypes) { + return Container( + decoration: BoxDecoration(color: AppColors.secondaryBlack1, borderRadius: BorderRadius.circular(10.r)), + child: Column( + children: List.generate(settingTypes.length, (index) { + return _buildSettingRow(settingTypes[index]); + }), + ), + ); + } + + /// 개별 설정 행 빌더 + Widget _buildSettingRow(NotificationSettingType type) { + final bool value = _getSettingValue(type); + + return SizedBox( + height: 74.h, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 좌측: 타이틀 + 서브텍스트 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(type.title, style: CustomTextStyles.p2.copyWith(color: AppColors.textColorWhite)), + SizedBox(height: 8.h), + Text( + type.description, + style: CustomTextStyles.p3.copyWith(color: AppColors.opacity60White, fontWeight: FontWeight.w500), + ), + ], + ), + ), + + // 우측: 토글 스위치 + CompletedToggleSwitch(value: value, onChanged: (newValue) => _onSettingChanged(type, newValue)), + ], + ), + ), + ); + } + + /// 설정 값 가져오기 + bool _getSettingValue(NotificationSettingType type) { + switch (type) { + case NotificationSettingType.marketing: + return _isMarketingEnabled; + case NotificationSettingType.activity: + return _isActivityEnabled; + case NotificationSettingType.chat: + return _isChatEnabled; + case NotificationSettingType.content: + return _isContentEnabled; + case NotificationSettingType.transaction: + return _isTransactionEnabled; + } + } + + /// 설정 값 변경 핸들러 (즉시 API 호출) + void _onSettingChanged(NotificationSettingType type, bool newValue) { + setState(() { + switch (type) { + case NotificationSettingType.marketing: + _isMarketingEnabled = newValue; + break; + case NotificationSettingType.activity: + _isActivityEnabled = newValue; + break; + case NotificationSettingType.chat: + _isChatEnabled = newValue; + break; + case NotificationSettingType.content: + _isContentEnabled = newValue; + break; + case NotificationSettingType.transaction: + _isTransactionEnabled = newValue; + break; + } + }); + + _updateNotificationSetting(type, newValue); + } + + /// 서버에 알림 설정 업데이트 + Future _updateNotificationSetting(NotificationSettingType type, bool value) async { + try { + await _memberApi.updateNotificationSetting( + isMarketingInfoAgreed: type == NotificationSettingType.marketing ? value : null, + isActivityNotificationAgreed: type == NotificationSettingType.activity ? value : null, + isChatNotificationAgreed: type == NotificationSettingType.chat ? value : null, + isContentNotificationAgreed: type == NotificationSettingType.content ? value : null, + isTradeNotificationAgreed: type == NotificationSettingType.transaction ? value : null, + ); + } catch (e) { + debugPrint('알림 설정 업데이트 실패: $e'); + // 실패 시 토글 원복 + if (mounted) { + setState(() { + switch (type) { + case NotificationSettingType.marketing: + _isMarketingEnabled = !value; + break; + case NotificationSettingType.activity: + _isActivityEnabled = !value; + break; + case NotificationSettingType.chat: + _isChatEnabled = !value; + break; + case NotificationSettingType.content: + _isContentEnabled = !value; + break; + case NotificationSettingType.transaction: + _isTransactionEnabled = !value; + break; + } + }); + } + } + } +} diff --git a/lib/services/apis/member_api.dart b/lib/services/apis/member_api.dart index 617ac292..8b78b70d 100644 --- a/lib/services/apis/member_api.dart +++ b/lib/services/apis/member_api.dart @@ -238,19 +238,34 @@ class MemberApi { return isSuccess; } - /// 알림 수신 동의 업데이트 API + /// 알림 수신 설정 업데이트 API (개별 알림 타입별) /// `POST /api/members/notification/update` - Future updateNotificationAgreement({required bool isNotificationAgreed}) async { + /// 변경하지 않는 필드는 null로 전달 + Future updateNotificationSetting({ + bool? isMarketingInfoAgreed, + bool? isActivityNotificationAgreed, + bool? isChatNotificationAgreed, + bool? isContentNotificationAgreed, + bool? isTradeNotificationAgreed, + }) async { const String url = '${AppUrls.baseUrl}/api/members/notification/update'; late MemberResponse memberResponse; + final Map fields = { + if (isMarketingInfoAgreed != null) 'isMarketingInfoAgreed': isMarketingInfoAgreed.toString(), + if (isActivityNotificationAgreed != null) 'isActivityNotificationAgreed': isActivityNotificationAgreed.toString(), + if (isChatNotificationAgreed != null) 'isChatNotificationAgreed': isChatNotificationAgreed.toString(), + if (isContentNotificationAgreed != null) 'isContentNotificationAgreed': isContentNotificationAgreed.toString(), + if (isTradeNotificationAgreed != null) 'isTradeNotificationAgreed': isTradeNotificationAgreed.toString(), + }; + await ApiClient.sendMultipartRequest( url: url, - fields: {'isNotificationAgreed': isNotificationAgreed.toString()}, + fields: fields, isAuthRequired: true, onSuccess: (responseData) { memberResponse = MemberResponse.fromJson(responseData); - debugPrint('알림 수신 동의 업데이트 성공: $isNotificationAgreed'); + debugPrint('알림 수신 설정 업데이트 성공'); }, ); diff --git a/lib/widgets/common/completed_toggle_switch.dart b/lib/widgets/common/completed_toggle_switch.dart index f658be3b..08e1c9b2 100644 --- a/lib/widgets/common/completed_toggle_switch.dart +++ b/lib/widgets/common/completed_toggle_switch.dart @@ -19,7 +19,7 @@ class CompletedToggleSwitch extends StatelessWidget { height: 20.h, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10.r), - color: value ? AppColors.primaryYellow : const Color(0x33FFFFFF), // 활성/비활성 색상 + color: value ? AppColors.primaryYellow : AppColors.opacity40White, // 활성/비활성 색상 ), child: Stack( children: [ diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5bfb30cd..bc2d0d89 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -9,6 +9,7 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) + - CocoaAsyncSocket (7.6.5) - file_selector_macos (0.0.1): - FlutterMacOS - Firebase/CoreOnly (12.4.0): @@ -106,6 +107,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - patrol (0.0.1): + - CocoaAsyncSocket (~> 7.6) + - Flutter + - FlutterMacOS - PromisesObjC (2.4.0) - shared_preferences_foundation (0.0.1): - Flutter @@ -127,6 +132,7 @@ DEPENDENCIES: - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) - google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - patrol (from `Flutter/ephemeral/.symlinks/plugins/patrol/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -135,6 +141,7 @@ SPEC REPOS: trunk: - AppAuth - AppCheckCore + - CocoaAsyncSocket - Firebase - FirebaseCore - FirebaseCoreInternal @@ -167,6 +174,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + patrol: + :path: Flutter/ephemeral/.symlinks/plugins/patrol/darwin shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -177,6 +186,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_core: e054894ab56033ef9bcbe2d9eac9395e5306e2fc @@ -197,6 +207,7 @@ SPEC CHECKSUMS: GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0