diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..a98950a46 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,139 @@ +language: ko-KR +early_access: false +tone_instructions: > + Be concise, constructive, and production-focused. Prioritize architecture + boundaries, crash safety, and maintainability over stylistic trivia. + +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + high_level_summary_instructions: | + Summarize the PR by module and risk: + 1) functional impact and user-facing behavior changes, + 2) risk points (network/error handling, threading, state consistency), + 3) required follow-up tests and verification. + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: false + changed_files_summary: true + sequence_diagrams: false + estimate_code_review_effort: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + auto_assign_reviewers: false + poem: false + in_progress_fortune: false + path_filters: + - "!**/build/**" + - "!**/.gradle/**" + - "!**/.idea/**" + - "!**/.venv_qa_bot/**" + - "!**/.venv*/**" + - "!**/venv*/**" + - "!**/__pycache__/**" + - "!**/*.pyc" + - "!**/bin/**" + - "!**/gen/**" + - "!**/out/**" + - "!**/obj/**" + - "!**/.externalNativeBuild/**" + - "!**/.signing/**" + - "!**/release/**" + - "!**/*.apk" + - "!**/*.aab" + - "!**/*.ap_" + - "!**/*.dex" + - "!**/*.class" + - "!**/*.iml" + - "!**/*.iws" + - "!**/*.swp" + - "!**/.DS_Store" + - "!**/qa_bot_commit_msg.txt" + - "!**/qa_bot_explanation.txt" + - "!**/pr_body.md" + - "!**/tmp_github_output.txt" + - "!**/.tmp_runner/**" + - "!**/.claude/**" + - "!**/captures/**" + - "!**/.classpath" + - "!**/.project" + - "!**/.cproject" + - "!**/.settings/**" + - "!**/local.properties" + - "!**/sentry.properties" + - "!**/dayo_keystore*" + - "!**/*.jks" + - "!**/*.keystore" + - "!**/*keystore*.properties" + - "!**/crashlytics.properties" + - "!**/crashlytics-build.properties" + - "!**/fabric.properties" + - "!**/com_crashlytics_export_strings.xml" + - "!**/google-services*.json" + - "!**/output-metadata.json" + - "!**/*.log" + path_instructions: + - path: "app/src/main/**/*.kt" + instructions: | + This layer is app-level wiring only. + Review for: + - lifecycle-safe initialization in Application class, + - SDK initialization order and error handling, + - accidental business logic leakage into app module. + - path: "domain/src/main/**/*.kt" + instructions: | + Enforce Domain Layer boundaries. + Ensure: + - no Android imports in domain files, + - repository interfaces stay thin and naming is consistent, + - UseCases are small and usually delegate with `operator fun invoke()`, + - no business logic placed in repository interfaces. + - path: "data/src/main/**/*.kt" + instructions: | + Data layer should be implementation and mapping only. + Check: + - API services are suspend and return `NetworkResponse`, + - `when` handles all 4 `NetworkResponse` cases (no `else`), + - errors are passed through without swallowing, + - mappers convert DTO → domain clearly and handle nullable API fields, + - repository methods remain thin and include DI wiring updates for new APIs. + - path: "presentation/src/main/**/*.kt" + instructions: | + Review for clean Compose/VM architecture. + Verify: + - Route/Screen split is maintained (routing + state in Route, UI-only Screen), + - ViewModel injections stay in Route/RouteOwner only, + - state exposure uses Flow/StateFlow and/or LiveData intentionally, + - one `when` expression handles all `NetworkResponse` branches, + - no hardcoded colors (use theme Color constants), + - no direct navigation from composables using `NavController`. + - path: "**/src/main/res/**/*.xml" + instructions: | + For UI resources and layouts, ensure existing naming conventions and + resource organization are preserved. Avoid introducing unused IDs or + hardcoded values where theme resources already exist. + - path: ".github/**/*" + instructions: | + Check workflow safety and reproducibility: + - no plaintext secrets in files, + - pinned and maintained actions when practical, + - Gradle/JDK matrix changes remain consistent with app requirements. + + auto_review: + enabled: true + auto_incremental_review: true + ignore_title_keywords: + - "WIP" + - "wip" + labels: + - "!wip" + drafts: false + base_branches: + - ".*" + +chat: + auto_reply: true diff --git a/.github/ISSUE_TEMPLATE/-qa--design-qa.md b/.github/ISSUE_TEMPLATE/-qa--design-qa.md new file mode 100644 index 000000000..15abc62b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/-qa--design-qa.md @@ -0,0 +1,23 @@ +--- +name: "[QA] Design QA" +about: 디자인 QA 이슈를 등록합니다 +title: '[QA] Fix {Component} in {Screen}' +labels: QA +assignees: '' + +--- + +# 🎨 디자인 QA + +## 발견 위치 +- **화면**: +- **Figma 링크**: + +## 문제 설명 + + +### Annotation에 함께 등록된 properties +- 없음 + +## 상세 스펙 +- 없음 diff --git a/.github/scripts/qa_bot.py b/.github/scripts/qa_bot.py new file mode 100644 index 000000000..9f525db2a --- /dev/null +++ b/.github/scripts/qa_bot.py @@ -0,0 +1,847 @@ +#!/usr/bin/env python3 +import os +import re +import json +import subprocess +import time +from pathlib import Path +from urllib import error, request + + + + +SKIP_SCREEN_PREFIXES = {"main", "sub"} + +ALLOWED_EDIT_PREFIXES = ( + "app/src/main/java/", + "data/src/main/java/", + "domain/src/main/java/", + "presentation/src/main/java/", +) + +DENIED_EDIT_CONTAINS = ( + "local.properties", + ".keystore", + ".jks", + "keystore", + "sentry.properties", + ".github/", +) + +FONT_WEIGHT_MAP = { + 100: "FontWeight.Thin", + 200: "FontWeight.ExtraLight", + 300: "FontWeight.Light", + 400: "FontWeight.Normal", + 500: "FontWeight.Medium", + 600: "FontWeight.SemiBold", + 700: "FontWeight.Bold", + 800: "FontWeight.ExtraBold", + 900: "FontWeight.Black", +} + +def parse_gemini_error(err_str: str) -> dict: + if not err_str: + return {} + try: + parsed = json.loads(err_str) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + return {} + + +def extract_retry_delay_seconds(err_str: str, fallback: int = 0) -> int: + parsed = parse_gemini_error(err_str) + raw = parsed.get("retryDelay") if isinstance(parsed, dict) else None + if isinstance(raw, str): + m = re.fullmatch(r"(\d+)s", raw.strip()) + if m: + return int(m.group(1)) + + m = re.search(r'retryDelay\":\s*\"(\d+)s\"', err_str) + if m: + return int(m.group(1)) + + return fallback + + +def is_quota_exhausted_error(err_str: str) -> bool: + parsed = parse_gemini_error(err_str) + err = parsed.get("error") if isinstance(parsed, dict) else None + code = err.get("code") if isinstance(err, dict) else None + status = err.get("status") if isinstance(err, dict) else "" + message = err.get("message") if isinstance(err, dict) else "" + + details = err.get("details") if isinstance(err, dict) else None + reasons: list[str] = [] + if isinstance(details, list): + for d in details: + if isinstance(d, dict): + reason = d.get("reason") + if isinstance(reason, str): + reasons.append(reason) + + haystack = " ".join( + [ + str(status), + str(message), + " ".join(reasons), + err_str, + ] + ).lower() + + if code == 429: + return True + if "resource_exhausted" in haystack: + return True + if "rate_limit" in haystack: + return True + if "quota exceeded" in haystack: + return True + if "generativelanguage.googleapis.com/generate_content" in haystack: + return True + return False + + +def rank_model_metadata(model: dict) -> tuple[int, int, str]: + name = model.get("name") + display_name = model.get("displayName") + description = model.get("description") + + safe_name = name if isinstance(name, str) else "" + lower_name = safe_name.lower() + lower_display = display_name.lower() if isinstance(display_name, str) else "" + lower_description = description.lower() if isinstance(description, str) else "" + meta = " ".join([lower_name, lower_display, lower_description]) + + deprecated_penalty = 1 if "deprecated" in meta else 0 + preview_penalty = 1 if ("preview" in meta or "experimental" in meta or "-exp" in lower_name) else 0 + return (deprecated_penalty, preview_penalty, lower_name) + + +def order_candidate_models(models: list[dict]) -> list[str]: + candidate_models: list[dict] = [] + seen_names: set[str] = set() + + for m in models: + if not isinstance(m, dict): + continue + name = m.get("name") + methods = m.get("supportedGenerationMethods") + if not isinstance(name, str): + continue + if not name.startswith("models/gemini"): + continue + if not (isinstance(methods, list) and "generateContent" in methods): + continue + if name in seen_names: + continue + + seen_names.add(name) + candidate_models.append(m) + + candidate_models.sort(key=rank_model_metadata) + return [m["name"] for m in candidate_models if isinstance(m.get("name"), str)] + + +def build_snippet_catalog(files: list[Path]) -> dict[str, dict[str, str]]: + def is_interesting(line: str) -> bool: + tokens = ( + "DayoTextField(", + "DayoPasswordTextField(", + "label =", + "placeholder =", + ".padding(", + "Modifier.", + "Text(", + "fontSize =", + "fontWeight =", + "color =", + "shape =", + "border =", + "keyboardOptions", + "keyboardActions", + ) + return any(t in line for t in tokens) + + catalog: dict[str, dict[str, str]] = {} + for file_idx, f in enumerate(files): + try: + content = f.read_text(encoding="utf-8") + except Exception: + continue + + snippets: dict[str, str] = {} + count = 0 + for line_idx, line in enumerate(content.splitlines(), start=1): + if not is_interesting(line): + continue + text = line.rstrip("\n") + if not text.strip(): + continue + + snippet_id = f"F{file_idx}S{count}L{line_idx}" + snippets[snippet_id] = text + count += 1 + if count >= 35: + break + + if snippets: + catalog[str(f)] = snippets + + return catalog + + +def is_allowed_edit_path(path: Path) -> bool: + s = path.as_posix().lstrip("./") + if any(x in s for x in DENIED_EDIT_CONTAINS): + return False + if not s.endswith(".kt"): + return False + return s.startswith(ALLOWED_EDIT_PREFIXES) + + +def fetch_issue(): + from github import Auth, Github + + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["REPO_FULL_NAME"] + issue_number = int(os.environ["ISSUE_NUMBER"]) + + g = Github(auth=Auth.Token(token)) + repo = g.get_repo(repo_name) + issue = repo.get_issue(issue_number) + + return { + "number": issue_number, + "title": issue.title, + "body": issue.body or "", + } + + +def parse_issue(title, body): + result = {} + + title_match = re.search(r"\[QA\]\s+Fix\s+(\S+)\s+in\s+(\S+)", title) + if title_match: + result["component"] = title_match.group(1) + result["screen"] = title_match.group(2) + + screen_match = re.search(r"\*\*화면\*\*:\s*(.+)", body) + if screen_match: + result["screen"] = screen_match.group(1).strip() + + problem_match = re.search(r"## 문제 설명\n(.+?)(?:\n###|\n##)", body, re.DOTALL) + if problem_match: + result["problem"] = problem_match.group(1).strip() + + props_match = re.search( + r"### Annotation에 함께 등록된 properties\n(.+?)(?:\n## |\Z)", body, re.DOTALL + ) + if props_match: + raw = props_match.group(1).strip() + result["properties"] = raw if raw != "- 없음" else "" + + spec_match = re.search(r"## 상세 스펙\n(.+?)(?:\n##|\Z)", body, re.DOTALL) + if spec_match: + raw = spec_match.group(1).strip() + result["specs"] = raw if raw != "- 없음" else "" + + return result + + +def find_relevant_files(screen: str, component: str) -> list[Path]: + repo_root = Path(".") + presentation = repo_root / "presentation" + + parts = screen.split("_") + meaningful = [p for p in parts if p not in SKIP_SCREEN_PREFIXES] + + candidates: set[Path] = set() + + search_terms = [] + if meaningful: + search_terms.append("".join(p.capitalize() for p in meaningful)) + search_terms.append(meaningful[-1].capitalize()) + if len(meaningful) > 1: + search_terms.append(meaningful[0].capitalize()) + + if component: + search_terms.append(component) + + for term in search_terms: + for kt_file in presentation.rglob("*.kt"): + if term.lower() in kt_file.stem.lower(): + candidates.add(kt_file) + + result = subprocess.run( + ["grep", "-rl", "--include=*.kt", term, str(presentation)], + capture_output=True, + text=True, + ) + for line in result.stdout.strip().splitlines(): + if line: + candidates.add(Path(line)) + + def score(f: Path) -> int: + s = 0 + p = f.as_posix().lower() + stem = f.stem.lower() + if "/screen/" in p: + s += 25 + if "screen" in stem: + s += 15 + if "screen" in p: + s += 5 + if "/view/" in p or stem.endswith("view"): + s += 4 + + if "/model/" in p or "model" in stem: + s -= 6 + for p in meaningful: + if p.lower() in f.stem.lower(): + s += 5 + if p.lower() in f.as_posix().lower(): + s += 2 + return s + + return sorted(candidates, key=score, reverse=True)[:6] + + +def build_prompt( + issue: dict, + parsed: dict, + files: list[Path], + snippet_catalog: dict[str, dict[str, str]], +) -> str: + file_sections = [] + catalog_sections: list[str] = [] + for f in files: + try: + content = f.read_text(encoding="utf-8") + + snippets = snippet_catalog.get(str(f), {}) + if snippets: + lines = [ + f"- [{sid}] {text}" + for sid, text in list(snippets.items())[:35] + if isinstance(text, str) + ] + catalog_sections.append( + "### " + + str(f) + + "\n" + + "Pick snippet_id(s) from this catalog:\n" + + "\n".join(lines) + ) + + if len(content) > 4000: + pivot = content.find("DayoTextField(") + if pivot == -1: + pivot = content.find("DayoPasswordTextField(") + if pivot == -1: + pivot = content.find("@Composable") + + if pivot != -1: + start = max(pivot - 1800, 0) + end = min(start + 4000, len(content)) + content = content[start:end] + "\n... (truncated)" + else: + content = content[:4000] + "\n... (truncated)" + file_sections.append(f"### {f}\n```kotlin\n{content}\n```") + except Exception: + pass + + files_str = "\n\n".join(file_sections) if file_sections else "No relevant files found." + catalog_str = ( + "\n\n".join(catalog_sections) + if catalog_sections + else "(No snippet catalog available.)" + ) + + color_hint = "" + props = parsed.get("properties", "") + if props: + r_match = re.search(r"r:\s*([\d.]+)", props) + g_match = re.search(r"g:\s*([\d.]+)", props) + b_match = re.search(r"b:\s*([\d.]+)", props) + if r_match and g_match and b_match: + r = int(float(r_match.group(1)) * 255) + g = int(float(g_match.group(1)) * 255) + b = int(float(b_match.group(1)) * 255) + hex_color = f"#{r:02X}{g:02X}{b:02X}" + color_hint = f"\n\n> Color hint: r/g/b values convert to hex **{hex_color}**" + + fw_hint = "" + fw_match = re.search(r"fontWeight[\"']?:\s*(\d+)", props) + if fw_match: + fw_val = int(fw_match.group(1)) + compose_fw = FONT_WEIGHT_MAP.get(fw_val, f"FontWeight({fw_val})") + fw_hint = f"\n\n> FontWeight hint: {fw_val} maps to **{compose_fw}** in Compose" + + return f"""You are an Android developer fixing a UI/design QA issue in a Kotlin Jetpack Compose Android app. + +## Issue +- Number: #{issue["number"]} +- Title: {issue["title"]} +- Screen: {parsed.get("screen", "unknown")} +- Component: {parsed.get("component", "unknown")} + +## Problem +{parsed.get("problem", "No description provided.")} + +## Design Properties +{parsed.get("properties", "none") or "none"}{color_hint}{fw_hint} + +## Detailed Specs +{parsed.get("specs", "none") or "none"} + +## Relevant Source Files +{files_str} + +## Snippet Catalog (Preferred) +To avoid mismatches, prefer returning snippet_id(s) from the catalog below instead of retyping "original": +{catalog_str} + +## Rules +1. Make the smallest possible change that fixes the issue. +2. Only modify EXISTING files — never create new files. + 3. Prefer snippet_id. If you use "original", it must be an exact verbatim substring of the file content. +4. Use existing color/style constants from the codebase if they match the required value. +5. For color fills: prefer the closest existing color constant over hardcoding a new hex. +6. For fontWeight: use the Compose FontWeight constant shown in the hint above. +7. Do not retype resource identifiers (e.g., R.string.*). Copy from the provided source or snippets. + +## Required Response Format +Respond with ONLY a JSON object — no markdown fences, no extra text: +{{ + "can_fix": true, + "explanation": "One-line explanation of what was changed", + "commit_message": "Imperative short commit message, no period", + "changes": [ + {{ + "file": "relative/path/to/File.kt", + "snippet_id": "F0S0L123", + "original": "(optional) exact verbatim code snippet to replace", + "replacement": "new code snippet" + }} + ] +}} + +If auto-fix is not safe or the issue requires logic changes beyond styling, respond with: +{{ + "can_fix": false, + "explanation": "Specific reason why this cannot be auto-fixed", + "changes": [] +}}""" + + +def call_gemini(prompt: str) -> dict: + api_key = os.environ.get("GEMINI_API_KEY", "").strip() + if not api_key: + return { + "can_fix": False, + "explanation": "Missing GEMINI_API_KEY", + "changes": [], + } + + def http_json(method: str, url: str, payload: dict | None = None) -> dict: + data = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + + req = request.Request( + url=url, + method=method, + data=data, + headers={ + "Content-Type": "application/json", + }, + ) + + try: + with request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + except error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw) if raw else {"error": {"code": e.code, "message": raw}} + except json.JSONDecodeError: + detail = {"error": {"code": e.code, "message": raw}} + raise RuntimeError(json.dumps(detail, ensure_ascii=True)) from e + + def list_models(api_version: str) -> list[dict]: + all_models: list[dict] = [] + page_token = "" + + while True: + url = f"https://generativelanguage.googleapis.com/{api_version}/models?pageSize=100&key={api_key}" + if page_token: + url = f"{url}&pageToken={page_token}" + + data = http_json("GET", url) + models = data.get("models") + if isinstance(models, list): + all_models.extend(m for m in models if isinstance(m, dict)) + + next_token = data.get("nextPageToken") + if not isinstance(next_token, str) or not next_token: + break + page_token = next_token + + return all_models + + def extract_text(resp: dict) -> str: + candidates = resp.get("candidates") + if not isinstance(candidates, list) or not candidates: + return "" + content = candidates[0].get("content") if isinstance(candidates[0], dict) else None + parts = content.get("parts") if isinstance(content, dict) else None + if not isinstance(parts, list) or not parts: + return "" + texts: list[str] = [] + for p in parts: + if isinstance(p, dict) and isinstance(p.get("text"), str): + texts.append(p["text"]) + return "".join(texts).strip() + + attempts: list[tuple[str, str]] = [] + errors_seen: list[str] = [] + seen_attempt_keys: set[str] = set() + + for api_version in ("v1beta", "v1", "v1alpha"): + try: + models = list_models(api_version) + except Exception as e: + errors_seen.append(f"{api_version} list models failed: {e}") + continue + + generate_models = order_candidate_models(models) + + if not generate_models: + errors_seen.append(f"{api_version}: no generateContent-capable models returned") + continue + + print(f"{api_version}: {len(generate_models)} generateContent-capable model(s)") + + for model_name in generate_models: + attempt_key = f"{api_version}:{model_name}" + if attempt_key in seen_attempt_keys: + continue + seen_attempt_keys.add(attempt_key) + attempts.append((api_version, model_name)) + + if not attempts: + return { + "can_fix": False, + "explanation": "Unable to list any usable Gemini models. " + "; ".join(errors_seen)[:500], + "changes": [], + } + + last_err = "" + saw_quota_error = False + saw_limit_zero = False + saw_not_found = False + saw_other = False + for api_version, model_name in attempts: + print(f"Trying Gemini model: {model_name} (api: {api_version})") + url = f"https://generativelanguage.googleapis.com/{api_version}/{model_name}:generateContent?key={api_key}" + payload = { + "contents": [ + { + "role": "user", + "parts": [ + { + "text": prompt, + } + ], + } + ] + } + + try: + resp = http_json("POST", url, payload) + text = extract_text(resp) + if not text: + return { + "can_fix": False, + "explanation": f"Gemini returned empty response for {model_name}", + "changes": [], + } + + json_fence = re.search(r"```(?:json)?\n(.+?)\n```", text, re.DOTALL) + if json_fence: + text = json_fence.group(1).strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + return { + "can_fix": False, + "explanation": f"Failed to parse AI response from {model_name}: {text[:200]}", + "changes": [], + } + except Exception as e: + err_str = str(e) + last_err = err_str + if is_quota_exhausted_error(err_str): + saw_quota_error = True + if '"limit": 0' in err_str or "limit\\\": 0" in err_str: + saw_limit_zero = True + retry_seconds = extract_retry_delay_seconds(err_str) + if retry_seconds > 0: + time.sleep(min(retry_seconds, 15)) + continue + if "NOT_FOUND" in err_str: + saw_not_found = True + continue + saw_other = True + retry_seconds = extract_retry_delay_seconds(err_str) + if retry_seconds > 0: + time.sleep(min(retry_seconds, 15)) + continue + continue + + if saw_limit_zero and saw_quota_error and not saw_other: + msg = "Gemini API free-tier quota appears to be 0 for all usable models in this project. Enable billing or use a different API key/project." + if saw_not_found: + msg += " Some model IDs were also not found." + return { + "can_fix": False, + "explanation": msg, + "changes": [], + } + + if saw_quota_error and not saw_other: + return { + "can_fix": False, + "explanation": "All discovered Gemini models hit quota/rate limits. Consider increasing quota or switching API key/project.", + "changes": [], + } + + return { + "can_fix": False, + "explanation": f"All Gemini models failed. Last error: {last_err}"[:500], + "changes": [], + } + + +def apply_changes( + changes: list[dict], + snippet_catalog: dict[str, dict[str, str]], +) -> tuple[list[str], list[str]]: + repo_root = Path(".").resolve() + applied: list[str] = [] + skipped: list[str] = [] + for change in changes: + file_str = change.get("file") if isinstance(change, dict) else None + if not isinstance(file_str, str) or not file_str.strip(): + skipped.append("missing file in change") + print("SKIP: missing file in change") + continue + + path = Path(file_str) + if path.is_absolute(): + skipped.append(f"absolute path not allowed: {path}") + print(f"SKIP: absolute path not allowed — {path}") + continue + + try: + resolved = path.resolve() + except Exception: + skipped.append(f"invalid path: {path}") + print(f"SKIP: invalid path — {path}") + continue + + try: + if not resolved.is_relative_to(repo_root): + skipped.append(f"path escapes repo: {path}") + print(f"SKIP: path escapes repo — {path}") + continue + except AttributeError: + if str(resolved).startswith(str(repo_root)) is False: + skipped.append(f"path escapes repo: {path}") + print(f"SKIP: path escapes repo — {path}") + continue + + if not is_allowed_edit_path(path): + skipped.append(f"path not allowed: {path}") + print(f"SKIP: path not allowed — {path}") + continue + + if not path.exists(): + print(f"SKIP: file not found — {path}") + skipped.append(f"file not found: {path}") + continue + + content = path.read_text(encoding="utf-8") + + original = "" + snippet_id = change.get("snippet_id") if isinstance(change, dict) else None + if isinstance(snippet_id, str) and snippet_id: + file_snips = snippet_catalog.get(str(path)) or snippet_catalog.get(str(path.as_posix())) + if isinstance(file_snips, dict) and isinstance(file_snips.get(snippet_id), str): + original = file_snips[snippet_id] + else: + skipped.append(f"snippet_id not found for {path}: {snippet_id}") + print(f"SKIP: snippet_id not found for {path} — {snippet_id}") + continue + else: + original = change.get("original", "") if isinstance(change, dict) else "" + + replacement = change.get("replacement", "") if isinstance(change, dict) else "" + if not isinstance(replacement, str) or not replacement: + skipped.append(f"missing replacement for {path}") + print(f"SKIP: missing replacement for {path}") + continue + + if original not in content: + print(f"SKIP: original snippet not found in {path}") + print(f" Looking for: {original[:80]!r}") + skipped.append(f"snippet not found in {path}: {original[:80]!r}") + continue + + replaced = False + line_no = None + if isinstance(snippet_id, str) and snippet_id: + m = re.search(r"L(\d+)$", snippet_id) + if m: + try: + line_no = int(m.group(1)) + except ValueError: + line_no = None + + if isinstance(line_no, int) and line_no >= 1: + lines = content.splitlines(True) + idx = line_no - 1 + if idx < len(lines): + current_line = lines[idx].rstrip("\r\n") + if current_line == original: + suffix = "" + if lines[idx].endswith("\r\n") and not replacement.endswith("\r\n"): + suffix = "\r\n" + elif lines[idx].endswith("\n") and not replacement.endswith("\n"): + suffix = "\n" + lines[idx] = replacement + suffix + new_content = "".join(lines) + replaced = True + + if not replaced: + new_content = content.replace(original, replacement, 1) + path.write_text(new_content, encoding="utf-8") + applied.append(str(path)) + print(f"CHANGED: {path}") + + return applied, skipped + + +def write_outputs(can_fix: bool, commit_msg: str, explanation: str, pr_body: str): + out_dir = Path(".tmp_runner/qa-bot") + out_dir.mkdir(parents=True, exist_ok=True) + + (out_dir / "qa_bot_commit_msg.txt").write_text(commit_msg, encoding="utf-8") + (out_dir / "qa_bot_explanation.txt").write_text(explanation, encoding="utf-8") + (out_dir / "pr_body.md").write_text(pr_body, encoding="utf-8") + + output_file = os.environ.get("GITHUB_OUTPUT", "") + if output_file: + with open(output_file, "a") as f: + f.write(f"can_fix={'true' if can_fix else 'false'}\n") + + +def build_repair_prompt( + issue: dict, + parsed: dict, + files: list[Path], + snippet_catalog: dict[str, dict[str, str]], + skipped: list[str], +) -> str: + base = build_prompt(issue, parsed, files, snippet_catalog) + details = "\n".join(f"- {s}" for s in skipped[:5]) if skipped else "- (none)" + return ( + base + + "\n\n## Apply Failure\n" + + "The previous JSON could not be applied because the exact 'original' snippet was not found.\n" + + "Fix this by returning snippet_id from the Snippet Catalog, or by copying 'original' from the provided files/snippets exactly.\n\n" + + details + ) + + +def main(): + print("QA Bot starting...") + + issue = fetch_issue() + print(f"Issue #{issue['number']}: {issue['title']}") + + parsed = parse_issue(issue["title"], issue["body"]) + parsed["number"] = issue["number"] + print(f"Screen: {parsed.get('screen')} Component: {parsed.get('component')}") + + screen = parsed.get("screen", "") + component = parsed.get("component", "") + relevant_files = find_relevant_files(screen, component) + print(f"Found {len(relevant_files)} candidate file(s):") + for f in relevant_files: + print(f" {f}") + + snippet_catalog = build_snippet_catalog(relevant_files) + prompt = build_prompt(issue, parsed, relevant_files, snippet_catalog) + print("Calling Gemini...") + result = call_gemini(prompt) + + can_fix = result.get("can_fix", False) + explanation = result.get("explanation", "No explanation provided.") + print(f"can_fix={can_fix} explanation={explanation}") + + if not can_fix or not result.get("changes"): + write_outputs( + can_fix=False, + commit_msg="", + explanation=explanation, + pr_body="", + ) + return + + changed, skipped = apply_changes(result["changes"], snippet_catalog) + if skipped: + repair_prompt = build_repair_prompt(issue, parsed, relevant_files, snippet_catalog, skipped) + print("Retrying Gemini for exact snippet match...") + repaired = call_gemini(repair_prompt) + if repaired.get("can_fix") and repaired.get("changes"): + changed2, skipped2 = apply_changes(repaired["changes"], snippet_catalog) + changed = list(dict.fromkeys(changed + changed2)) + skipped = skipped2 + + if not changed or skipped: + reason = ( + "Code snippets could not be matched in file content. " + + "; ".join(skipped[:3]) + )[:500] + write_outputs( + can_fix=False, + commit_msg="", + explanation=reason, + pr_body="", + ) + return + + commit_msg = result.get("commit_message", f"Fix {component} in {screen}") + pr_body = ( + f"Closes #{issue['number']}\n\n" + f"**Screen**: `{screen}` \n" + f"**Component**: `{component}`\n\n" + f"**Changes**: \n{explanation}\n\n" + f"**Files modified**: \n" + + "\n".join(f"- `{f}`" for f in changed) + + "\n\n> 🤖 Auto-generated by QA Bot" + ) + + write_outputs(can_fix=True, commit_msg=commit_msg, explanation=explanation, pr_body=pr_body) + print(f"Done — {len(changed)} file(s) modified.") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/qa-bot.yml b/.github/workflows/qa-bot.yml new file mode 100644 index 000000000..a0e636a72 --- /dev/null +++ b/.github/workflows/qa-bot.yml @@ -0,0 +1,102 @@ +name: QA Issue Auto-Fix Bot + +on: + issues: + types: [opened, labeled] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + qa-fix: + # QA 라벨이 붙은 이슈가 열릴 때, 또는 기존 이슈에 QA 라벨이 붙을 때 실행 + if: | + (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'QA')) || + (github.event.action == 'labeled' && github.event.label.name == 'QA') + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: pip install google-genai PyGithub + + - name: Run QA Bot + id: qa_bot + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO_FULL_NAME: ${{ github.repository }} + run: python .github/scripts/qa_bot.py + + - name: Create branch, commit and push + id: git_push + if: steps.qa_bot.outputs.can_fix == 'true' + run: | + git config user.name "qa-bot[bot]" + git config user.email "qa-bot[bot]@users.noreply.github.com" + + BRANCH="feature/issue-${{ github.event.issue.number }}" + + # 동일 브랜치가 이미 존재하면 삭제 (재실행 안전성) + git push origin --delete "$BRANCH" 2>/dev/null || true + + git checkout -b "$BRANCH" + # NOTE: do not pass ignored paths as pathspecs; git add exits 1 when an ignored path is explicitly mentioned + git add -A + git commit -m "#${{ github.event.issue.number }} [bug] $(cat .tmp_runner/qa-bot/qa_bot_commit_msg.txt)" + git push origin "$BRANCH" + + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Create Pull Request + id: create_pr + if: steps.qa_bot.outputs.can_fix == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_URL=$(gh pr create \ + --title "#${{ github.event.issue.number }} [bug] $(cat .tmp_runner/qa-bot/qa_bot_commit_msg.txt)" \ + --body-file .tmp_runner/qa-bot/pr_body.md \ + --base develop \ + --head "feature/issue-${{ github.event.issue.number }}") + + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + + - name: Comment on issue — success + if: steps.qa_bot.outputs.can_fix == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_URL=$(cat pr_url.txt 2>/dev/null || echo "${{ steps.create_pr.outputs.pr_url }}") + gh issue comment ${{ github.event.issue.number }} \ + --body "🤖 **QA 봇**이 자동으로 수정 PR을 생성했습니다! + + PR: ${{ steps.create_pr.outputs.pr_url }} + + 수정 내용을 검토한 후 머지해주세요. 문제가 있으면 PR에 코멘트를 남겨주세요." + + - name: Comment on issue — cannot fix + if: steps.qa_bot.outputs.can_fix == 'false' || steps.qa_bot.outputs.can_fix == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REASON=$(cat .tmp_runner/qa-bot/qa_bot_explanation.txt 2>/dev/null || echo "분석 결과 자동 수정이 어렵습니다.") + gh issue comment ${{ github.event.issue.number }} \ + --body "🤖 **QA 봇**이 이슈를 분석했지만 자동 수정이 어렵습니다. + + **사유**: $REASON + + 수동 검토가 필요합니다." diff --git a/.gitignore b/.gitignore index 80750c2df..5526fbb45 100644 --- a/.gitignore +++ b/.gitignore @@ -124,4 +124,19 @@ fabric.properties sentry.properties /*/sentry.properties -# End of https://www.toptal.com/developers/gitignore/api/androidstudio \ No newline at end of file +# Python +__pycache__/ +*.pyc +.venv*/ +venv*/ + +# QA bot artifacts (should not be committed) +qa_bot_commit_msg.txt +qa_bot_explanation.txt +pr_body.md + +# Local QA bot debug artifacts +tmp_github_output.txt +.tmp_runner/ + +# End of https://www.toptal.com/developers/gitignore/api/androidstudio diff --git a/README.md b/README.md index 614c7356f..a05422bb9 100644 --- a/README.md +++ b/README.md @@ -67,5 +67,37 @@ git clone git@github.com:Daily-DAYO/DAYO_Android.git - shape drawable → border _(color) _ ([fill/line/fill_line]) _ (radius) e.g. border_white_fill_12 +## QA Auto-Fix Bot + +QA 라벨이 붙은 이슈가 생성되면 Gemini AI가 자동으로 코드를 수정하고 PR을 생성합니다. + +### Setup + +1. [Google AI Studio](https://aistudio.google.com/app/apikey)에서 Gemini API Key 발급 (무료) +2. GitHub 저장소 Settings → Secrets and variables → Actions → **New repository secret** + - Name: `GEMINI_API_KEY` + - Value: 발급받은 API Key +3. `.github/workflows/qa-bot.yml`이 `develop` 브랜치에 존재하면 자동 활성화 + +### How it works + +``` +QA 이슈 생성 (label: QA) + ↓ +GitHub Actions 트리거 + ↓ +이슈 파싱 → 화면명·컴포넌트·스펙 추출 + ↓ +관련 Kotlin 파일 탐색 + ↓ +Gemini 1.5 Flash 호출 → 수정 코드 생성 + ↓ +feature/issue-{N} 브랜치 생성 → 커밋 + ↓ +PR 자동 생성 + 이슈에 코멘트 +``` + +자동 수정이 어려운 이슈(로직 변경 필요 등)는 이슈에 사유를 코멘트합니다. + ## Copyright Copyrightⓒ 2021- DAYO, All rights reserved. diff --git a/app/build.gradle b/app/build.gradle index 95bf3092e..0dc753281 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,8 +29,8 @@ android { applicationId "com.daily.dayo" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 21020 - versionName "2.1.2" + versionCode 22000 + versionName "2.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/presentation/src/androidTest/AndroidManifest.xml b/presentation/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..53a7bb10c --- /dev/null +++ b/presentation/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/presentation/src/androidTest/java/daily/dayo/presentation/screen/account/AuthPasswordFieldUsageTest.kt b/presentation/src/androidTest/java/daily/dayo/presentation/screen/account/AuthPasswordFieldUsageTest.kt new file mode 100644 index 000000000..1862d3a30 --- /dev/null +++ b/presentation/src/androidTest/java/daily/dayo/presentation/screen/account/AuthPasswordFieldUsageTest.kt @@ -0,0 +1,91 @@ +package daily.dayo.presentation.screen.account + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.test.ext.junit.runners.AndroidJUnit4 +import daily.dayo.presentation.theme.DayoTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AuthPasswordFieldUsageTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun assertContentDescriptionCount(contentDescription: String, expectedCount: Int) { + composeRule.onAllNodesWithContentDescription(contentDescription) + .assertCountEquals(expectedCount) + } + + @Test + fun signInEmailInputLayout_showsClearAndVisibilityOnEditablePasswordField() { + var email by mutableStateOf("") + var password by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + SignInEmailInputLayout( + emailValue = email, + onEmailChange = { email = it }, + passwordValue = password, + onPasswordChange = { password = it } + ) + } + } + + assertContentDescriptionCount("Clear password", 1) + assertContentDescriptionCount("Show password", 1) + } + + @Test + fun signUpPasswordConfirmLayout_hidesClearOnDisabledReferenceField() { + var password by mutableStateOf("password") + var passwordConfirmation by mutableStateOf("confirm") + + composeRule.setContent { + DayoTheme { + SetPasswordView( + passwordInputViewCondition = false, + passwordConfirmationViewCondition = true, + password = password, + setPassword = { password = it }, + isPasswordFormatValid = true, + passwordConfirmation = passwordConfirmation, + setPasswordConfirmation = { passwordConfirmation = it } + ) + } + } + + assertContentDescriptionCount("Clear password", 1) + assertContentDescriptionCount("Show password", 2) + } + + @Test + fun resetPasswordConfirmLayout_hidesClearOnDisabledReferenceField() { + var password by mutableStateOf("password") + var passwordConfirmation by mutableStateOf("confirm") + + composeRule.setContent { + DayoTheme { + NewPasswordLayout( + resetPasswordStep = ResetPasswordStep.NEW_PASSWORD_CONFIRM, + password = password, + setPassword = { password = it }, + isPasswordFormatValid = true, + passwordConfirmation = passwordConfirmation, + setPasswordConfirmation = { passwordConfirmation = it } + ) + } + } + + assertContentDescriptionCount("Clear password", 1) + assertContentDescriptionCount("Show password", 2) + } +} diff --git a/presentation/src/androidTest/java/daily/dayo/presentation/view/DayoPasswordTextFieldTest.kt b/presentation/src/androidTest/java/daily/dayo/presentation/view/DayoPasswordTextFieldTest.kt new file mode 100644 index 000000000..04bfe2c19 --- /dev/null +++ b/presentation/src/androidTest/java/daily/dayo/presentation/view/DayoPasswordTextFieldTest.kt @@ -0,0 +1,163 @@ +package daily.dayo.presentation.view + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import daily.dayo.presentation.theme.DayoTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DayoPasswordTextFieldTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun assertContentDescriptionCount(contentDescription: String, expectedCount: Int) { + composeRule.onAllNodesWithContentDescription(contentDescription) + .assertCountEquals(expectedCount) + } + + @Test + fun givenTextAndDefaultFlags_whenRendered_thenClearAndEyeIconsAreBothShown() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it } + ) + } + } + + assertContentDescriptionCount("Clear password", 1) + assertContentDescriptionCount("Show password", 1) + } + + @Test + fun givenText_whenClearIconTapped_thenFieldIsEmptied() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it } + ) + } + } + + composeRule.onNodeWithContentDescription("Clear password").performClick() + + composeRule.runOnIdle { + assertEquals("", passwordValue) + } + assertContentDescriptionCount("Clear password", 0) + assertContentDescriptionCount("Show password", 1) + } + + @Test + fun givenDefaultVisibilityIcon_whenTapped_thenContentDescriptionChangesToHidePassword() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it } + ) + } + } + + composeRule.onNodeWithContentDescription("Show password").performClick() + + assertContentDescriptionCount("Hide password", 1) + } + + @Test + fun givenErrorState_whenRendered_thenOnlyErrorIconIsShown() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it }, + isError = true, + errorMessage = "error" + ) + } + } + + assertContentDescriptionCount("error icon", 1) + assertContentDescriptionCount("Clear password", 0) + assertContentDescriptionCount("Show password", 0) + } + + @Test + fun givenVisibilityIconHiddenAndTextExists_whenRendered_thenOnlyClearIconIsShown() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it }, + showVisibilityIcon = false + ) + } + } + + assertContentDescriptionCount("Clear password", 1) + assertContentDescriptionCount("Show password", 0) + assertContentDescriptionCount("Hide password", 0) + } + + @Test + fun givenVisibilityIconHiddenAndTextBlank_whenRendered_thenNoTrailingIconsAreShown() { + var passwordValue by mutableStateOf("") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it }, + showVisibilityIcon = false + ) + } + } + + assertContentDescriptionCount("Clear password", 0) + assertContentDescriptionCount("Show password", 0) + assertContentDescriptionCount("Hide password", 0) + assertContentDescriptionCount("error icon", 0) + } + + @Test + fun givenDisabledFieldWithText_whenRendered_thenClearIsHiddenAndEyeRemains() { + var passwordValue by mutableStateOf("password") + + composeRule.setContent { + DayoTheme { + DayoPasswordTextField( + value = passwordValue, + onValueChange = { passwordValue = it }, + isEnabled = false + ) + } + } + + assertContentDescriptionCount("Clear password", 0) + assertContentDescriptionCount("Show password", 1) + } +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/AccountScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/AccountScreen.kt index 2f9f808ba..542c76e81 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/AccountScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/AccountScreen.kt @@ -3,6 +3,7 @@ package daily.dayo.presentation.screen.account import BottomSheetController import LocalBottomSheetController import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding @@ -35,7 +36,9 @@ internal fun AccountScreen( snackbarHost = { SnackbarHost( hostState = snackBarHostState, - modifier = Modifier.navigationBarsPadding() + modifier = Modifier + .navigationBarsPadding() + .imePadding() ) } ) { innerPadding -> @@ -75,4 +78,4 @@ internal fun AccountScreen( sealed class AccountScreen(val route: String) { object SignIn : AccountScreen(SignInRoute.route) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/ResetPasswordScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/ResetPasswordScreen.kt index 35bc2c242..ba239cdab 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/ResetPasswordScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/ResetPasswordScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -53,6 +55,7 @@ import daily.dayo.presentation.screen.account.model.EmailExistenceStatus import daily.dayo.presentation.theme.Dark import daily.dayo.presentation.theme.DayoTheme import daily.dayo.presentation.theme.Gray2_767B83 +import daily.dayo.presentation.theme.Gray3_9FA5AE import daily.dayo.presentation.theme.White_FFFFFF import daily.dayo.presentation.view.DayoPasswordTextField import daily.dayo.presentation.view.DayoTextButton @@ -276,6 +279,8 @@ fun ResetPasswordScreen( isCheckingEmail: Boolean = false, setIsCheckingEmail: (Boolean) -> Unit = {}, ) { + val contentScrollState = rememberScrollState() + Surface( modifier = Modifier .background(DayoTheme.colorScheme.background) @@ -333,9 +338,10 @@ fun ResetPasswordScreen( Column( modifier = Modifier .background(DayoTheme.colorScheme.background) + .weight(1f) .padding(horizontal = 20.dp, vertical = 0.dp) .fillMaxWidth() - .wrapContentSize() + .verticalScroll(contentScrollState) ) { // Title 영역 Spacer(modifier = Modifier.height(8.dp)) @@ -354,19 +360,21 @@ fun ResetPasswordScreen( AnimatedVisibility( visible = (resetPasswordStep.stepNum in 1..2), ) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - ) - Text( - text = if (resetPasswordStep == ResetPasswordStep.EMAIL_INPUT) - stringResource(R.string.reset_password_email_sub_title) - else if (resetPasswordStep == ResetPasswordStep.EMAIL_VERIFICATION) - stringResource(R.string.reset_password_new_password_sub_title) - else "", - style = DayoTheme.typography.b6.copy(color = Gray2_767B83), - ) + Column { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Text( + text = if (resetPasswordStep == ResetPasswordStep.EMAIL_INPUT) + stringResource(R.string.reset_password_email_sub_title) + else if (resetPasswordStep == ResetPasswordStep.EMAIL_VERIFICATION) + stringResource(R.string.reset_password_new_password_sub_title) + else "", + style = DayoTheme.typography.b6.copy(color = Gray2_767B83), + ) + } } // Contents 영역 @@ -432,7 +440,6 @@ fun ResetPasswordScreen( } } } - Spacer(modifier = Modifier.weight(1f)) ResetPasswordBottomLayout( resetPasswordStep = resetPasswordStep, onNextClick = { @@ -592,6 +599,14 @@ fun EmailInputLayout( requestEmailCertification: (String) -> Unit = {}, ) { val lastErrorMessage = remember { mutableStateOf("") } + val isEmailError = when { + email.isBlank() -> null + emailCertification == EmailCertificationState.INVALID_FORMAT || + emailCertification == EmailCertificationState.NOT_EXIST_EMAIL || + emailCertification == EmailCertificationState.OAUTH_EMAIL -> true + + else -> false + } LaunchedEffect(emailCertification) { lastErrorMessage.value = when (emailCertification) { @@ -612,19 +627,27 @@ fun EmailInputLayout( val formatValid = android.util.Patterns.EMAIL_ADDRESS.matcher(it).matches() setNextButtonEnabled(formatValid) setIsNextButtonClickable(formatValid) - if (!formatValid) { + if (it.isBlank()) { + setEmailCertification(EmailCertificationState.BEFORE_CERTIFICATION) + lastErrorMessage.value = "" + } else if (!formatValid) { setEmailCertification(EmailCertificationState.INVALID_FORMAT) } else { + setEmailCertification(EmailCertificationState.BEFORE_CERTIFICATION) lastErrorMessage.value = "" // INVALID FORMAT 에러 메시지가 다음 에러 메시지가 표시될 떄 남아 있지 않도록 value Clear } }, - label = stringResource(R.string.email), + label = if (email.isNotEmpty()) { + stringResource(R.string.email) + } else { + " " + }, placeholder = stringResource(R.string.reset_password_email_placeholder), trailingIconId = if (email.isNotBlank()) R.drawable.ic_trailing_check else null, errorTrailingIconId = R.drawable.ic_trailing_error, errorMessage = lastErrorMessage.value, - isError = if (email.isBlank()) null else !isNextButtonEnabled, + isError = isEmailError, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), ) } @@ -647,6 +670,9 @@ private fun EmailCertificationLayout( requestEmailCertification: (String) -> Unit = {}, ) { val certificateEmailAuthCodeFormat = Regex("^\\d{6}$") + val isServerCertificationCodeReady = + certificationCode != EMAIL_CERTIFICATE_AUTH_CODE_INITIAL.toString() && + certificationCode != RESET_PASSWORD_EMAIL_CERTIFICATE_AUTH_CODE_FAIL.toString() var tryCount by remember { mutableStateOf(1) } val isPaused = remember { mutableStateOf(false) } @@ -657,10 +683,12 @@ private fun EmailCertificationLayout( remember { mutableStateOf((R.string.reset_password_email_certification_fail_wrong)) } setNextButtonEnabled( - certificateEmailAuthCodeFormat.matches(certificationInputCode) + certificateEmailAuthCodeFormat.matches(certificationInputCode) && + isServerCertificationCodeReady ) setIsNextButtonClickable( - certificateEmailAuthCodeFormat.matches(certificationInputCode) + certificateEmailAuthCodeFormat.matches(certificationInputCode) && + isServerCertificationCodeReady ) key(tryCount) { @@ -678,6 +706,7 @@ private fun EmailCertificationLayout( isError = isEmailCertificateError ?: false, errorMessage = stringResource(timerErrorMessageRedId.value), timeOutErrorMessage = stringResource(R.string.reset_password_email_certification_fail_time_out), + labelColor = Gray3_9FA5AE, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), ) } @@ -712,6 +741,10 @@ private fun EmailCertificationLayout( text = stringResource(R.string.reset_password_email_certification_resend_button), onClick = { tryCount++ + setCertificationInputCode("") + timerErrorMessageRedId.value = + R.string.reset_password_email_certification_fail_wrong + setIsEmailCertificateError(false) requestEmailCertification(email) }, underline = true, @@ -725,7 +758,7 @@ private fun EmailCertificationLayout( @Composable @Preview -private fun NewPasswordLayout( +internal fun NewPasswordLayout( resetPasswordStep: ResetPasswordStep = ResetPasswordStep.NEW_PASSWORD_INPUT, isNextButtonEnabled: Boolean = false, setNextButtonEnabled: (Boolean) -> Unit = {}, @@ -808,4 +841,4 @@ enum class ResetPasswordStep(val stepNum: Int) { EMAIL_VERIFICATION(2), // 인증번호 입력 NEW_PASSWORD_INPUT(3), // 비밀번호 입력 NEW_PASSWORD_CONFIRM(4), // 비밀번호 재입력 -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailCertificationView.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailCertificationView.kt index a66b5bbc5..be0a2fe6e 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailCertificationView.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailCertificationView.kt @@ -25,6 +25,7 @@ import daily.dayo.presentation.screen.account.model.EmailCertificationState import daily.dayo.presentation.screen.account.model.SignUpStep import daily.dayo.presentation.theme.DayoTheme import daily.dayo.presentation.theme.Gray2_767B83 +import daily.dayo.presentation.theme.Gray3_9FA5AE import daily.dayo.presentation.view.DayoTextButton import daily.dayo.presentation.view.DayoTimerTextField import daily.dayo.presentation.viewmodel.AccountViewModel @@ -48,6 +49,9 @@ fun SetEmailCertificationView( requestEmailCertification: (String) -> Unit = {}, ) { val certificateEmailAuthCodeFormat = Regex("^\\d{6}$") + val isServerCertificationCodeReady = + certificationCode != AccountViewModel.EMAIL_CERTIFICATE_AUTH_CODE_INITIAL.toString() && + certificationCode != AccountViewModel.SIGN_UP_EMAIL_CERTIFICATE_AUTH_CODE_FAIL.toString() var tryCount by remember { mutableStateOf(1) } val isPaused = remember { mutableStateOf(false) } @@ -57,10 +61,14 @@ fun SetEmailCertificationView( val isTimeOut = remember { mutableStateOf(false) } setNextButtonEnabled( - certificateEmailAuthCodeFormat.matches(certificationInputCode) && !isTimeOut.value + certificateEmailAuthCodeFormat.matches(certificationInputCode) && + isServerCertificationCodeReady && + !isTimeOut.value ) setIsNextButtonClickable( - certificateEmailAuthCodeFormat.matches(certificationInputCode) && !isTimeOut.value + certificateEmailAuthCodeFormat.matches(certificationInputCode) && + isServerCertificationCodeReady && + !isTimeOut.value ) key(tryCount) { @@ -78,6 +86,7 @@ fun SetEmailCertificationView( isError = isEmailCertificateError ?: false, errorMessage = stringResource(timerErrorMessageRedId.value), timeOutErrorMessage = stringResource(R.string.sign_up_email_set_address_certification_fail_time_out), + labelColor = Gray3_9FA5AE, onTimeOut = { isTimeOut.value = true setNextButtonEnabled(false) @@ -117,7 +126,11 @@ fun SetEmailCertificationView( text = stringResource(R.string.sign_up_email_set_address_resend_button), onClick = { tryCount++ + setCertificationInputCode("") isTimeOut.value = false + timerErrorMessageRedId.value = + R.string.sign_up_email_set_address_certification_fail_wrong + setIsEmailCertificateError(false) requestEmailCertification(email) }, underline = true, @@ -125,4 +138,4 @@ fun SetEmailCertificationView( ) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailView.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailView.kt index 1c22c4714..28b35e108 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailView.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/SetEmailView.kt @@ -54,7 +54,11 @@ fun SetEmailView( requestEmailCertification(it) } }, - label = stringResource(R.string.email), + label = if (email.isNotEmpty()) { + stringResource(R.string.email) + } else { + " " + }, placeholder = stringResource(R.string.sign_up_email_set_address_placeholder), trailingIconId = if (email.isNotBlank()) R.drawable.ic_trailing_check else null, errorTrailingIconId = R.drawable.ic_trailing_error, diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/SignInEmailScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/SignInEmailScreen.kt index 4511ae24f..ba57c09d5 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/SignInEmailScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/SignInEmailScreen.kt @@ -3,6 +3,8 @@ package daily.dayo.presentation.screen.account import android.app.Activity import android.content.Intent import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -17,7 +19,6 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -109,8 +110,7 @@ internal fun SignInEmailRoute( email = email, password = password ) - }, - accountViewModel = accountViewModel + } ) Loading( @@ -128,11 +128,11 @@ fun SignInEmailScreen( onBackClick: () -> Unit = {}, onForgetPasswordClick: () -> Unit = {}, onSignUpClick: () -> Unit = {}, - onSignInClick: (email: String, password: String) -> Unit = { _, _ -> }, - accountViewModel: AccountViewModel = hiltViewModel() + onSignInClick: (email: String, password: String) -> Unit = { _, _ -> } ) { val emailState = remember { mutableStateOf("") } val passwordState = remember { mutableStateOf("") } + val contentScrollState = rememberScrollState() val isSignInButtonEnabled = remember(emailState.value, passwordState.value) { emailState.value.isNotBlank() && passwordState.value.isNotBlank() } @@ -146,9 +146,10 @@ fun SignInEmailScreen( SignInEmailActionbarLayout(onBackClick = onBackClick) Column( modifier = Modifier + .weight(1f) .padding(horizontal = 20.dp, vertical = 0.dp) .fillMaxWidth() - .wrapContentSize(), + .verticalScroll(contentScrollState), verticalArrangement = Arrangement.Top ) { Spacer( @@ -171,7 +172,6 @@ fun SignInEmailScreen( onSignInClick = { onSignInClick(emailState.value, passwordState.value) } ) } - Spacer(modifier = Modifier.weight(1f)) SignInEmailBottomLayout( onSignUpClick = onSignUpClick, onSignInClick = { onSignInClick(emailState.value, passwordState.value) }, @@ -226,7 +226,11 @@ fun SignInEmailInputLayout( ) { DayoTextField( modifier = Modifier.focusRequester(focusRequesterEmail), - label = stringResource(R.string.sign_in_email_input_email_title), + label = if (emailValue.isNotEmpty()) { + stringResource(R.string.sign_in_email_input_email_title) + } else { + " " + }, placeholder = stringResource(R.string.sign_in_email_input_email_title), value = emailValue, onValueChange = onEmailChange, @@ -242,7 +246,11 @@ fun SignInEmailInputLayout( ) DayoPasswordTextField( modifier = Modifier.focusRequester(focusRequesterPassword), - label = stringResource(R.string.sign_in_email_input_password_title), + label = if (passwordValue.isNotEmpty()) { + stringResource(R.string.sign_in_email_input_password_title) + } else { + " " + }, placeholder = stringResource(R.string.sign_in_email_input_password_placeholder), value = passwordValue, onValueChange = onPasswordChange, diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/SignUpEmailScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/SignUpEmailScreen.kt index c42bbbedc..11ec1a7b9 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/SignUpEmailScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/SignUpEmailScreen.kt @@ -7,9 +7,12 @@ import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -262,15 +265,17 @@ fun SignUpEmailTitleLayout( // SubTitle 영역 AnimatedVisibility(visible = subTitle.isNotBlank()) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - ) - Text( - text = subTitle, - style = DayoTheme.typography.b6.copy(color = Gray2_767B83), - ) + Column { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + Text( + text = subTitle, + style = DayoTheme.typography.b6.copy(color = Gray2_767B83), + ) + } } } @@ -564,6 +569,8 @@ fun SignUpEmailScaffold( onNextClick: () -> Unit = {}, content: @Composable (ColumnScope.() -> Unit), ) { + val contentScrollState = rememberScrollState() + BackHandler { onBackClick() } Scaffold( topBar = { @@ -592,6 +599,7 @@ fun SignUpEmailScaffold( ) } }, + windowInsets = WindowInsets(0, 0, 0, 0), ) } ) { innerPadding -> @@ -604,9 +612,10 @@ fun SignUpEmailScaffold( Column( modifier = Modifier .background(DayoTheme.colorScheme.background) + .weight(1f) .padding(horizontal = 20.dp, vertical = 0.dp) .fillMaxWidth() - .wrapContentSize() + .verticalScroll(contentScrollState) ) { if (signUpStep.stepNum <= SignUpStep.PASSWORD_CONFIRM.stepNum) { SignUpEmailTitleLayout(title = title, subTitle = subTitle) @@ -615,7 +624,6 @@ fun SignUpEmailScaffold( } if (signUpStep != SignUpStep.SIGNUP_COMPLETE) { - Spacer(modifier = Modifier.weight(1f)) SignUpEmailBottomLayout( signUpStep = signUpStep, onNextClick = { onNextClick() }, @@ -683,4 +691,4 @@ fun SignUpEmailNextButton( enabled = isSignUpButtonEnabled, ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/account/WithdrawScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/account/WithdrawScreen.kt index 657f1a90e..a5324dad0 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/account/WithdrawScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/account/WithdrawScreen.kt @@ -11,9 +11,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -453,33 +455,28 @@ fun WithdrawHoldBottomSheet( .fillMaxWidth() .wrapContentHeight() ) { - Column( - modifier = Modifier - .padding(bottom = 8.dp) - ) { - - Text( - text = stringResource(id = content.titleResId), - style = DayoTheme.typography.b1, - color = Dark, - ) + Text( + text = stringResource(id = content.titleResId), + style = DayoTheme.typography.b1, + color = Dark, + ) - val descriptionText = stringResource(id = content.descriptionResId) - if (descriptionText.isNotBlank()) { - Spacer( - modifier = Modifier.height( - if (isOtherReason) 2.dp else 4.dp - ) + val descriptionText = stringResource(id = content.descriptionResId) + if (descriptionText.isNotBlank()) { + Spacer( + modifier = Modifier.height( + if (isOtherReason) 2.dp else 4.dp ) - Text( - text = descriptionText, - style = DayoTheme.typography.caption2.copy( - color = Gray2_767B83, - fontWeight = FontWeight.Medium - ) + ) + Text( + text = descriptionText, + style = DayoTheme.typography.caption2.copy( + color = Gray2_767B83, + fontWeight = FontWeight.Medium ) - } + ) } + Spacer( modifier = Modifier.height( if (isOtherReason || hasWithdrawReasonGuide) 8.dp @@ -551,7 +548,7 @@ fun WithdrawHoldBottomSheet( label = stringResource(id = content.cancelButtonTextResId), modifier = Modifier .weight(1f) - .height(52.dp), + .defaultMinSize(minHeight = 52.dp), color = ButtonDefaults.buttonColors( containerColor = PrimaryL3_F2FBF7, contentColor = Primary_23C882 @@ -567,7 +564,7 @@ fun WithdrawHoldBottomSheet( label = stringResource(id = content.confirmButtonTextResId), modifier = Modifier .weight(1f) - .height(52.dp), + .defaultMinSize(minHeight = 52.dp), enabled = !(reason == WithdrawalReason.OTHER && otherReasonText.isBlank()), color = ButtonDefaults.buttonColors( containerColor = Primary_23C882, @@ -620,11 +617,11 @@ fun WithdrawConfirmScreen( fontWeight = FontWeight.SemiBold ), ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(32.dp)) withdrawCheckLists.forEachIndexed { index, text -> WithdrawConfirmCheckItems(checkText = text) if (index != withdrawCheckLists.lastIndex) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) } } } @@ -669,7 +666,7 @@ fun WithdrawButton( FilledRoundedCornerButton( modifier = Modifier .fillMaxWidth() - .height(52.dp), + .defaultMinSize(minHeight = 52.dp), label = stringResource(R.string.withdraw_confirm), color = ButtonDefaults.buttonColors( containerColor = Primary_23C882, @@ -690,11 +687,13 @@ fun WithdrawConfirmCheckItems( Row( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically ) { Icon( painter = painterResource(id = R.drawable.ic_check), contentDescription = null, + modifier = Modifier.size(20.dp), tint = Primary_23C882, ) Spacer(modifier = Modifier.width(4.dp)) @@ -814,30 +813,40 @@ private fun WithdrawGuideContentUI( Spacer(modifier = Modifier.height(20.dp)) val guideStrings = words.ifEmpty { emptyList() } - Row( + FlowRow( modifier = Modifier .padding(bottom = 16.dp) - .wrapContentHeight() .fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.Center ) { guideStrings.forEachIndexed { index, guide -> Text( text = guide, + modifier = Modifier.align(Alignment.CenterVertically), color = Gray1_50545B, textAlign = TextAlign.Center, - style = DayoTheme.typography.caption4 + style = DayoTheme.typography.caption4, ) if (index != guideStrings.lastIndex) { - Spacer(modifier = Modifier.width(6.dp)) + Spacer( + modifier = Modifier + .width(6.dp) + .align(Alignment.CenterVertically) + ) Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_right), contentDescription = null, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically), tint = Gray3_9FA5AE, - modifier = Modifier.size(12.dp) ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer( + modifier = Modifier + .width(6.dp) + .align(Alignment.CenterVertically) + ) } } } @@ -925,4 +934,4 @@ data class WithdrawRetentionSheetContent( enum class WithdrawStep(val stepNum: Int) { REASON_SELECT(0), CONFIRM(1), -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/bookmark/BookmarkScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/bookmark/BookmarkScreen.kt index 57079a5f4..bfbcdddb7 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/bookmark/BookmarkScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/bookmark/BookmarkScreen.kt @@ -50,6 +50,7 @@ import java.text.DecimalFormat @Composable fun BookmarkScreen( + onPostClick: (Long) -> Unit, onBackClick: () -> Unit, bookmarkViewModel: BookmarkViewModel = hiltViewModel() ) { @@ -105,7 +106,8 @@ fun BookmarkScreen( post = post, isEditMode = bookmarkUiState.isEditMode, isSelected = bookmarkUiState.selectedBookmarks.contains(post.postId), - onBookmarkClick = { bookmarkViewModel.toggleSelection(post.postId) } + onBookmarkPostClick = { onPostClick(post.postId) }, + onBookmarkEditClick = { bookmarkViewModel.toggleSelection(post.postId) } ) } } @@ -222,7 +224,8 @@ private fun BookmarkPostItem( post: BookmarkPost, isEditMode: Boolean, isSelected: Boolean, - onBookmarkClick: () -> Unit + onBookmarkPostClick: (BookmarkPost) -> Unit, + onBookmarkEditClick: () -> Unit ) { Box { RoundImageView( @@ -232,12 +235,19 @@ private fun BookmarkPostItem( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) + .clickableSingle(onClick = { + if (isEditMode) { + onBookmarkEditClick() + } else { + onBookmarkPostClick(post) + } + }) ) if (isEditMode) { DayoCheckbox( checked = isSelected, - onCheckedChange = { onBookmarkClick() }, + onCheckedChange = { onBookmarkEditClick() }, modifier = Modifier.align(Alignment.TopEnd) ) } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/feed/FeedScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/feed/FeedScreen.kt index b2ce5c71f..292abf8b2 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/feed/FeedScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/feed/FeedScreen.kt @@ -203,6 +203,12 @@ private fun FeedEmptyView(onEmptyViewClick: () -> Unit) { Text(text = stringResource(id = R.string.feed_empty_description), style = DayoTheme.typography.caption1.copy(Gray4_C5CAD2)) Spacer(modifier = Modifier.height(36.dp)) - FilledButton(onClick = onEmptyViewClick, label = stringResource(id = R.string.feed_empty_button)) + FilledButton( + onClick = onEmptyViewClick, + label = stringResource(id = R.string.feed_empty_button), + modifier = Modifier.height(44.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 11.5.dp), + textStyle = DayoTheme.typography.b5 + ) } } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderCreateScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderCreateScreen.kt index 2a407133d..335ed7f70 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderCreateScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderCreateScreen.kt @@ -107,7 +107,7 @@ private fun FolderCreateScreen( .background(DayoTheme.colorScheme.background) .fillMaxSize() .padding(contentPadding) - .padding(horizontal = 18.dp, vertical = 16.dp), + .padding(horizontal = 20.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { DayoTextField( @@ -143,7 +143,10 @@ private fun FolderCreateScreen( ToggleButtonWithLabel( label = stringResource(R.string.write_post_folder_new_folder_privacy_title), isToggled = privacy.value == Privacy.ONLY_ME, - onToggleChanged = { privacy.value = if (it) Privacy.ONLY_ME else Privacy.ALL } + onToggleChanged = { privacy.value = if (it) Privacy.ONLY_ME else Privacy.ALL }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.End), ) } } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderScreen.kt index f95aa8685..68d617180 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/folder/FolderScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -410,7 +411,8 @@ private fun FolderDropdownMenu( onDismissRequest = { expanded.value = false }, modifier = Modifier .background(DayoTheme.colorScheme.background) - .width(140.dp) + .width(140.dp), + shape = RoundedCornerShape(16.dp) ) { menuItems.forEach { DropdownMenuItem( @@ -436,6 +438,9 @@ private fun FolderDropdownMenu( it.onClickMenu() expanded.value = false }, + modifier = Modifier + .padding(horizontal = 4.dp) + .clip(RoundedCornerShape(12.dp)), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 11.5.dp) ) } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/home/HomeDayoPickScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/home/HomeDayoPickScreen.kt index 85dd45d6c..207965f25 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/home/HomeDayoPickScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/home/HomeDayoPickScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells @@ -25,6 +26,7 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -181,21 +183,41 @@ private fun HomeDayoPickEmptyView() { } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun CategoryButton( selectedCategory: String, onCategoryClick: () -> Unit ) { + Button( onClick = onCategoryClick, shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(top = 6.dp, bottom = 6.dp, start = 12.dp, end = 4.dp), - colors = androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = White_FFFFFF, contentColor = Gray2_767B83), - modifier = Modifier.border(1.dp, Gray6_F0F1F3, shape = RoundedCornerShape(8.dp)) + contentPadding = PaddingValues(start = 12.dp, end = 4.dp), + modifier = Modifier + .border(1.dp, Gray6_F0F1F3, shape = RoundedCornerShape(8.dp)) + .height(36.dp), + colors = ButtonDefaults.buttonColors( + containerColor = White_FFFFFF, + contentColor = Gray2_767B83 + ) ) { - Text(text = selectedCategory, style = DayoTheme.typography.caption3) - Spacer(modifier = Modifier.width(8.dp)) - Icon(Icons.Filled.ArrowDropDown, "category menu") + Row( + modifier = Modifier.height(36.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedCategory, + style = DayoTheme.typography.caption3 + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } } + } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/main/MainScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/main/MainScreen.kt index 6b6f7ed0e..05040e924 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/main/MainScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/main/MainScreen.kt @@ -112,10 +112,9 @@ internal fun MainScreen( SharedTransitionLayout { Scaffold( snackbarHost = { - SnackbarHost( - hostState = snackBarHostState, - modifier = Modifier.navigationBarsPadding() - ) + if (!bottomSheetController.isVisible) { + SnackbarHost(hostState = snackBarHostState) + } } ) { Box { @@ -293,9 +292,16 @@ internal fun MainScreen( onDismissRequest = { bottomSheetController.hide() }, modifier = Modifier.navigationBarsPadding(), sheetState = bottomSheetState, + sheetGesturesEnabled = false, dragHandle = null ) { - bottomSheetController.sheetContent() + Box { + bottomSheetController.sheetContent() + SnackbarHost( + hostState = snackBarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } } } } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MyPageScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MyPageScreen.kt index d11514c0c..3716b7f37 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MyPageScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MyPageScreen.kt @@ -299,6 +299,7 @@ private fun MyPageMenu( Icon( painter = painterResource(R.drawable.ic_bookmark_default), contentDescription = stringResource(id = R.string.bookmark), + modifier = Modifier.size(20.dp), tint = Gray1_50545B ) } @@ -339,12 +340,13 @@ private fun MyPageDiaryHeader( ) { Icon( imageVector = Icons.Filled.Add, - contentDescription = stringResource(id = R.string.my_profile_new_folder) + contentDescription = stringResource(id = R.string.my_profile_new_folder), + modifier = Modifier.size(12.dp) ) Spacer(modifier = Modifier.width(4.dp)) Text( text = stringResource(id = R.string.my_profile_new_folder), - style = DayoTheme.typography.b6.copy( + style = DayoTheme.typography.caption4.copy( if (isCreateFolderEnabled) Primary_23C882 else Gray4_C5CAD2 ) ) @@ -372,7 +374,7 @@ private fun MyPageTopNavigation(onSettingsClick: () -> Unit) { Text( text = stringResource(id = R.string.my_page), style = DayoTheme.typography.h1.copy( - color = Gray1_50545B, + color = Dark, fontWeight = FontWeight.SemiBold ) ) @@ -383,9 +385,10 @@ private fun MyPageTopNavigation(onSettingsClick: () -> Unit) { onClick = onSettingsClick, iconContentDescription = "setting button", iconPainter = painterResource(id = R.drawable.ic_setting), + iconButtonModifier = Modifier.padding(end = 8.dp), iconModifier = Modifier - .padding(end = 18.dp) - .size(24.dp), + .size(44.dp) + .padding(10.dp), iconTintColor = Dark ) } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MypageNavigation.kt b/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MypageNavigation.kt index 130a9208b..04d9134e4 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MypageNavigation.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/mypage/MypageNavigation.kt @@ -125,6 +125,7 @@ fun NavGraphBuilder.myPageNavGraph( composable(MyPageRoute.bookmark()) { BookmarkScreen( + onPostClick = onPostClick, onBackClick = onBackClick ) } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/post/PostScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/post/PostScreen.kt index 18468f4df..50fd3aa31 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/post/PostScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/post/PostScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateListOf @@ -99,19 +98,17 @@ fun PostScreen( val commentState = postViewModel.postComments.observeAsState() val commentText = remember { mutableStateOf(TextFieldValue("")) } val showMentionSearchView = remember { mutableStateOf(false) } - val commentFocusRequester = FocusRequester() + val commentFocusRequester = remember { FocusRequester() } // comment option val onClickCommentDelete: (Long) -> Unit = { commentId -> postViewModel.requestDeletePostComment(commentId) } val postCommentDeleteSuccess by postViewModel.postCommentDeleteSuccess.observeAsState(Event(false)) - if (postCommentDeleteSuccess.getContentIfNotHandled() == true) { - postViewModel.requestPostComment(postId) - SideEffect { - coroutineScope.launch { - snackBarHostState.showSnackbar(context.getString(R.string.comment_delete_message)) - } + LaunchedEffect(postCommentDeleteSuccess) { + if (postCommentDeleteSuccess.getContentIfNotHandled() == true) { + postViewModel.requestPostComment(postId) + snackBarHostState.showSnackbar(context.getString(R.string.comment_delete_message)) } } var showReportDialog by remember { mutableStateOf(false) } @@ -180,11 +177,30 @@ fun PostScreen( val onClickCancelReply: () -> Unit = { clearComment() } - val postCommentCreateSuccess by postViewModel.postCommentCreateSuccess.observeAsState(Event(false)) - if (postCommentCreateSuccess.getContentIfNotHandled() == true) { - clearComment() - keyboardController?.hide() - postViewModel.requestPostComment(postId) + + val postCommentCreateState by postViewModel.postCommentCreateState.observeAsState() + LaunchedEffect(postCommentCreateState) { + postCommentCreateState?.status?.let { state -> + when (state) { + Status.LOADING -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.loading_default_message)) + } + } + + Status.SUCCESS -> { + clearComment() + keyboardController?.hide() + postViewModel.requestPostComment(postId) + } + + Status.ERROR -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.network_error_dialog_default_message)) + } + } + } + } } BackHandler(enabled = loadingVisible) {} @@ -454,7 +470,7 @@ private fun PreviewPostScreen() { userSearchKeyword = userSearchKeyword, showMentionSearchView = showMentionSearchView, userResults = userResults, - commentFocusRequester = FocusRequester(), + commentFocusRequester = remember { FocusRequester() }, onClickPostComment = { }, onClickProfile = { }, onClickPost = { }, @@ -474,4 +490,3 @@ private fun PreviewPostScreen() { ) } } - diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/search/SearchResultScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/search/SearchResultScreen.kt index 0f73c6055..24940a43f 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/search/SearchResultScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/search/SearchResultScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -86,7 +85,9 @@ import daily.dayo.presentation.theme.DayoTheme import daily.dayo.presentation.theme.Gray1_50545B import daily.dayo.presentation.theme.Gray2_767B83 import daily.dayo.presentation.theme.Gray3_9FA5AE +import daily.dayo.presentation.theme.Gray4_C5CAD2 import daily.dayo.presentation.theme.Gray5_E8EAEE +import daily.dayo.presentation.theme.Gray6_F0F1F3 import daily.dayo.presentation.theme.PrimaryL3_F2FBF7 import daily.dayo.presentation.theme.Primary_23C882 import daily.dayo.presentation.theme.White_FFFFFF @@ -231,7 +232,7 @@ fun SearchResultScreen( color = Primary_23C882, ) }, - divider = { Divider(color = Color.Transparent, thickness = 0.dp) }, + divider = { Divider(color = Gray6_F0F1F3, thickness = 1.dp) }, modifier = Modifier .fillMaxWidth() .padding(18.dp, 0.dp, 18.dp, 0.dp), @@ -335,14 +336,12 @@ fun SearchResultEmpty() { textAlign = TextAlign.Center ), ) + Spacer(modifier = Modifier.height(2.dp)) Text( - modifier = Modifier.padding(vertical = 2.dp), text = stringResource(id = R.string.search_result_empty_description), - style = DayoTheme.typography.caption1 - .copy( - color = Gray3_9FA5AE, - textAlign = TextAlign.Center - ), + color = Gray4_C5CAD2, + fontWeight = FontWeight(500), + style = DayoTheme.typography.caption2, ) } } @@ -351,33 +350,38 @@ fun SearchResultEmpty() { @Preview fun SearchResultsCount(resultCount: Int = 0) { Surface( - color = White_FFFFFF, modifier = Modifier .fillMaxWidth() - .height(44.dp), + .height(44.dp) + .background(White_FFFFFF) + .padding(horizontal = 18.dp, vertical = 12.dp) ) { Row( - horizontalArrangement = Arrangement.spacedBy(0.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp) + modifier = Modifier + .height(20.dp) + .background(White_FFFFFF) ) { Text( + text = "$resultCount", style = TextStyle( fontSize = 13.sp, + lineHeight = 19.5.sp, fontFamily = FontFamily(Font(R.font.pretendard_medium)), fontWeight = FontWeight(500), color = Primary_23C882 - ), - text = "$resultCount", - modifier = Modifier.padding(end = 2.dp) + ) ) Text( + text = stringResource(R.string.search_result_count_description), style = TextStyle( fontSize = 13.sp, + lineHeight = 19.5.sp, fontFamily = FontFamily(Font(R.font.pretendard_medium)), - fontWeight = FontWeight(500) - ), - text = "개의 검색결과" + fontWeight = FontWeight(500), + color = Gray2_767B83, + ) ) } } diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/settings/BlockedUsersScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/settings/BlockedUsersScreen.kt index 3869a2055..465f48788 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/settings/BlockedUsersScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/settings/BlockedUsersScreen.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -49,6 +50,7 @@ import daily.dayo.presentation.view.FilledRoundedCornerButton import daily.dayo.presentation.view.NoRippleIconButton import daily.dayo.presentation.view.RoundImageView import daily.dayo.presentation.view.TopNavigation +import daily.dayo.presentation.view.TopNavigationAlign import daily.dayo.presentation.viewmodel.ProfileSettingViewModel import daily.dayo.presentation.viewmodel.ProfileViewModel import kotlinx.coroutines.launch @@ -96,19 +98,25 @@ fun BlockedUsersScreen( topBar = { BlockedUsersActionbarLayout(onBackClick = onBackClick) }, snackbarHost = { SnackbarHost(snackBarHostState) }, content = { innerPadding -> - Column( + Box( modifier = Modifier .background(DayoTheme.colorScheme.background) .padding(innerPadding) .fillMaxSize() - .padding(top = 12.dp, start = 20.dp, end = 20.dp) + .padding(top = 12.dp) ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(vertical = 16.dp) - ) { - if (blockedUsers.status != Status.ERROR) { + if (blockedUsers.status == Status.ERROR) { + BlockedUsersErrorLayout( + onRetry = { profileSettingViewModel.requestBlockList() }, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(start = 20.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { blockedUsers.data.orEmpty().let { blockedUsers -> if (blockedUsers.isEmpty()) { item { @@ -159,41 +167,6 @@ fun BlockedUsersScreen( } } } - } else { - item { - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 164.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Image( - painter = painterResource(id = R.drawable.ic_blocked_users_empty), - contentDescription = null, - modifier = Modifier - .width(136.dp) - .wrapContentHeight() - .padding(6.5.dp) - ) - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = stringResource(R.string.blocked_users_error_description), - color = Gray3_9FA5AE, - style = DayoTheme.typography.b3, - modifier = Modifier - .wrapContentSize() - ) - Spacer(modifier = Modifier.height(20.dp)) - FilledRoundedCornerButton( - modifier = Modifier - .padding(horizontal = 20.dp) - .wrapContentSize(), - onClick = { profileSettingViewModel.requestBlockList() }, - label = stringResource(R.string.re_try) - ) - } - } } } } @@ -201,6 +174,50 @@ fun BlockedUsersScreen( ) } +@Composable +private fun BlockedUsersErrorLayout( + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(176f)) + + Column( + modifier = Modifier.wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.ic_blocked_users_empty), + contentDescription = null, + modifier = Modifier + .width(136.dp) + .height(100.dp) + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.blocked_users_error_description), + style = DayoTheme.typography.h3.copy(color = Gray3_9FA5AE), + modifier = Modifier.wrapContentSize() + ) + } + + Spacer(modifier = Modifier.weight(336f)) + + FilledRoundedCornerButton( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .padding(bottom = 20.dp), + onClick = onRetry, + label = stringResource(R.string.re_try), + ) + } +} + @Preview @Composable fun BlockedUser( @@ -261,5 +278,6 @@ fun BlockedUsersActionbarLayout( ) }, title = stringResource(R.string.blocked_users_title), + titleAlignment = TopNavigationAlign.CENTER, ) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/settings/ChangePasswordScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/settings/ChangePasswordScreen.kt index ede8c5adc..3963f12c3 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/settings/ChangePasswordScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/settings/ChangePasswordScreen.kt @@ -3,6 +3,8 @@ package daily.dayo.presentation.screen.settings import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer @@ -12,7 +14,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -225,6 +226,8 @@ fun ChangePasswordScaffold( onNextClick: () -> Unit = {}, content: @Composable (ColumnScope.() -> Unit) = {}, ) { + val contentScrollState = rememberScrollState() + BackHandler { onBackClick() } Scaffold( topBar = { @@ -242,15 +245,15 @@ fun ChangePasswordScaffold( Column( modifier = Modifier .background(DayoTheme.colorScheme.background) + .weight(1f) .padding(horizontal = 18.dp, vertical = 0.dp) .fillMaxWidth() - .wrapContentSize() + .verticalScroll(contentScrollState) ) { ChangePasswordTitleLayout(title = title) content() } - Spacer(modifier = Modifier.weight(1f)) ChangePasswordBottomLayout( onNextClick = { onNextClick() }, isChangePasswordButtonEnabled = isNextButtonEnabled, @@ -341,4 +344,4 @@ enum class ChangePasswordStep(val stepNum: Int) { CUR_PASSWORD_INPUT(1), // 현재 비밀번호 입력 NEW_PASSWORD_INPUT(2), // 비밀번호 입력 NEW_PASSWORD_CONFIRM(3), // 비밀번호 재입력 -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/settings/SettingsScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/settings/SettingsScreen.kt index 808893d50..6dee5b312 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/settings/SettingsScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/settings/SettingsScreen.kt @@ -42,8 +42,10 @@ import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import daily.dayo.domain.model.Profile @@ -273,14 +275,16 @@ private fun SettingProfile( Text( text = profile?.nickname ?: "", color = Dark, - style = DayoTheme.typography.b2 + textAlign = TextAlign.Center, + style = DayoTheme.typography.b1 ) // email Text( text = profile?.email ?: "", color = Gray3_9FA5AE, - style = DayoTheme.typography.b6 + textAlign = TextAlign.Center, + style = DayoTheme.typography.b6.copy(lineHeight = 21.sp) ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteFolderScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteFolderScreen.kt index ab809b720..f72d9b22b 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteFolderScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteFolderScreen.kt @@ -51,6 +51,7 @@ import daily.dayo.presentation.common.extension.clickableSingle import daily.dayo.presentation.common.extension.limitTo import daily.dayo.presentation.theme.Dark import daily.dayo.presentation.theme.DayoTheme +import daily.dayo.presentation.theme.Gray1_50545B import daily.dayo.presentation.theme.Gray2_767B83 import daily.dayo.presentation.theme.Gray3_9FA5AE import daily.dayo.presentation.theme.Primary_23C882 @@ -264,6 +265,11 @@ fun WriteFolderItemLayout( isSelected: Boolean = true, onFolderClick: (Long, String) -> Unit = { _, _ -> }, ) { + val thumbnailModel: Any = folder.thumbnailImage + .takeIf { it.isNotBlank() } + ?.let { "${BuildConfig.BASE_URL}/images/$it" } + ?: R.drawable.img_default_folder_dayo_logo + Row( modifier = Modifier .fillMaxWidth() @@ -278,10 +284,11 @@ fun WriteFolderItemLayout( ) { RoundImageView( context = LocalContext.current, - imageUrl = "${BuildConfig.BASE_URL}/images/${folder.thumbnailImage}", + imageUrl = thumbnailModel, modifier = Modifier.size(FOLDER_THUMBNAIL_SIZE.dp), imageSize = Size(FOLDER_THUMBNAIL_SIZE, FOLDER_THUMBNAIL_SIZE), roundSize = FOLDER_THUMBNAIL_RADIUS_SIZE.dp, + placeholderResId = R.drawable.img_default_folder_dayo_logo, ) if (isSelected) { Box( @@ -290,17 +297,17 @@ fun WriteFolderItemLayout( .clip(RoundedCornerShape(size = FOLDER_THUMBNAIL_RADIUS_SIZE.dp)) .background(Primary_23C882.copy(alpha = 0.6f)) ) { - Image( - painter = painterResource(id = R.drawable.ic_check), - contentDescription = "Selected", - contentScale = ContentScale.Crop, - colorFilter = ColorFilter.tint(White_FFFFFF), - modifier = Modifier - .size(18.dp) - .align(Alignment.Center) - ) - } - } + Image( + painter = painterResource(id = R.drawable.ic_check_corner_round), + contentDescription = "Selected", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(White_FFFFFF), + modifier = Modifier + .align(Alignment.Center) + .size(18.dp) + ) + } + } } Spacer(modifier = Modifier.width(12.dp)) Column( @@ -308,24 +315,20 @@ fun WriteFolderItemLayout( .fillMaxHeight() .wrapContentHeight(Alignment.CenterVertically) ) { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { if (folder.privacy == Privacy.ONLY_ME) { Image( modifier = Modifier - .height(24.dp) - .wrapContentHeight(Alignment.CenterVertically), + .size(16.dp), painter = painterResource(id = R.drawable.ic_lock), contentDescription = "Only Me", - colorFilter = ColorFilter.tint(Dark) + colorFilter = ColorFilter.tint(Gray1_50545B) ) Spacer(modifier = Modifier.width(4.dp)) } Text( modifier = Modifier - .fillMaxWidth() - .height(24.dp) - .wrapContentWidth(Alignment.Start) - .wrapContentHeight(Alignment.CenterVertically), + .fillMaxWidth(), text = folder.title, style = DayoTheme.typography.b4.copy( color = Dark, diff --git a/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteScreen.kt b/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteScreen.kt index 693aa1fe2..ce30ca565 100644 --- a/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteScreen.kt +++ b/presentation/src/main/java/daily/dayo/presentation/screen/write/WriteScreen.kt @@ -25,13 +25,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.Surface +import androidx.compose.material3.Icon import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState @@ -64,6 +65,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -99,6 +101,8 @@ const val WRITE_POST_DETAIL_MAX_LENGTH = 200 const val WRITE_POST_IMAGE_SIZE = 220 const val WRITE_POST_TOP_Z_INDEX = 1f +private val WriteSummaryLabelSpacing = 12.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WriteRoute( @@ -445,26 +449,33 @@ fun WriteUploadImages( ) { Row( modifier = Modifier - .width(112.dp) + .width(116.dp) .height(36.dp) .clickable { onEditImage(index) } - .padding(horizontal = 12.dp) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Image( - painter = painterResource(id = R.drawable.ic_crop), - contentDescription = "edit image", - modifier = Modifier - .align(Alignment.CenterVertically) - .size(20.dp) + Icon( + painter = painterResource(id = R.drawable.ic_plus_green), + contentDescription = stringResource(R.string.write_post_image_edit), + tint = White_FFFFFF, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(4.dp)) Text( text = stringResource(R.string.write_post_image_edit), style = DayoTheme.typography.b5, color = White_FFFFFF, - modifier = Modifier.align(Alignment.CenterVertically) + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = 14.sp, + stepSize = 0.25.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -678,13 +689,9 @@ fun WriteTagLayout( style = DayoTheme.typography.b3, color = Dark ) - Spacer( - modifier = Modifier - .weight(1f) - .widthIn(min = 54.dp) - ) + Spacer(modifier = Modifier.width(WriteSummaryLabelSpacing)) if (tags.isNotEmpty()) { - val tag = tags.joinToString(separator = ", ", postfix = " ") { + val tag = tags.joinToString(separator = ", ") { ContextCompat.getString(context, R.string.write_post_select_tag_contents).format(it) } Text( @@ -747,11 +754,7 @@ fun WriteFolderLayout( style = DayoTheme.typography.b3, color = Dark ) - Spacer( - modifier = Modifier - .weight(1f) - .widthIn(min = 54.dp) - ) + Spacer(modifier = Modifier.width(WriteSummaryLabelSpacing)) if (!folderName.isNullOrEmpty()) { Text( modifier = Modifier @@ -787,4 +790,4 @@ fun WriteFolderLayout( contentDescription = stringResource(R.string.write_post_select_folder_title) ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/Button.kt b/presentation/src/main/java/daily/dayo/presentation/view/Button.kt index f13fa2a10..4d7df7139 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/Button.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/Button.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -31,8 +32,10 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import daily.dayo.presentation.theme.Dark import daily.dayo.presentation.theme.DayoTheme import daily.dayo.presentation.theme.Gray2_767B83 @@ -50,7 +53,9 @@ fun FilledButton( modifier: Modifier = Modifier, enabled: Boolean = true, isTonal: Boolean = false, - icon: @Composable (() -> Unit)? = null + icon: @Composable (() -> Unit)? = null, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + textStyle: TextStyle = DayoTheme.typography.b6 ) { val buttonColors = if (isTonal) ButtonDefaults.buttonColors( @@ -73,10 +78,10 @@ fun FilledButton( modifier = modifier, enabled = enabled, colors = buttonColors, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + contentPadding = contentPadding, content = { if (icon != null) icon() - Text(text = label, style = DayoTheme.typography.b6) + Text(text = label, style = textStyle) } ) } @@ -116,7 +121,14 @@ fun FilledRoundedCornerButton( text = label, textAlign = TextAlign.Center, style = textStyle, - modifier = contentModifier ?: Modifier.fillMaxWidth() + modifier = contentModifier ?: Modifier.fillMaxWidth(), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize + ) ) } }, @@ -261,4 +273,4 @@ private fun PreviewDayoTextButton() { Text(text = "입니다.", style = DayoTheme.typography.caption3.copy(Gray4_C5CAD2)) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/Comment.kt b/presentation/src/main/java/daily/dayo/presentation/view/Comment.kt index 98766de62..ee72340c7 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/Comment.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/Comment.kt @@ -5,31 +5,25 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.TextFieldDefaults.TextFieldDecorationBox -import androidx.compose.material.TextFieldDefaults.textFieldColors import androidx.compose.material3.Icon import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -55,7 +49,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview @@ -98,30 +91,35 @@ fun CommentListView( if (postComments.isEmpty()) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, modifier = Modifier .background(DayoTheme.colorScheme.background) .fillMaxSize() - .padding(top = 12.dp, bottom = 30.dp) .then(modifier) ) { - if (showEmptyIcon) { - Icon( - painter = painterResource(id = R.drawable.ic_comment_empty), - contentDescription = "empty", - tint = Color.Unspecified + Spacer(modifier = Modifier.weight(64f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (showEmptyIcon) { + Icon( + painter = painterResource(id = R.drawable.ic_comment_empty), + contentDescription = "empty", + tint = Color.Unspecified + ) + } + + Text( + text = stringResource(id = R.string.post_comment_empty), + style = DayoTheme.typography.b5.copy(Gray2_767B83), + modifier = Modifier.padding(top = 12.dp, bottom = 2.dp) + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(id = R.string.post_comment_empty_description), + style = DayoTheme.typography.caption4.copy(Gray3_9FA5AE) ) } - Text( - text = stringResource(id = R.string.post_comment_empty), - style = DayoTheme.typography.b3.copy(Gray3_9FA5AE), - modifier = Modifier.padding(top = 12.dp, bottom = 2.dp) - ) - Text( - text = stringResource(id = R.string.post_comment_empty_description), - style = DayoTheme.typography.caption1.copy(Gray4_C5CAD2) - ) + Spacer(modifier = Modifier.weight(135f)) } } else { Column( @@ -328,20 +326,24 @@ fun CommentMentionSearchView(userResults: LazyPagingItems, onClickFo modifier = Modifier .background(DayoTheme.colorScheme.background) .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 18.dp) + contentPadding = PaddingValues(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(userResults.itemCount) { index -> userResults[index]?.let { user -> + val interactionSource = remember { MutableInteractionSource() } + val isPressed = interactionSource.collectIsPressedAsState().value Row( modifier = Modifier - .background(DayoTheme.colorScheme.background) .fillMaxWidth() - .padding(vertical = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (isPressed) Gray7_F6F6F7 else DayoTheme.colorScheme.background) .clickableSingle( - indication = ripple(bounded = false, radius = 8.dp, color = Gray7_F6F6F7), - interactionSource = remember { MutableInteractionSource() }, + indication = null, + interactionSource = interactionSource, onClick = { onClickFollowUser(user) } - ), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { RoundImageView( @@ -349,12 +351,7 @@ fun CommentMentionSearchView(userResults: LazyPagingItems, onClickFo context = LocalContext.current, modifier = Modifier .clip(CircleShape) - .size(24.dp) - .clickableSingle( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { } - ), + .size(24.dp), imageDescription = "search users profile image", ) Spacer(modifier = Modifier.width(12.dp)) @@ -395,7 +392,6 @@ fun CommentReplyDescriptionView(replyCommentState: MutableState - TextFieldDecorationBox( - value = commentText.value.text, - innerTextField = innerTextField, - enabled = true, - singleLine = false, - visualTransformation = VisualTransformation.None, - interactionSource = interactionSource, - placeholder = { Text(text = "댓글을 남겨주세요", style = DayoTheme.typography.b6.copy(Gray4_C5CAD2)) }, - shape = DayoTheme.shapes.small.copy(all = CornerSize(12.dp)), - colors = textFieldColors(backgroundColor = Gray7_F6F6F7), - contentPadding = TextFieldDefaults.textFieldWithLabelPadding(top = 8.dp, bottom = 8.dp, start = 12.dp) - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Gray7_F6F6F7, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + if (commentText.value.text.isEmpty()) { + Text( + text = "댓글을 남겨주세요", + style = DayoTheme.typography.b6.copy(Gray4_C5CAD2) + ) + } + innerTextField() + } } ) + Spacer(modifier = Modifier.width(8.dp)) + Box( modifier = Modifier - .defaultMinSize(minWidth = 64.dp, minHeight = 36.dp) + .height(36.dp) .clip(RoundedCornerShape(12.dp)) .background(color = if (enabled) Primary_23C882 else PrimaryL1_8FD9B9) .clickableSingle(enabled = enabled) { onClickPostComment() } @@ -544,4 +544,4 @@ private fun PreviewCommentTextField() { focusRequester = commentFocusRequester, onClickPostComment = { } ) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/FeedPostView.kt b/presentation/src/main/java/daily/dayo/presentation/view/FeedPostView.kt index 28d557dbc..6c9f2e4b5 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/FeedPostView.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/FeedPostView.kt @@ -285,19 +285,19 @@ fun FeedPostView( // like count val dec = DecimalFormat("#,###") Row(modifier = Modifier.weight(1f)) { - Text(text = stringResource(id = R.string.post_like_count_message_1), style = DayoTheme.typography.caption1.copy(Gray2_767B83)) + Text(text = stringResource(id = R.string.post_like_count_message_1), style = DayoTheme.typography.caption2.copy(Gray2_767B83)) Text( text = " ${dec.format(post.heartCount)} ", - style = DayoTheme.typography.caption1, + style = DayoTheme.typography.caption2, modifier = if (post.heartCount != 0) Modifier.clickableSingle { post.postId?.let { onPostLikeUsersClick(it) } } else Modifier, color = if (post.heartCount != 0) Primary_23C882 else Gray4_C5CAD2) - Text(text = stringResource(id = R.string.post_like_count_message_2), style = DayoTheme.typography.caption1.copy(Gray2_767B83)) + Text(text = stringResource(id = R.string.post_like_count_message_2), style = DayoTheme.typography.caption2.copy(Gray2_767B83)) } // comment count Row { - Text(text = " ${dec.format(post.commentCount)} ", style = DayoTheme.typography.caption1, color = if (post.commentCount != 0) Primary_23C882 else Gray4_C5CAD2) - Text(text = stringResource(id = R.string.post_comment_count_message), style = DayoTheme.typography.caption1.copy(Gray2_767B83)) + Text(text = " ${dec.format(post.commentCount)} ", style = DayoTheme.typography.caption2, color = if (post.commentCount != 0) Primary_23C882 else Gray4_C5CAD2) + Text(text = stringResource(id = R.string.post_comment_count_message), style = DayoTheme.typography.caption2.copy(Gray2_767B83)) } } diff --git a/presentation/src/main/java/daily/dayo/presentation/view/FolderView.kt b/presentation/src/main/java/daily/dayo/presentation/view/FolderView.kt index d49cce7e6..6727d234a 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/FolderView.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/FolderView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import daily.dayo.domain.model.Folder import daily.dayo.domain.model.Privacy import daily.dayo.presentation.BuildConfig @@ -77,10 +78,18 @@ fun FolderView( // folder info Column { - Text(text = folder.title, style = DayoTheme.typography.b6.copy(Dark)) + Text( + text = folder.title, + lineHeight = 21.sp, + style = DayoTheme.typography.b6.copy(Dark) + ) val dec = DecimalFormat("#,###") - Text(text = "${dec.format(folder.postCount)}개", style = DayoTheme.typography.b6.copy(Gray3_9FA5AE)) + Text( + text = "${dec.format(folder.postCount)}개", + lineHeight = 21.sp, + style = DayoTheme.typography.b6.copy(Gray3_9FA5AE) + ) } } } \ No newline at end of file diff --git a/presentation/src/main/java/daily/dayo/presentation/view/Switch.kt b/presentation/src/main/java/daily/dayo/presentation/view/Switch.kt index 9578b159d..e8beb9cee 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/Switch.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/Switch.kt @@ -3,10 +3,7 @@ package daily.dayo.presentation.view import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.Text import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -24,15 +21,13 @@ import daily.dayo.presentation.theme.White_FFFFFF fun ToggleButtonWithLabel( label: String, isToggled: Boolean, - onToggleChanged: (Boolean) -> Unit + onToggleChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, - modifier = Modifier - .padding(18.dp) - .fillMaxWidth() - .wrapContentHeight() + modifier = modifier ) { Text( text = label, diff --git a/presentation/src/main/java/daily/dayo/presentation/view/TextField.kt b/presentation/src/main/java/daily/dayo/presentation/view/TextField.kt index c3fbfd17a..be35c50c8 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/TextField.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/TextField.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxWidth @@ -52,6 +53,7 @@ import daily.dayo.presentation.common.TextLimitUtil import daily.dayo.presentation.theme.Dark import daily.dayo.presentation.theme.DayoTheme import daily.dayo.presentation.theme.Gray2_767B83 +import daily.dayo.presentation.theme.Gray3_9FA5AE import daily.dayo.presentation.theme.Gray4_C5CAD2 import daily.dayo.presentation.theme.Gray5_E8EAEE import daily.dayo.presentation.theme.Gray6_F0F1F3 @@ -89,7 +91,7 @@ fun DayoTextField( Text( text = label, style = DayoTheme.typography.caption3.copy( - color = Gray4_C5CAD2, + color = Gray3_9FA5AE, fontWeight = FontWeight.SemiBold ) ) @@ -142,7 +144,7 @@ fun DayoTextField( errorContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedLabelColor = Color.Transparent, // 라벨 - focusedLabelColor = Gray4_C5CAD2, + focusedLabelColor = Gray3_9FA5AE, errorLabelColor = Red_FF4545, focusedPlaceholderColor = Gray5_E8EAEE, // 힌트 unfocusedPlaceholderColor = Gray5_E8EAEE, @@ -198,6 +200,8 @@ fun DayoPasswordTextField( textAlign: TextAlign = TextAlign.Left, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onErrorIconClick: (() -> Unit) = { }, + showClearIcon: Boolean = true, + showVisibilityIcon: Boolean = true, keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardActions: KeyboardActions = KeyboardActions.Default, isEnabled: Boolean = true, @@ -207,12 +211,21 @@ fun DayoPasswordTextField( verticalArrangement = Arrangement.spacedBy(4.dp) ) { var passwordHidden by remember { mutableStateOf(true) } + val showError = isError == true + val shouldShowClear = showClearIcon && isEnabled && value.isNotBlank() && !showError + val shouldShowVisibility = showVisibilityIcon && !showError + val trailingContentWidth = when { + showError -> 20.dp + shouldShowClear && shouldShowVisibility -> 48.dp + shouldShowClear || shouldShowVisibility -> 20.dp + else -> 0.dp + } if (label.isNotEmpty()) { Text( text = label, style = DayoTheme.typography.caption3.copy( - color = Gray4_C5CAD2, + color = Gray3_9FA5AE, fontWeight = FontWeight.SemiBold ) ) @@ -254,7 +267,7 @@ fun DayoPasswordTextField( contentPadding = PaddingValues( start = contentPadding.calculateStartPadding(LayoutDirection.Ltr), top = contentPadding.calculateTopPadding(), - end = contentPadding.calculateEndPadding(LayoutDirection.Ltr) + 20.dp, + end = contentPadding.calculateEndPadding(LayoutDirection.Ltr) + trailingContentWidth, bottom = contentPadding.calculateBottomPadding() ), colors = TextFieldDefaults.colors( @@ -266,7 +279,7 @@ fun DayoPasswordTextField( errorContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedLabelColor = Color.Transparent, // 라벨 - focusedLabelColor = Gray4_C5CAD2, + focusedLabelColor = Gray3_9FA5AE, errorLabelColor = Red_FF4545, focusedPlaceholderColor = Gray5_E8EAEE, // 힌트 unfocusedPlaceholderColor = Gray5_E8EAEE, @@ -279,22 +292,42 @@ fun DayoPasswordTextField( Box( modifier = Modifier.align(alignment = Alignment.CenterEnd) ) { - if (isError != null && isError == true) { + if (showError) { NoRippleIconButton( onClick = onErrorIconClick, iconContentDescription = "error icon", iconPainter = painterResource(id = errorTrailingIconId), iconButtonModifier = Modifier.size(20.dp) ) - } else { - val trailingIconId = if (passwordHidden) R.drawable.ic_trailing_invisible else R.drawable.ic_trailing_visible - val description = if (passwordHidden) "Show password" else "Hide password" - NoRippleIconButton( - onClick = { passwordHidden = passwordHidden.not() }, - iconContentDescription = description, - iconPainter = painterResource(id = trailingIconId), - iconButtonModifier = Modifier.size(20.dp) - ) + } else if (shouldShowClear || shouldShowVisibility) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (shouldShowClear) { + NoRippleIconButton( + onClick = { onValueChange("") }, + iconContentDescription = "Clear password", + iconPainter = painterResource(id = R.drawable.ic_trailing_delete), + iconButtonModifier = Modifier.size(20.dp) + ) + } + + if (shouldShowVisibility) { + val trailingIconId = if (passwordHidden) { + R.drawable.ic_trailing_invisible + } else { + R.drawable.ic_trailing_visible + } + val description = if (passwordHidden) "Show password" else "Hide password" + NoRippleIconButton( + onClick = { passwordHidden = passwordHidden.not() }, + iconContentDescription = description, + iconPainter = painterResource(id = trailingIconId), + iconButtonModifier = Modifier.size(20.dp) + ) + } + } } } } @@ -323,6 +356,7 @@ fun DayoTimerTextField( isError: Boolean = false, errorMessage: String = "", timeOutErrorMessage: String = stringResource(id = R.string.email_address_certificate_alert_message_time_fail), + labelColor: Color = Gray3_9FA5AE, onTimeOut: (() -> Unit) = { }, textAlign: TextAlign = TextAlign.Left, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -351,7 +385,7 @@ fun DayoTimerTextField( Text( text = label, style = DayoTheme.typography.caption3.copy( - color = Gray4_C5CAD2, + color = labelColor, fontWeight = FontWeight.SemiBold ) ) @@ -404,7 +438,7 @@ fun DayoTimerTextField( errorContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedLabelColor = Color.Transparent, // 라벨 - focusedLabelColor = Gray4_C5CAD2, + focusedLabelColor = Gray3_9FA5AE, errorLabelColor = Red_FF4545, focusedPlaceholderColor = Gray5_E8EAEE, // 힌트 unfocusedPlaceholderColor = Gray5_E8EAEE, @@ -528,4 +562,4 @@ private fun PreviewOutlinedTextField() { placeholder = stringResource(id = R.string.report_post_reason_other_hint), maxLength = 5 ) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/TopNavigation.kt b/presentation/src/main/java/daily/dayo/presentation/view/TopNavigation.kt index 00b4c24de..daa401421 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/TopNavigation.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/TopNavigation.kt @@ -1,6 +1,7 @@ package daily.dayo.presentation.view import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.CenterAlignedTopAppBar @@ -25,7 +26,8 @@ fun TopNavigation( title: String = "", leftIcon: @Composable () -> Unit = {}, rightIcon: @Composable () -> Unit = {}, - titleAlignment: TopNavigationAlign = TopNavigationAlign.LEFT + titleAlignment: TopNavigationAlign = TopNavigationAlign.LEFT, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets ) { when (titleAlignment) { TopNavigationAlign.LEFT -> { @@ -34,10 +36,11 @@ fun TopNavigation( containerColor = White_FFFFFF, titleContentColor = Dark, ), + windowInsets = windowInsets, navigationIcon = leftIcon, actions = { rightIcon() }, title = { - Text(text = title, maxLines = 1, style = DayoTheme.typography.h3) + Text(text = title, maxLines = 1, style = DayoTheme.typography.b3) } ) } @@ -48,10 +51,11 @@ fun TopNavigation( containerColor = White_FFFFFF, titleContentColor = Dark, ), + windowInsets = windowInsets, navigationIcon = leftIcon, actions = { rightIcon() }, title = { - Text(text = title, maxLines = 1, style = DayoTheme.typography.h3) + Text(text = title, maxLines = 1, style = DayoTheme.typography.b3) } ) } @@ -93,4 +97,4 @@ fun PreviewTopNavigation() { titleAlignment = TopNavigationAlign.CENTER ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/dialog/BottomSheetDialog.kt b/presentation/src/main/java/daily/dayo/presentation/view/dialog/BottomSheetDialog.kt index 80340c231..b53629ea9 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/dialog/BottomSheetDialog.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/dialog/BottomSheetDialog.kt @@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource @@ -67,7 +70,7 @@ fun BottomSheetDialog( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), - shape = RoundedCornerShape(12.dp, 12.dp, 0.dp, 0.dp), + shape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp), color = White_FFFFFF ) { Column( @@ -115,53 +118,75 @@ fun BottomSheetDialog( buttons.forEachIndexed { index, button -> val interactionSource = remember { MutableInteractionSource() } val isPressed = interactionSource.collectIsPressedAsState().value - - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .background( - if (isPressed) Gray6_F0F1F3 else White_FFFFFF, - RoundedCornerShape(12.dp, 12.dp, 0.dp, 0.dp) - ) - .padding(if (leftIconButtons == null) 16.dp else 12.dp) - .clickable( - onClick = button.second, - interactionSource = interactionSource, - indication = null - ), - horizontalArrangement = if (leftIconButtons == null) Arrangement.Center else Arrangement.SpaceBetween, - ) { - if (leftIconButtons != null && leftIconCheckedButtons != null) { - Icon( - imageVector = if (checkedButtonIndex == index) leftIconCheckedButtons[index] else leftIconButtons[index], - contentDescription = "", - modifier = Modifier.align(Alignment.CenterVertically), - tint = Color.Unspecified + if (leftIconButtons != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable( + onClick = button.second, + interactionSource = interactionSource + ) + .background(White_FFFFFF) + .padding(vertical = 8.dp, horizontal = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (leftIconCheckedButtons != null) { + Icon( + imageVector = if (checkedButtonIndex == index) leftIconCheckedButtons[index] else leftIconButtons[index], + contentDescription = "", + modifier = Modifier.align(Alignment.CenterVertically), + tint = Color.Unspecified + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = button.first, + modifier = Modifier.weight(1f), + color = if ((isFirstButtonColored && index == 0) || (checkedButtonIndex == index)) checkedColor else normalColor, + fontSize = 16.sp, + style = DayoTheme.typography.b4 ) - } - Text( - text = button.first, - modifier = Modifier.offset( - if (leftIconButtons == null) 0.dp else 8.dp, - 0.dp - ), - color = if ((isFirstButtonColored && index == 0) || (checkedButtonIndex == index)) checkedColor else normalColor, - fontSize = 16.sp, - style = DayoTheme.typography.b4 - ) - if (leftIconButtons != null) { - Spacer(modifier = Modifier.weight(1f)) if (checkedButtonIndex == index) { Icon( imageVector = rightIcon, contentDescription = "", - modifier = Modifier.align(Alignment.CenterVertically), + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically), tint = Color.Unspecified ) } } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .background( + if (isPressed) Gray6_F0F1F3 else White_FFFFFF, + RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp) + ) + .clickable( + onClick = button.second, + interactionSource = interactionSource, + indication = null + ), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = button.first, + textAlign = TextAlign.Center, + color = if ((isFirstButtonColored && index == 0) || (checkedButtonIndex == index)) checkedColor else normalColor, + fontSize = 16.sp, + style = DayoTheme.typography.b4 + ) + } } + if (index < buttons.size - 1 && title.isEmpty()) { Divider( modifier = Modifier @@ -221,4 +246,4 @@ fun PreviewMyBottomSheetDialog() { checkedButtonIndex = 0, ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/daily/dayo/presentation/view/dialog/CommentBottomSheetDialog.kt b/presentation/src/main/java/daily/dayo/presentation/view/dialog/CommentBottomSheetDialog.kt index 3984e606e..98aecd32b 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/dialog/CommentBottomSheetDialog.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/dialog/CommentBottomSheetDialog.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,7 +17,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateListOf @@ -93,13 +94,15 @@ fun CommentBottomSheetDialog( val onClickDelete: (Long) -> Unit = { commentId -> postViewModel.requestDeletePostComment(commentId) } - val postCommentDeleteSuccess by postViewModel.postCommentDeleteSuccess.observeAsState(Event(false)) - if (postCommentDeleteSuccess.getContentIfNotHandled() == true) { - postViewModel.requestPostComment(postId) - SideEffect { - coroutineScope.launch { - snackBarHostState.showSnackbar("댓글이 삭제되었어요.") - } + val postCommentDeleteSuccess by postViewModel.postCommentDeleteSuccess.observeAsState( + Event( + false + ) + ) + LaunchedEffect(postCommentDeleteSuccess) { + if (postCommentDeleteSuccess.getContentIfNotHandled() == true) { + postViewModel.requestPostComment(postId) + snackBarHostState.showSnackbar(context.getString(R.string.comment_delete_message)) } } var showReportDialog by remember { mutableStateOf(false) } @@ -133,14 +136,24 @@ fun CommentBottomSheetDialog( } // create comment - val replyCommentState = remember { mutableStateOf?>(null) } // parent comment Id, reply comment + val replyCommentState = + remember { mutableStateOf?>(null) } // parent comment Id, reply comment val onClickPostComment: () -> Unit = { if (replyCommentState.value == null) { if (commentText.value.text.isNotBlank()) { - postViewModel.requestCreatePostComment(commentText.value.text, postId, mentionedMemberIds) + postViewModel.requestCreatePostComment( + commentText.value.text, + postId, + mentionedMemberIds + ) } } else { - postViewModel.requestCreatePostCommentReply(replyCommentState.value!!, commentText.value.text, postId, mentionedMemberIds) + postViewModel.requestCreatePostCommentReply( + replyCommentState.value!!, + commentText.value.text, + postId, + mentionedMemberIds + ) } } val onClickReply: (Pair?) -> Unit = { reply -> @@ -149,7 +162,8 @@ fun CommentBottomSheetDialog( // show mention user name val replyUsername = "@${replyCommentState.value?.second?.nickname} " - commentText.value = TextFieldValue(text = replyUsername, selection = TextRange(replyUsername.length)) + commentText.value = + TextFieldValue(text = replyUsername, selection = TextRange(replyUsername.length)) commentFocusRequester.requestFocus() } val commentEnabled = if (replyCommentState.value == null) { @@ -168,11 +182,30 @@ fun CommentBottomSheetDialog( val onClickCancelReply: () -> Unit = { clearComment() } - val postCommentCreateSuccess by postViewModel.postCommentCreateSuccess.observeAsState(Event(false)) - if (postCommentCreateSuccess.getContentIfNotHandled() == true) { - clearComment() - keyboardController?.hide() - postViewModel.requestPostComment(postId) + + val postCommentCreateState by postViewModel.postCommentCreateState.observeAsState() + LaunchedEffect(postCommentCreateState) { + postCommentCreateState?.status?.let { state -> + when (state) { + Status.LOADING -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.loading_default_message)) + } + } + + Status.SUCCESS -> { + clearComment() + keyboardController?.hide() + postViewModel.requestPostComment(postId) + } + + Status.ERROR -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.network_error_dialog_default_message)) + } + } + } + } } Surface( @@ -188,7 +221,7 @@ fun CommentBottomSheetDialog( Column( modifier = Modifier .fillMaxWidth() - .padding(top = 12.dp, bottom = 65.dp) + .padding(bottom = 65.dp) .wrapContentHeight(), ) { CommentBottomSheetDialogTitle(clearComment, onClickClose) @@ -206,8 +239,14 @@ fun CommentBottomSheetDialog( } Column(modifier = Modifier.align(Alignment.BottomCenter)) { - if (showMentionSearchView.value) CommentMentionSearchView(userResults, onClickFollowUser) - if (replyCommentState.value != null) CommentReplyDescriptionView(replyCommentState, onClickCancelReply) + if (showMentionSearchView.value) CommentMentionSearchView( + userResults, + onClickFollowUser + ) + if (replyCommentState.value != null) CommentReplyDescriptionView( + replyCommentState, + onClickCancelReply + ) CommentTextField( enabled = commentEnabled, commentText = commentText, @@ -242,12 +281,15 @@ private fun CommentBottomSheetDialogTitle(clearComment: () -> Unit, onClickClose Box( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .height(48.dp) .background(DayoTheme.colorScheme.background) ) { Text( text = stringResource(id = R.string.comment), - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .align(Alignment.Center) + .padding(top = 15.dp, bottom = 6.dp) + .height(27.dp), textAlign = TextAlign.Center, style = DayoTheme.typography.b1.copy(color = Dark, fontWeight = FontWeight.SemiBold) ) @@ -259,7 +301,10 @@ private fun CommentBottomSheetDialogTitle(clearComment: () -> Unit, onClickClose }, iconContentDescription = "close", iconPainter = painterResource(id = R.drawable.ic_x), - iconButtonModifier = Modifier.align(Alignment.CenterEnd) + iconButtonModifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + .size(32.dp) ) } } @@ -280,16 +325,35 @@ private fun CommentBottomSheetDialogContent( .fillMaxHeight(0.8f) ) { item { - CommentListView( - currentMemberId = currentMemberId, - postComments = postComments, - onClickProfile = onClickCommentProfile, - onClickReply = onClickReply, - onClickDelete = onClickDelete, - onClickReport = onClickReport, - modifier = Modifier.padding(horizontal = 18.dp), - showEmptyIcon = true - ) + if (postComments.data.isEmpty()) { + Box( + modifier = Modifier + .fillParentMaxHeight() + .fillMaxWidth() + ) { + CommentListView( + currentMemberId = currentMemberId, + postComments = postComments, + onClickProfile = onClickCommentProfile, + onClickReply = onClickReply, + onClickDelete = onClickDelete, + onClickReport = onClickReport, + modifier = Modifier.padding(horizontal = 18.dp), + showEmptyIcon = true + ) + } + } else { + CommentListView( + currentMemberId = currentMemberId, + postComments = postComments, + onClickProfile = onClickCommentProfile, + onClickReply = onClickReply, + onClickDelete = onClickDelete, + onClickReport = onClickReport, + modifier = Modifier.padding(horizontal = 18.dp), + showEmptyIcon = true + ) + } } } } diff --git a/presentation/src/main/java/daily/dayo/presentation/view/dialog/ConfirmDialog.kt b/presentation/src/main/java/daily/dayo/presentation/view/dialog/ConfirmDialog.kt index 4fd215dd3..4634dc104 100644 --- a/presentation/src/main/java/daily/dayo/presentation/view/dialog/ConfirmDialog.kt +++ b/presentation/src/main/java/daily/dayo/presentation/view/dialog/ConfirmDialog.kt @@ -5,12 +5,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text import androidx.compose.material3.Divider -import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,11 +46,12 @@ fun ConfirmDialog( ) { Box( modifier = modifier + .widthIn(min = 252.dp, max = 320.dp) .background( DayoTheme.colorScheme.background, - RoundedCornerShape(10.dp) + RoundedCornerShape(16.dp) ) - .clip(RoundedCornerShape(10.dp)) + .clip(RoundedCornerShape(16.dp)) ) { Column { DialogHeader(title, description) @@ -87,6 +90,7 @@ private fun DialogHeader(title: String, description: String) { } if (description.isNotBlank()) { + Spacer(Modifier.height(8.dp)) Text( text = description, color = Gray2_767B83, diff --git a/presentation/src/main/java/daily/dayo/presentation/viewmodel/PostViewModel.kt b/presentation/src/main/java/daily/dayo/presentation/viewmodel/PostViewModel.kt index 8c5a5affa..e0fccb7e5 100644 --- a/presentation/src/main/java/daily/dayo/presentation/viewmodel/PostViewModel.kt +++ b/presentation/src/main/java/daily/dayo/presentation/viewmodel/PostViewModel.kt @@ -66,8 +66,8 @@ class PostViewModel @Inject constructor( private val _postDeleteSuccess = MutableSharedFlow() val postDeleteSuccess = _postDeleteSuccess.asSharedFlow() - private val _postCommentCreateSuccess = MutableLiveData>() - val postCommentCreateSuccess get() = _postCommentCreateSuccess + private val _postCommentCreateState = MutableLiveData>() + val postCommentCreateState: LiveData> get() = _postCommentCreateState private val _postCommentDeleteSuccess = MutableLiveData>() val postCommentDeleteSuccess get() = _postCommentDeleteSuccess @@ -241,17 +241,19 @@ class PostViewModel @Inject constructor( } fun requestCreatePostComment(contents: String, postId: Long, mentionedUser: List) { - if (contents.isEmpty()) return + if (contents.isEmpty() || _postCommentCreateState.value?.status == Status.LOADING) return + viewModelScope.launch { + _postCommentCreateState.postValue(Resource.loading(null)) val mentionList = getMentionList(contents, mentionedUser) requestCreatePostCommentUseCase(contents = contents, postId = postId, mentionList = mentionList).let { response -> when (response) { is NetworkResponse.Success -> { - _postCommentCreateSuccess.postValue(Event(true)) + _postCommentCreateState.postValue(Resource.success(true)) } else -> { - _postCommentCreateSuccess.postValue(Event(false)) + _postCommentCreateState.postValue(Resource.error("댓글 작성 실패", false)) } } } @@ -259,19 +261,21 @@ class PostViewModel @Inject constructor( } fun requestCreatePostCommentReply(reply: Pair, contents: String, postId: Long, mentionedUser: List) { - if (contents.isEmpty()) return + if (contents.isEmpty() || _postCommentCreateState.value?.status == Status.LOADING) return + viewModelScope.launch { + _postCommentCreateState.postValue(Resource.loading(null)) val mentionList = getMentionList(contents, mentionedUser).toMutableList() val (parentCommentId, comment) = reply mentionList.add(MentionUser(comment.memberId, comment.nickname)) // 언급된 유저 리스트에 원본 댓글 유저 추가 (팔로우하지 않아도 답글 가능하므로 따로 추가) requestCreatePostCommentReplyUseCase(commentId = parentCommentId, contents = contents, postId = postId, mentionList = mentionList).let { response -> when (response) { is NetworkResponse.Success -> { - _postCommentCreateSuccess.postValue(Event(true)) + _postCommentCreateState.postValue(Resource.success(true)) } else -> { - _postCommentCreateSuccess.postValue(Event(false)) + _postCommentCreateState.postValue(Resource.error("답글 작성 실패", false)) } } } diff --git a/presentation/src/main/res/drawable/dialog_bottom_sheet_default.xml b/presentation/src/main/res/drawable/dialog_bottom_sheet_default.xml deleted file mode 100644 index c3b325661..000000000 --- a/presentation/src/main/res/drawable/dialog_bottom_sheet_default.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_blocked_users_empty.xml b/presentation/src/main/res/drawable/ic_blocked_users_empty.xml index 648d468e6..144752c33 100644 --- a/presentation/src/main/res/drawable/ic_blocked_users_empty.xml +++ b/presentation/src/main/res/drawable/ic_blocked_users_empty.xml @@ -1,42 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_check.xml b/presentation/src/main/res/drawable/ic_check.xml index 5da02f28b..94c281d9b 100644 --- a/presentation/src/main/res/drawable/ic_check.xml +++ b/presentation/src/main/res/drawable/ic_check.xml @@ -1,10 +1,12 @@ + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_check_corner_round.xml b/presentation/src/main/res/drawable/ic_check_corner_round.xml new file mode 100644 index 000000000..96f06c515 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_check_corner_round.xml @@ -0,0 +1,15 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_feed_empty.xml b/presentation/src/main/res/drawable/ic_feed_empty.xml index 706bc7260..dc7c7c6b5 100644 --- a/presentation/src/main/res/drawable/ic_feed_empty.xml +++ b/presentation/src/main/res/drawable/ic_feed_empty.xml @@ -1,47 +1,68 @@ - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_lock.xml b/presentation/src/main/res/drawable/ic_lock.xml index f760683fd..455f0a607 100644 --- a/presentation/src/main/res/drawable/ic_lock.xml +++ b/presentation/src/main/res/drawable/ic_lock.xml @@ -1,15 +1,5 @@ - - - + + + + diff --git a/presentation/src/main/res/drawable/ic_setting.xml b/presentation/src/main/res/drawable/ic_setting.xml index ac866b614..9ef66caf1 100644 --- a/presentation/src/main/res/drawable/ic_setting.xml +++ b/presentation/src/main/res/drawable/ic_setting.xml @@ -1,9 +1,14 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + android:pathData="M5.42 3.1C4.46 3.65 4.13 4.87 4.69 5.83l0.23 0.41C5.12 6.6 5.1 7.03 4.87 7.37c-0.16 0.25-0.31 0.5-0.45 0.77C4.24 8.5 3.88 8.75 3.48 8.75H3c-1.1 0-2 0.9-2 2v2.5c0 1.1 0.9 2 2 2h0.48c0.4 0 0.76 0.25 0.94 0.6 0.14 0.27 0.29 0.53 0.45 0.78 0.22 0.34 0.26 0.78 0.05 1.13l-0.23 0.4c-0.56 0.97-0.23 2.19 0.73 2.74l2.16 1.25c0.96 0.55 2.18 0.23 2.73-0.73L10.55 21c0.2-0.35 0.6-0.54 1-0.52L12 20.5l0.45-0.01c0.4-0.02 0.8 0.17 1 0.52l0.24 0.4c0.55 0.97 1.77 1.3 2.73 0.74l2.16-1.25c0.96-0.55 1.29-1.77 0.73-2.73l-0.23-0.41c-0.2-0.35-0.17-0.79 0.05-1.13 0.16-0.25 0.31-0.5 0.45-0.77 0.18-0.36 0.54-0.61 0.94-0.61H21c1.1 0 2-0.9 2-2v-2.5c0-1.1-0.9-2-2-2h-0.48c-0.4 0-0.76-0.25-0.94-0.6-0.14-0.27-0.29-0.53-0.45-0.78-0.22-0.34-0.26-0.78-0.05-1.13l0.23-0.4c0.56-0.97 0.23-2.19-0.73-2.74l-2.16-1.25c-0.96-0.55-2.18-0.23-2.73 0.73L13.45 3c-0.2 0.35-0.6 0.54-1 0.52L12 3.5l-0.45 0.01c-0.4 0.02-0.8-0.17-1-0.52l-0.24-0.41C9.76 1.62 8.54 1.3 7.58 1.85L5.42 3.1Z" + android:strokeWidth="1.4" + android:strokeColor="#FF313131" /> + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/img_default_folder_dayo_logo.xml b/presentation/src/main/res/drawable/img_default_folder_dayo_logo.xml new file mode 100644 index 000000000..a2b98185c --- /dev/null +++ b/presentation/src/main/res/drawable/img_default_folder_dayo_logo.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/values-night/themes.xml b/presentation/src/main/res/values-night/themes.xml index 136c45c19..0a88fee6c 100644 --- a/presentation/src/main/res/values-night/themes.xml +++ b/presentation/src/main/res/values-night/themes.xml @@ -12,8 +12,6 @@ #FFFFFFFF true - - @style/AppBottomSheetDialogTheme - - - - \ No newline at end of file + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 236dd6e15..3feac48d6 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -28,7 +28,7 @@ 잠시만 기다려 주세요 인터넷 연결 상태를 확인해주세요 닫기 - 재시도 + 다시 시도 방금 전 @@ -135,7 +135,7 @@ 개의 댓글 더보기 아직 댓글이 없어요 - 이 게시글에 대해 댓글을 남겨주세요 + 첫번째 댓글을 남겨보세요 게시물 수정 게시물 삭제 이 게시글을 정말 삭제할까요? @@ -259,6 +259,7 @@ 추천 검색어 앗! 찾으시는 검색 결과가 없어요. 다른 검색어를 입력해보세요 + 개의 검색 결과 검색 기록 지우기 최근 검색 결과가 없어요. 관심있는 키워드 또는 사용자를 찾아보세요 diff --git a/presentation/src/main/res/values/themes.xml b/presentation/src/main/res/values/themes.xml index 909bc9c74..5708138f3 100644 --- a/presentation/src/main/res/values/themes.xml +++ b/presentation/src/main/res/values/themes.xml @@ -12,8 +12,6 @@ #FFFFFFFF true - - @style/AppBottomSheetDialogTheme - - - - - \ No newline at end of file +