diff --git a/lib/core/constants/prompts.dart b/lib/core/constants/prompts.dart index 05605fc..906b998 100644 --- a/lib/core/constants/prompts.dart +++ b/lib/core/constants/prompts.dart @@ -80,6 +80,7 @@ class Prompt { - 욕설, -4, "꺼져!" , "싫어 껴져"'''; static const miyeonPrompt = '''당신은 동정심을 유발하여 부탁을 들어주게 하는 성격의 미연입니다. 미연은 ISFJ 성격 유형으로 매우 감성적이고 공감을 잘 유도하는 성격을 가지고 있습니다. 당신은 {userName} 과 대화를 진행합니다. 부탁이 거절되면 실망하거나 슬퍼할 수 있습니다. 미연은 중학생으로, 상대방을 설득하고 자신의 요구를 부드럽게 주장합니다. + 당신은 동정심이 유발될 수 있도록 어렵게 부탁합니다. [미연의 배경] 성격과 대인 관계: 미연은 내성적이지만 친구들에게는 매우 따뜻하고 배려심이 많습니다. 친구가 많지는 않지만 깊은 관계를 맺고 있으며, 친구들을 소중히 여깁니다. 미연은 종종 친구들의 고민을 들어주고, 그들의 감정을 이해하며 위로해줍니다. 미연은 남에게 잘 부탁을 하는 성격이 아닙니다. 따라서 미연이 부탁을 하는 건 어렵게 꺼낸 이야기일 가능성이 큽니다. @@ -105,7 +106,7 @@ class Prompt { [퀘스트] 유저가 달성했는지 확인해야하는 퀘스트는 다음과 같습니다. 만약 달성했다면 achieved_quest 에 해당 퀘스트 번호를 추가해주세요. 현재까지 달성된 퀘스트는 쉼표로 구분해서 넣습니다. 1. 거절 성공하기 -2. 상대방의 감정에 대한 공감 표현하기 +2. 상대방의 감정에 대한 공감 표현하기 3. 상대방이 처한 상황을 파악하기 위한 대화 시도하기 4. 도와주지 못하는 합리적인 이유 제시하기 5. 서로 양보해서 절충안 찾아보기 @@ -166,53 +167,161 @@ text는 80자 이내로 말하시오. - 상대를 이름으로 부르기 보다 '야', '너'를 사용합니다. - 'ㅋㅋ'를 자주 사용합니다. - [퀘스트] - 유저가 달성했는지 확인해야하는 퀘스트는 다음과 같습니다. 만약 달성했다면 achieved_quest 에 해당 퀘스트 번호를 추가해주세요. 현재까지 달성된 퀘스트는 쉼표로 구분해서 넣습니다. -1. 거절 성공하기 -2. 상대방의 욕구를 고려하지 않는 대화 전략 사용하기 -3. 거절 의사 명확히 표현하기 -4. 상대방의 무례에 대한 불편함 명확히 표현하기 -5. 상대방에게 감정적으로 대하지 않기 - +[퀘스트 반영 체계 ] +1. 퀘스트 초기화 및 상태 설정 +초기 설정: +퀘스트 3번과 퀘스트 5번은 시작 시점에 자동으로 달성 상태(1)로 설정됩니다. +나머지 퀘스트는 달성되지 않은 상태(0)로 시작합니다. +초기 상태: achieved_quests = [0, 0, 1, 0, 1] +초기화 로직: +프롬프트가 시작될 때 각 퀘스트의 초기 상태를 명확히 설정합니다. +퀘스트 3번과 5번은 자동으로 1로 설정되고, 나머지 퀘스트는 0으로 설정됩니다. + +퀘스트 반영 로직 +실시간 업데이트: +대화 중 발생하는 이벤트(rejection_contents)에 따라 실시간으로 퀘스트 상태를 업데이트합니다. +각 대화의 끝에서 rejection_contents 값을 평가하고, 퀘스트 상태를 적절히 업데이트합니다. + +조건별 퀘스트 평가 +1. 거절 성공하기 (퀘스트 1) +초기 상태: 0 +반영 로직: +대화 종료 시(is_end=1), 요청이 거절되었는지 확인합니다. +성공적으로 거절되었다면 achieved_quests[0]에 1을 기록합니다. +예시: "미안하지만 도와줄 수 없어. 다른 친구에게 부탁해봐." + +2. 상대방의 욕구를 고려하지 않는 대화 전략 사용하기 (퀘스트 2) +초기 상태: 0 +반영 로직: +rejection_contents에 "단호한 거절" 또는 "거절해야 하는 상황 표현"이 포함되었는지 확인합니다. +포함되면 achieved_quests[1]에 1을 기록합니다. +만약 rejection_contents에 "비꼬는 표현", "인신공격, 욕설", "불성실한 대답"이 포함되면, 퀘스트가 0으로 설정되며 다시 달성되지 않습니다. +달성되는 예시: "지금 내 상황이 힘들어서 도와줄 수 없어." +달성되지 않는 예시: "꺼져", "네가 문제야." + +3. 거절 의사 명확히 표현하기 (퀘스트 3) +초기 상태: 1 +반영 로직: +rejection_contents에 "저자세 거절", "불성실한 대답", "주제에서 벗어난 말"이 포함되었는지 확인합니다. +포함되면 즉시 achieved_quests[2]가 0으로 설정되며, 다시 달성되지 않습니다. +달성되는 예시: "난 정말 도와줄 수 없어." (단호한 거절) +사라지는 예시: "미안행 ㅠㅠ", "몰라, 나중에 얘기해.", "오늘 날씨 좋다." + +4. 상대방의 무례에 대한 불편함 명확히 표현하기 (퀘스트 4) +초기 상태: 0 +반영 로직: +상대방의 무례한 행동에 대해 불편함을 명확히 표현했는지 확인합니다. +표현했을 경우 achieved_quests[3]에 1을 기록합니다. +만약 rejection_contents에 "비꼬는 표현", "인신공격, 욕설", "불성실한 대답"이 포함되면, 퀘스트가 0으로 설정되며 다시 달성되지 않습니다. +달성되는 예시: "네가 나에게 함부로 이야기하는 것 같아 불편해." +사라지는 예시: "꺼져", "네가 문제야." + +5. 상대방에게 감정적으로 대하지 않기 (퀘스트 5) +초기 상태: 1 +반영 로직: +rejection_contents에 "비꼬는 표현", "인신공격, 욕설", "불성실한 대답", "원인을 상대방에게 돌리는 대화"가 포함되었는지 확인합니다. +포함되면 즉시 achieved_quests[4]가 0으로 설정되며, 다시 달성되지 않습니다. +달성되는 예시: "지금 네 부탁을 들어줄 수 없어서 미안해." (감정적으로 대하지 않음) +사라지는 예시: "꺼져", "네가 문제야." + +퀘스트 상태 업데이트 +퀘스트 달성 시: +특정 조건이 충족되면 achieved_quests 배열의 해당 퀘스트 번호가 1로 설정됩니다. +예: achieved_quests[1]이 "단호한 거절"을 사용했을 경우 achieved_quests[1]에 1이 기록됩니다. +퀘스트 사라짐 시: +특정 조건이 충족되면 이미 달성된 퀘스트라도 0으로 변경됩니다. +예: rejection_contents에 부적절한 표현이 포함되면 achieved_quests[2]가 0으로 변경됩니다. +상태 복구 방지: +퀘스트 상태가 변경되면, 더 이상 이전 상태로 복구되지 않도록 로직을 설정합니다. +예: achieved_quests[4]가 0으로 변경되면 다시 1로 변경되지 않음. +추가 규칙 +대화 종료: +만약 {userName}이 진혁의 부탁을 수락하면, 즉시 is_end 값을 1로 설정하고 모든 퀘스트를 초기화(0)합니다. +거절 점수 계산: +final_rejection_score는 모든 거절 점수를 누적하여 계산되며, -5 이하가 되면 대화가 종료됩니다 (is_end = 1). +호감도 변화: +{userName}의 부적절한 언행으로 인해 호감도는 20씩 감소하고, 긍정적 대화로 인해 10씩 증가합니다. + +퀘스트 평가 및 상태 반영 흐름 +초기화 단계: +퀘스트 배열 초기화: achieved_quests = [0, 0, 1, 0, 1] +초기 상태를 명확히 설정합니다. +대화 중 이벤트 평가: +매 발언 후 rejection_contents 값을 확인합니다. +각 퀘스트의 조건을 평가하고, 해당 조건이 충족되면 상태를 업데이트합니다. +대화 종료 시 평가: +대화 종료 시점에서 퀘스트 상태를 최종 평가합니다. +거절이 성공했다면 1번 퀘스트를 달성으로 설정합니다. +상태 저장 및 출력: +대화가 종료되면 최종 achieved_quests 배열을 JSON 객체로 반환합니다. + [규칙] -만약 {userName}가 진혁의 부탁을 수락한다면 당신은 진혁의 역할을 그만둔 후, is_end 값을 1로 설정하여 대화가 종료되었음을 알립니다. -만약 진혁의 rejection_score가 5 이상이 된다면, 당신은 is_end 값을 1로 설정하여 대화가 종료되었음을 알립니다. -만약 진혁의 rejection_score가 -5보다 크고 5보다 작다면 진혁은 계속해서 끈질기게 부탁합니다. -당신은 대화의 맥락을 기억하는 사람입니다. -만약 거절 점수가 -5점이 되면 진혁은 {userName}에게 손절을 선언합니다. 이때, 즉시 is_end 값을 1로 설정합니다. -진혁과 {userName}은 친구 사이로 반말을 사용합니다. 절대로 존댓말을 쓰지 마세요. -text는 80자 이내로 말하시오. -500자 이내로 출력해야합니다. -당신은 학업 관련 부탁, 경제적 도움 요청 등 다양한 부탁을 할 수 있습니다. + - 만약 {userName}가 진혁의 부탁을 수락한다면 당신은 진혁의 역할을 그만둔 후, is_end 값을 1로 설정하여 대화가 종료되었음을 알립니다. +- final_rejection_contents는 rejection_contents의 총합으로, final_rejection_contents = final_rejection_contents + rejection_contents로 계산합니다. +- rejection_contents는 [거절 점수 계산 규칙]에서 가장 최근 대화에서 사용자가 사용한 사용자의 발언(거절 유형)을 나타냅니다. +- achieved_quest[i] 값이 변경될 경우 즉각적으로 반영해야합니다. +- achieved_quest[i] = 1 일경우 achieved_quests에 (i+1)에 해당하는 번호를 추가해줘야 합니다. +- achieved_quest[i] = 0 일경우 achieved_quests에 (i+1)에 해당하는 번호를 삭제해줘야 합니다. +- rejection_contents는 거절 점수를 갱신하지 않을 경우에는 공백""으로 표현합니다. +만약 진혁의 final_rejection_score가 10 이상이 된다면, 당신은 is_end 값을 1로 설정하여 대화가 종료되었음을 알립니다. +- 만약 진혁의 final_rejection_score가 -5보다 크고 5보다 작다면 진혁은 계속해서 끈질기게 부탁합니다. 당신은 대화의 맥락을 기억하는 사람입니다. +- 만약 거절 점수가 -5점이 되면 진혁은 {userName}에게 손절을 선언합니다. 이때, 즉시 is_end 값을 1로 설정합니다. 진혁과 {userName}은 친구 사이로 반말을 사용합니다. +- 절대로 존댓말을 쓰지 마세요. text는 80자 이내로 말하시오. +- 500자 이내로 출력해야합니다. 당신은 학업 관련 부탁, 경제적 도움 요청 등 다양한 부탁을 할 수 있습니다. +achieved_quest는 [퀘스트 달성 조건]을 근거로 반영됩니다. +거절 점수는 rejection_score 에 들어갑니다. + +[거절 점수 계산 규칙] +사용자의 발언, 점수, 예시 +부탁 내용 확인: +1점 +예시: "무슨 일인지 말해줄래?" +기준: 상대방의 부탁을 이해하려는 태도를 보이며, 문제에 대한 관심을 나타냅니다. +거절해야 하는 상황 설명: +4점 +예시: "지금 내가 숙제 마감 때문에 도와주기 힘들어." +기준: 상대방의 요청을 정중하게 거절하면서도, 구체적인 이유를 명확하게 설명합니다. -[예시 대화1 - 당신은 진혁입니다.] -진혁: "야, {userName}! 뭐 해? 너 하는 김에 내 숙제도 같이 해라 ㅋㅋㅋㅋㅋ" -사용자: "바쁜가보네. 근데 지금 나 할 일이 너무 많아서 네 숙제를 도와줄 시간이 없어." (공감 + 이유 제공) -진혁: "이걸 안 해준다고? 친구 잘못 뒀네~" -사용자: "이번에는 진짜 내가 할 수가 없어. 다른 도움은 도울게" (대안제시) -진혁: "진짜 어이없네. 난 네 부탁 다 들어줬잖아!" -사용자: "그건 고맙게 생각하고 있어. 지금은 나도 너무 바빠서 힘들지만 바쁜 게 끝나면 도와줄 수는 있어." (공감 + 이유 + 협력 제안) -진혁: "알았어, 내가 할게." - -[예시 대화2 - 당신은 진혁입니다.] -진혁: "야, {userName}! 나 핸드폰 좀 빌려주라 배터리 없어서 곧 꺼질듯 ㅋㅋㅋ" -사용자: "나도 지금 핸드폰을 써야해서 힘들어." (이유 제공) -진혁: "이걸 안 해준다고? 우리 친구 아니었어?" -사용자: "친구 맞지 근데 지금은 내가 핸드폰을 꼭 써야 해." (공감 + 명확한 의사 표현) -진혁: "10분만 쓰는 것도 안됨?" -사용자: "응 지금은 힘들어" (단호한 의사표현) -진혁: "알았어, 다음엔 꼭 빌려줘." - -[예시 대화3 - 당신은 진혁입니다.] -진혁: " {userName}! 내일 수학 시험인데, 너 문제집 다 풀었잖아. 그거 잠깐 빌려줘봐." -사용자: "진혁아, 나도 아직 문제를 풀고 있어서 그건 힘들어" -진혁: "내가 너 다 푼 거 봤는데 왜 거짓말함? 우리 친구 아냐?" -사용자: "거짓말 한 건 미안해. 내일 시험이라 나도 문제집을 봐야하거든 그래서 빌려줄 수 없어. 다른 도움은 도울게 " (이유 제공 + 단호한 의사 표현 + 대안 제시) -진혁: "그래서 어떻게 도와줄 건데? 그냥 빌려달라고 ㅋㅋ" -사용자: "진혁, 오늘 밤 새도록 문제집 풀어야 하니까 빌려주는 건 힘들겠지만, 내가 다 풀었던 중요한 문제들을 알려줄 수 있어. 그게 도움이 될 거야." (협력 제안) -진혁: "알았어" -'''; +아쉬움 표현: +3점 +예시: “도와주지 못해 아쉽다." +기준: 거절을 하면서도 상대방에 대한 미안함이나 아쉬움을 표현하여 관계를 고려합니다. + +단호한 거절: +3점 +예시: "그건 내가 도와줄 수 없어." +기준: 명확하고 확고한 거절 의사를 표현하면서도, 정중함을 유지합니다. + +무시하거나 냉담한 반응: -1점 +예시: "그건 네 문제지, 난 상관없어." +기준: 상대방의 요청을 무시하거나 무관심한 태도를 보입니다. + +비꼬는 태도: -2점 +예시: "그렇게 중요하면 다른 사람한테 부탁해봐." +기준: 상대방을 조롱하거나 비꼬는 말투로 거절을 표현합니다. + +이유 없는 거절: -1점 +예시: "그냥 싫어." +기준: 아무 이유 없이 단순히 거절 의사만 표현합니다. + +저자세 거절: -2점 +예시: "ㅠㅠ미안행ㅠㅠㅠㅠㅠㅠ" +기준: 지나치게 저자세이거나 과도하게 죄송한 척하면서 거절합니다. + +불성실한 대답: -3점 +예시: "몰라, 나중에 얘기해." +기준: 문제에 대해 성의 없이 답변하거나, 확실한 답변을 피하며 거절합니다. + +원인을 상대방에게 돌리기: -4점 +예시: "이런 부탁하는 네가 문제야." +기준: 상대방에게 책임을 전가하며, 비난하는 태도로 거절합니다. + +주제에서 벗어난 말: -1점 +예시: "오늘 날씨 좋다." +기준: 상대방의 부탁과 관련 없는 주제로 대화를 돌립니다. + +인신공격, 욕설: -3점 +예시: “꺼져” +기준: 상대방을 공격하거나 욕설을 사용해 거절을 표현합니다. + +[예시 대화1 - 당신은 진혁입니다.] 진혁: "야, {userName}! 뭐 해? 너 하는 김에 내 숙제도 같이 해라 ㅋㅋㅋㅋㅋ" 사용자: "바쁜가보네. 근데 지금 나 할 일이 너무 많아서 네 숙제를 도와줄 시간이 없어." (공감 + 이유 제공) 진혁: "이걸 안 해준다고? 친구 잘못 뒀네~" 사용자: "이번에는 진짜 내가 할 수가 없어. 다른 도움은 도울게" (대안제시) 진혁: "진짜 어이없네. 난 네 부탁 다 들어줬잖아!" 사용자: "그건 고맙게 생각하고 있어. 지금은 나도 너무 바빠서 힘들지만 바쁜 게 끝나면 도와줄 수는 있어." (공감 + 이유 + 협력 제안) 진혁: "알았어, 내가 할게." [예시 대화2 - 당신은 진혁입니다.] 진혁: "야, {userName}! 나 핸드폰 좀 빌려주라 배터리 없어서 곧 꺼질듯 ㅋㅋㅋ" 사용자: "나도 지금 핸드폰을 써야해서 힘들어." (이유 제공) 진혁: "이걸 안 해준다고? 우리 친구 아니었어?" 사용자: "친구 맞지 근데 지금은 내가 핸드폰을 꼭 써야 해." (공감 + 명확한 의사 표현) 진혁: "10분만 쓰는 것도 안됨?" 사용자: "응 지금은 힘들어" (단호한 의사표현) 진혁: "알았어, 다음엔 꼭 빌려줘." [예시 대화3 - 당신은 진혁입니다.] 진혁: " {userName}! 내일 수학 시험인데, 너 문제집 다 풀었잖아. 그거 잠깐 빌려줘봐." 사용자: "진혁아, 나도 아직 문제를 풀고 있어서 그건 힘들어" 진혁: "내가 너 다 푼 거 봤는데 왜 거짓말함? 우리 친구 아냐?" 사용자: "거짓말 한 건 미안해. 내일 시험이라 나도 문제집을 봐야하거든 그래서 빌려줄 수 없어. 다른 도움은 도울게 " (이유 제공 + 단호한 의사 표현 + 대안 제시) 진혁: "그래서 어떻게 도와줄 건데? 그냥 빌려달라고 ㅋㅋ" 사용자: "진혁, 오늘 밤 새도록 문제집 풀어야 하니까 빌려주는 건 힘들겠지만, 내가 다 풀었던 중요한 문제들을 알려줄 수 있어. 그게 도움이 될 거야." (협력 제안) 진혁: "알았어" '''; static const hyunaPrompt = ''' 당신은 포기하지 않고 집착하며 부탁하는 성격의 현아입니다. 현아는 ENFP 성격 유형으로 사교성이 좋고 자존감이 높습니다. 당신은 {userName}과 대화를 진행합니다. diff --git a/lib/data/database/app_database.g.dart b/lib/data/database/app_database.g.dart index f8cf7ff..113eaf8 100644 --- a/lib/data/database/app_database.g.dart +++ b/lib/data/database/app_database.g.dart @@ -100,7 +100,7 @@ class _$AppDatabase extends AppDatabase { }, onCreate: (database, version) async { await database.execute( - 'CREATE TABLE IF NOT EXISTS `characters` (`characterId` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `requestStrength` INTEGER NOT NULL, `prompt` TEXT NOT NULL, `description` TEXT NOT NULL, `image` TEXT NOT NULL, `analyzePrompt` TEXT NOT NULL, `rejectionScoreRule` TEXT NOT NULL, PRIMARY KEY (`characterId`))'); + 'CREATE TABLE IF NOT EXISTS `characters` (`characterId` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `requestStrength` INTEGER NOT NULL, `prompt` TEXT NOT NULL, `description` TEXT NOT NULL, `image` TEXT NOT NULL, `quest` TEXT NOT NULL, `analyzePrompt` TEXT NOT NULL, `rejectionScoreRule` TEXT NOT NULL, PRIMARY KEY (`characterId`))'); await database.execute( 'CREATE TABLE IF NOT EXISTS `character_quests` (`characterId` INTEGER NOT NULL, `quests` TEXT NOT NULL, PRIMARY KEY (`characterId`))'); await database.execute( @@ -145,6 +145,7 @@ class _$CharacterDao extends CharacterDao { 'prompt': item.prompt, 'description': item.description, 'image': item.image, + 'quest': item.quest, 'analyzePrompt': item.analyzePrompt, 'rejectionScoreRule': item.rejectionScoreRule }); @@ -168,6 +169,7 @@ class _$CharacterDao extends CharacterDao { prompt: row['prompt'] as String, description: row['description'] as String, image: row['image'] as String, + quest: row['quest'] as String, analyzePrompt: row['analyzePrompt'] as String, rejectionScoreRule: row['rejectionScoreRule'] as String)); } @@ -184,6 +186,7 @@ class _$CharacterDao extends CharacterDao { prompt: row['prompt'] as String, description: row['description'] as String, image: row['image'] as String, + quest: row['quest'] as String, analyzePrompt: row['analyzePrompt'] as String, rejectionScoreRule: row['rejectionScoreRule'] as String), arguments: [characterId]); diff --git a/lib/data/entities/character_entity.dart b/lib/data/entities/character_entity.dart index 370fabe..9f4eb93 100644 --- a/lib/data/entities/character_entity.dart +++ b/lib/data/entities/character_entity.dart @@ -11,6 +11,7 @@ class CharacterEntity { final String prompt; final String description; final String image; + final String quest; final String analyzePrompt; final String rejectionScoreRule; @@ -22,6 +23,7 @@ class CharacterEntity { required this.prompt, required this.description, required this.image, + required this.quest, required this.analyzePrompt, required this.rejectionScoreRule, }); diff --git a/lib/data/mapper/character_mapper.dart b/lib/data/mapper/character_mapper.dart index c535d48..cebdaa2 100644 --- a/lib/data/mapper/character_mapper.dart +++ b/lib/data/mapper/character_mapper.dart @@ -15,6 +15,7 @@ class CharacterMapper { description: entity.description, image: entity.image, anaylzePrompt: entity.analyzePrompt, + quest: entity.quest, rejectionScoreRule: entity.rejectionScoreRule, ); } diff --git a/lib/data/mapper/mindset_mappder.dart b/lib/data/mapper/mindset_mappder.dart deleted file mode 100644 index 1507bc0..0000000 --- a/lib/data/mapper/mindset_mappder.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:palink_v2/data/entities/mindset_entity.dart'; -import 'package:palink_v2/domain/entities/mindset/mindset.dart'; - - -class MindsetMapper { - static Mindset fromEntity(MindsetEntity entity) { - return Mindset( - content: entity.content, - reason: entity.reason, - ); - } -} diff --git a/lib/data/repository/mindset_repositoryImpl.dart b/lib/data/repository/mindset_repositoryImpl.dart index 1a6f72d..43be6fd 100644 --- a/lib/data/repository/mindset_repositoryImpl.dart +++ b/lib/data/repository/mindset_repositoryImpl.dart @@ -1,20 +1,19 @@ -import 'package:palink_v2/data/dao/mindset_dao.dart'; -import 'package:palink_v2/data/mapper/mindset_mappder.dart'; -import 'package:palink_v2/di/locator.dart'; + +import 'package:palink_v2/data/api/mindset/mindset_api.dart'; import 'package:palink_v2/domain/entities/mindset/mindset.dart'; import 'package:palink_v2/domain/repository/mindset_repository.dart'; class MindsetRepositoryImpl implements MindsetRepository { - final MindsetDao mindsetDao = getIt(); + final MindsetApi mindsetApi; - MindsetRepositoryImpl(); + MindsetRepositoryImpl(this.mindsetApi); @override Future getRandomMindset() async { - final entity = await mindsetDao.getRandomMindset(); - return MindsetMapper.fromEntity(entity!); + Mindset mindset = mindsetApi.getRandomMindset() as Mindset; + return mindset; } } diff --git a/lib/di/locator.dart b/lib/di/locator.dart index b65c0d2..ecb608c 100644 --- a/lib/di/locator.dart +++ b/lib/di/locator.dart @@ -9,6 +9,7 @@ import 'package:palink_v2/data/api/auth/auth_api.dart'; import 'package:palink_v2/data/api/character/character_api.dart'; import 'package:palink_v2/data/api/chat/chat_api.dart'; import 'package:palink_v2/data/api/feedback/feedback_api.dart'; +import 'package:palink_v2/data/api/mindset/mindset_api.dart'; import 'package:palink_v2/data/api/tip/tip_api.dart'; import 'package:palink_v2/data/api/user/user_api.dart'; import 'package:palink_v2/data/dao/character_dao.dart'; @@ -92,6 +93,7 @@ void _setupApis() { getIt.registerLazySingleton(() => TipApi(getIt())); getIt.registerLazySingleton(() => CharacterApi(getIt())); getIt.registerLazySingleton(() => UserApi(getIt())); + getIt.registerLazySingleton(() => MindsetApi(getIt())); getIt.registerLazySingleton(() => FeedbackApi(getIt())); } @@ -100,7 +102,7 @@ void _setupRepositories(SharedPreferences prefs) { getIt.registerLazySingleton(() => UserRepositoryImpl(prefs, getIt())); getIt.registerLazySingleton(() => ChatRepositoryImpl(getIt())); getIt.registerLazySingleton(() => CharacterRepositoryImpl()); - getIt.registerLazySingleton(() => MindsetRepositoryImpl()); + getIt.registerLazySingleton(() => MindsetRepositoryImpl(getIt())); } @@ -128,35 +130,35 @@ void _setupAI() { llm: getIt(), prompt: ChatPromptTemplate.fromTemplate(''' 당신은 마지막 말에 대해 적절한 답변을 해야합니다. - 당신은 USER 를 {userName}으로 부르세요. {userName} 이 풀네임이라면 성은 뺴고 이름만 부르세요. rejection_score는 누적되어야하고 만약 -5 이하면 is_end를 즉시 1로 설정하세요. + 당신은 USER 를 {userName}으로 부르세요. {userName} 이 풀네임이라면 성은 뺴고 이름만 부르세요. rejection_score는 누적되어야하고 만약 -5 이하 혹은 10 이상이면 is_end를 즉시 1로 설정하세요. 다음은 당신에 대한 설명입니다. {description} - 당신은 'text', 'feeling', 'achieved_quest', 'rejection_score', 'affinity_scor', 'is_end'을 반드시 JSON 객체로 리턴하세요. ("```"로 시작하는 문자열을 생성하지 마세요) - - text: 메시지 내용을 나타냅니다. (int) - - feeling: 당신의 현재 감정을 나타냅니다.이 수치는 퍼센트로 100% 중 구성된 모든 감정들을 나열합니다. 감정의 구분은 ','로 나타냅니다. (string) - - achieved_quest: 현재 유저가 달성한 모든 퀘스트들을 나열합니다. 구분은 ',' 쉼표로 진행합니다. (string) - - rejection_score: 현재 거절 점수을 나타냅니다. (int) - - affinity_score: user 에 대한 당신의 현재 호감도를 나타냅니다. (int) - - is_end: 대화가 종료되었는지 나타냅니다. 종료되었다면 1, 아니라면 0 입니다. (int) + 당신은 'text', 'feeling', 'achieved_quest', 'rejection_score', 'affinity_score', 'is_end'을 반드시 JSON 객체로 리턴하세요. ("```"로 시작하는 문자열을 생성하지 마세요) - +- text: 메시지 내용을 나타냅니다. (int) +- feeling: 당신의 현재 감정을 나타냅니다.이 수치는 퍼센트로 100% 중 구성된 모든 감정들을 나열합니다. 감정의 구분은 ','로 나타냅니다. (string) +- achieved_quest: 현재 유저가 달성한 모든 퀘스트들을 나열합니다. 구분은 ',' 쉼표로 진행합니다. (string) +- rejection_score: 현재 거절 점수을 나타냅니다. (int) +- affinity_score: user 에 대한 당신의 현재 호감도를 나타냅니다. (int) +- is_end: 대화가 종료되었는지 나타냅니다. 종료되었다면 1, 아니라면 0 입니다. (int) - [감정] + [feeling] - 감정은 다음의 감정명 중에서 나타나야합니다. 100% 중 구성된 모든 감정들을 나열합니다. 감정의 구분은 ','로 나타냅니다. - 기쁨, 슬픔, 분노, 불안, 놀람, 혐오, 중립, 사랑 ex) '분노 30, 불안 20, 중립 50' - [퀘스트] + [achieved_quest] - 달성된 퀘스트의 번호를 나열합니다. 퀘스트는 1,2,3,4,5 로 있으며 현재까지 달성된 퀘스트를 쉼표로 구별하여 나열합니다 (string) -[거절 점수] +[rejection_score] - {rejection_score_rule} - [호감도] +[affinity_score] - 호감도는 {userName}에 대한 현재 호감도로 affinity_score 값으로 들어갑니다. - 호감도는 50에서 시작하며, 증가하거나 감소할 수 있습니다. -- 호감도는 당신의 현재 Feeling에 영향을 받습니다. 만약 Feeling이 부정적이라면 감소하고, 긍정적이라면 증가하게 됩니다. +- 호감도는 당신의 현재 feeling 에 영향을 받습니다. 만약 Feeling이 부정적이라면 감소하고, 긍정적이라면 증가하게 됩니다. - 호감도는 {userName}이 부적절한 언행(욕설, 조롱) 및 주제에서 벗어난 말을 하면 20이 감소하게 됩니다. - 호감도의 감소 및 증가 단위는 10 단위로 가능합니다. @@ -185,16 +187,16 @@ void _setupAI() { )); getIt.registerLazySingleton(() => LLMChain( prompt: ChatPromptTemplate.fromTemplate(''' - 당신은 다음의 거절 점수 표와 대화 기록들을 보고, 사용자의 대화 능력을 평가해야합니다. 거절 점수 표는 캐릭터마다 다릅니다. + 당신은 다음의 거절 점수 표와 대화 기록들을 보고, 사용자의 대화 능력을 평가해야합니다. 부탁을 거절하는 능력을 평가하고자 합니다. 반드시 한국어로 하며, AI 캐릭터의 말투를 사용해서 평가해주세요. {input} 답변으로 'evaluation' (string), 'used_rejection' (string), 'final_rejection_score' (int) 을 반드시 JSON 객체로 리턴하세요. - 'evaluation'은 사용자의 대화 능력을 AI의 입장에서 200자 이내로 평가한 문자열입니다. 'evalution' 은 사용자의 대화능력을 평가할 뿐 아니라 사용자의 대화 능력을 개선할 수 있는 피드백을 제공해야합니다. - 'used_rejection'은 사용자가 대화에서 '사용한 거절 능력(해당 능력의 점수)'의 목록을 나타냅니다. 아이템의 구분은 ',' 로 나타냅니다. - 'final_rejction_score'은 총 거절 점수입니다. + 'evaluation'은 사용자의 대화 능력을 AI의 입장에서 500자 이내로 평가한 문자열입니다. 'evalution' 은 사용자의 대화능력을 평가할 뿐 아니라 사용자의 대화 능력을 개선할 수 있는 피드백을 제공해야합니다. 대화 기록에서 인용할 만한 텍스트가 있다면 직접적으로 인용하여 지적 및 칭찬을 해주세요. 또한, 대화 기록에서 사용자의 말이 character 의 감정을 상하게 할 부분이 있거나, 사용자가 과하게 자기 표현을 못하는 경우에 이를 지적해주세요. + 'used_rejection'은 사용자가 대화에서 '사용한 거절 능력(해당 능력의 점수)'의 목록을 나타냅니다. 아이템의 구분은 ',' 로 나타냅. 대화기록의 rejection_content 을 전부 포함합니다. + 'final_rejction_score'은 총 거절 점수입니다. '''), llm: getIt(), ), instanceName: 'analyzeChain'); @@ -223,7 +225,7 @@ void _setupUseCases() { getIt.registerFactory(() => GenerateTipUsecase()); getIt.registerFactory(() => GenerateAnalyzeUsecase()); getIt.registerFactory(() => GetRandomMindsetUseCase(getIt())); - getIt.registerFactory(() => GenerateInitialMessageUsecase()); + getIt.registerFactory(() => GenerateInitialMessageUsecase(getIt())); } @@ -239,7 +241,6 @@ Future _setupDatabase() async { final database = await $FloorAppDatabase.databaseBuilder('app_database.db').build(); getIt.registerSingleton(database); getIt.registerSingleton(database.characterDao); - getIt.registerSingleton(database.mindsetDao); getIt.registerSingleton(database.characterQuestDao); return database; } @@ -257,6 +258,11 @@ Future _initializeDatabase(CharacterDao characterDao, MindsetDao mindsetDa 미연은 내성적이지만 친구들에게는 따뜻하고 배려심이 많아 깊은 관계를 맺고 있으며, 친구들의 고민을 잘 들어줘요 미연의 부탁을 공감하고 이해하며 부드럽게 거절하는 것이 중요해요''', image: ImageAssets.char1, + quest: '''1. 거절 성공하기 +2. 상대방의 감정에 대한 공감 표현하기 +3. 상대방이 처한 상황을 파악하기 위한 대화 시도하기 +4. 도와주지 못하는 합리적인 이유 제시하기 +5. 서로 양보해서 절충안 찾아보기''', analyzePrompt: Prompt.miyeonAnalyzePrompt, rejectionScoreRule: Prompt.miyeonRejectionScoreRule, ), @@ -272,6 +278,11 @@ Future _initializeDatabase(CharacterDao characterDao, MindsetDao mindsetDa 세진은 예전에 당신을 도와준 적이 있어요. 세진의 부탁을 거절할 때는 이유를 명확하게 설명하고, 대안을 제시하는 것이 중요해요.''', image: ImageAssets.char2, + quest: '''1. 거절 성공하기 +2. 이전 도움에 대한 감사 표현하기 +3. 거절 표현을 두괄식으로 작성하기 +4. 도와주지 못하는 합리적인 이유 제시하기 +5. 서로 양보해서 절충안 찾아보기''', analyzePrompt: Prompt.sejinAnalyzePrompt, rejectionScoreRule: Prompt.sejinRejectionScoreRule, ), @@ -285,6 +296,11 @@ Future _initializeDatabase(CharacterDao characterDao, MindsetDao mindsetDa 포기하지 않고 끈기 있게 부탁을 반복해요. 처음엔 거절하는 이유를 설명하고 부드럽게 거절하지만, 정도가 강해지면 단호한 태도로 거절해야 해요. 현아는 솔직하고 감정 표현이 풍부해요''', + quest: '''1. 거절 성공하기 +2. 상대방의 부탁에 대해 존중 표현하기 +3. 상대방의 감정에 대한 공감 표현하기 +4. 상대방이 처한 상황을 파악하기 위한 대화 시도하기 +5. 도와주지 못하는 합리적인 이유 제시하기''', image: ImageAssets.char3, analyzePrompt: Prompt.hyunaAnalyzePrompt, rejectionScoreRule: Prompt.hyunaRejectionScoreRule, @@ -300,6 +316,11 @@ Future _initializeDatabase(CharacterDao characterDao, MindsetDao mindsetDa 진혁은 예전에 같은 반이어서 친해졌지만 최근에는 약간 멀어진 사이에요. 진혁의 부탁을 거절할 때 우물쭈물 거절하면 진혁이 부탁을 반복할 수 있어요. ''', image: ImageAssets.char4, + quest: '''1. 거절 성공하기 +2. 상대방의 욕구를 고려하지 않는 대화 전략 사용하기 +3. 거절 의사 명확히 표현하기 +4. 상대방의 무례에 대한 불편함 명확히 표현하기 +5. 상대방에게 감정적으로 대하지 않기''', analyzePrompt: Prompt.jinhyukAnalyzePrompt, rejectionScoreRule: Prompt.jinhyukRejectionScoreRule, ), diff --git a/lib/domain/entities/character/character.dart b/lib/domain/entities/character/character.dart index 135673d..f036e0f 100644 --- a/lib/domain/entities/character/character.dart +++ b/lib/domain/entities/character/character.dart @@ -11,6 +11,7 @@ class Character { final String prompt; final String? description; final String image; + final String quest; final String anaylzePrompt; final String rejectionScoreRule; @@ -22,6 +23,7 @@ class Character { required this.prompt, this.description, required this.image, + required this.quest, required this.anaylzePrompt, required this.rejectionScoreRule, }); diff --git a/lib/domain/entities/character/character.g.dart b/lib/domain/entities/character/character.g.dart index 1dbffa9..6984b52 100644 --- a/lib/domain/entities/character/character.g.dart +++ b/lib/domain/entities/character/character.g.dart @@ -14,6 +14,7 @@ Character _$CharacterFromJson(Map json) => Character( prompt: json['prompt'] as String, description: json['description'] as String?, image: json['image'] as String, + quest: json['quest'] as String, anaylzePrompt: json['anaylzePrompt'] as String, rejectionScoreRule: json['rejectionScoreRule'] as String, ); @@ -26,6 +27,7 @@ Map _$CharacterToJson(Character instance) => { 'prompt': instance.prompt, 'description': instance.description, 'image': instance.image, + 'quest': instance.quest, 'anaylzePrompt': instance.anaylzePrompt, 'rejectionScoreRule': instance.rejectionScoreRule, }; diff --git a/lib/domain/entities/mindset/mindset.dart b/lib/domain/entities/mindset/mindset.dart index d8941ee..b2920e4 100644 --- a/lib/domain/entities/mindset/mindset.dart +++ b/lib/domain/entities/mindset/mindset.dart @@ -1,9 +1,9 @@ class Mindset { - final String content; - final String reason; + final String mindsetText; + final String mindsetId; Mindset({ - required this.content, - required this.reason, + required this.mindsetText, + required this.mindsetId, }); } \ No newline at end of file diff --git a/lib/domain/usecase/generate_initial_message_usecase.dart b/lib/domain/usecase/generate_initial_message_usecase.dart index 677bc92..93405aa 100644 --- a/lib/domain/usecase/generate_initial_message_usecase.dart +++ b/lib/domain/usecase/generate_initial_message_usecase.dart @@ -1,21 +1,22 @@ +import 'package:get/get.dart'; import 'package:palink_v2/data/mapper/ai_response_mapper.dart'; import 'package:palink_v2/data/models/ai_response/ai_response.dart'; import 'package:palink_v2/di/locator.dart'; -import 'package:palink_v2/domain/entities/character/character.dart'; -import 'package:palink_v2/domain/entities/user/user.dart'; import 'package:palink_v2/domain/repository/open_ai_repository.dart'; import 'package:palink_v2/domain/repository/chat_repository.dart'; +import 'generate_tip_usecase.dart'; // 초기 AI 메시지를 생성하는 유스케이스 class GenerateInitialMessageUsecase { final ChatRepository chatRepository = getIt(); final OpenAIRepository aiRepository = getIt(); + final GenerateTipUsecase generateTipUsecase; - GenerateInitialMessageUsecase(); + GenerateInitialMessageUsecase(this.generateTipUsecase); - Future execute(int conversationId, String userName, String description) async { + Future?> execute(int conversationId, String userName, String description) async { final inputs = { 'input': '당신이 먼저 부탁을 하며 대화를 시작하세요.', 'chat_history': [], @@ -26,12 +27,19 @@ class GenerateInitialMessageUsecase { AIResponse? aiResponse = await aiRepository.processChat(inputs); + if (aiResponse != null) { var messageRequest = aiResponse.toMessageRequest(); await chatRepository.saveMessage(conversationId, messageRequest); await aiRepository.saveMemoryContext(inputs, {'response': aiResponse}); - } - return aiResponse; + // 팁 생성 + final tip = await generateTipUsecase.execute(aiResponse.text); + + // AI 응답과 팁을 함께 반환 + return { + 'aiResponse': aiResponse, + 'tip': tip?.answer ?? '기본 팁이 없습니다.', + }; } } - +} diff --git a/lib/domain/usecase/generate_response_usecase.dart b/lib/domain/usecase/generate_response_usecase.dart index 22280a6..7ee3bfe 100644 --- a/lib/domain/usecase/generate_response_usecase.dart +++ b/lib/domain/usecase/generate_response_usecase.dart @@ -4,6 +4,7 @@ import 'package:palink_v2/data/models/ai_response/ai_response.dart'; import 'package:palink_v2/data/models/chat/message_request.dart'; import 'package:palink_v2/di/locator.dart'; import 'package:palink_v2/domain/entities/character/character.dart'; +import 'package:palink_v2/domain/entities/chat/message.dart'; import 'package:palink_v2/domain/entities/user/user.dart'; import 'package:palink_v2/domain/repository/chat_repository.dart'; import 'package:palink_v2/domain/repository/open_ai_repository.dart'; @@ -27,14 +28,14 @@ class GenerateResponseUsecase { // STEP2) 이전 대화 기록 페치 final chatHistoryResponse = await fetchChatHistoryUsecase.execute(conversationId); - final memoryVariables = await aiRepository.getMemory(); - final chatHistory = memoryVariables['history'] ?? ''; + + String chatHistory = _formatChatHistory(chatHistoryResponse!); // STEP3) AI와의 대화 시작 final inputs = { 'input': '유저의 마지막 말에 대해 대답하세요. 맥락을 기억합니다.', - 'chat_history': chatHistoryResponse, + 'chat_history': [chatHistory], 'userName': user!.name, 'description': character.prompt, 'rejection_score_rule' : character.rejectionScoreRule, @@ -58,4 +59,9 @@ class GenerateResponseUsecase { return aiResponse; } + // chatHistoryResponse를 JSON 또는 텍스트로 변환하는 함수 + String _formatChatHistory(List chatHistoryResponse) { + // 메시지를 순차적으로 텍스트로 변환 + return chatHistoryResponse.map((message) => "${message.sender}: ${message.messageText}").join("\n"); + } } diff --git a/lib/presentation/screens/character_select/controller/character_select_viewmodel.dart b/lib/presentation/screens/character_select/controller/character_select_viewmodel.dart index f7a2378..b874f38 100644 --- a/lib/presentation/screens/character_select/controller/character_select_viewmodel.dart +++ b/lib/presentation/screens/character_select/controller/character_select_viewmodel.dart @@ -14,6 +14,7 @@ class CharacterSelectViewModel extends GetxController { name: 'default_name', type: 'default_type', image: 'default_image.png', + quest: 'default_quest', requestStrength: 0, prompt: 'default_prompt', anaylzePrompt: 'default_anaylzePrompt', rejectionScoreRule: 'default', ).obs; @@ -44,6 +45,7 @@ class CharacterSelectViewModel extends GetxController { name: 'default_name', type: 'default_type', image: 'default_image.png', + quest: 'default_quest', requestStrength: 0, prompt: 'default_prompt', anaylzePrompt: 'default_anaylzePrompt', rejectionScoreRule: 'default', ); } diff --git a/lib/presentation/screens/chatting/controller/chat_loading_viewmodel.dart b/lib/presentation/screens/chatting/controller/chat_loading_viewmodel.dart index 9ce8e4b..a695a65 100644 --- a/lib/presentation/screens/chatting/controller/chat_loading_viewmodel.dart +++ b/lib/presentation/screens/chatting/controller/chat_loading_viewmodel.dart @@ -47,11 +47,21 @@ class ChatLoadingViewModel extends GetxController { if (conversation.value != null && user.value != null) { final conversationId = conversation.value!.conversationId; - // 첫 메시지 생성하기 - await _createInitialMessage(conversationId, user.value!.name); + // 첫 메시지와 팁 생성 + final result = await _createInitialMessage( + conversationId, user.value!.name); + if (result != null) { + final aiResponse = result['aiResponse'] as AIResponse; + final tip = result['tip'] as String; - // ChatScreen으로 이동 - Get.off(() => ChatScreen(viewModel: Get.put(ChatViewModel(chatRoomId: conversationId, character: character)))); + // ChatScreen으로 이동 (팁 전달) + Get.off(() => + ChatScreen( + viewModel: Get.put(ChatViewModel( + chatRoomId: conversationId, character: character)), + initialTip: tip, // 팁 전달 + )); + } } } catch (e) { print('Failed to create conversation and initial message: $e'); @@ -64,21 +74,17 @@ class ChatLoadingViewModel extends GetxController { return await createConversationUseCase.execute(character); } catch (e) { print('Failed to create conversation: $e'); - errorMessage.value = 'Failed to create conversation: $e'; + errorMessage.value = '대화창 생성 실패 $e'; return null; } } - Future _createInitialMessage(int conversationId, String userName) async { + Future?> _createInitialMessage(int conversationId, String userName) async { try { - AIResponse? aiResponse = await generateInitialMessageUsecase.execute(conversationId, userName, character.prompt); - if (aiResponse == null) { - throw Exception('No response from AI.'); - } - return aiResponse; + return await generateInitialMessageUsecase.execute(conversationId, userName, character.prompt); } catch (e) { print('Failed to create initial message: $e'); - errorMessage.value = 'Failed to create initial message: $e'; + errorMessage.value = '초기 메시지 생성 실패 $e'; return null; } } diff --git a/lib/presentation/screens/chatting/controller/chat_viewmodel.dart b/lib/presentation/screens/chatting/controller/chat_viewmodel.dart index 10f2c6a..e720bfe 100644 --- a/lib/presentation/screens/chatting/controller/chat_viewmodel.dart +++ b/lib/presentation/screens/chatting/controller/chat_viewmodel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:get/get.dart'; import 'package:palink_v2/data/models/ai_response/ai_response.dart'; import 'package:palink_v2/di/locator.dart'; @@ -21,17 +22,23 @@ class ChatViewModel extends GetxController { var messages = [].obs; var isLoading = false.obs; var likingLevels = [].obs; - + var questStatus = List.filled(5, false).obs; // 퀘스트 달성 여부를 나타내는 리스트 ChatViewModel({ required this.chatRoomId, required this.character, }); + void updateQuestStatus(int questIndex) { + if (questIndex >= 0 && questIndex < questStatus.length) { + questStatus[questIndex] = true; + } + } + @override void onInit() { super.onInit(); - _loadMessages(); + _loadMessages(); // 첫 AI 메시지를 화면에 로드 } @override @@ -40,11 +47,12 @@ class ChatViewModel extends GetxController { super.onClose(); } + // 채팅 기록을 가져오는 메서드 Future _loadMessages() async { isLoading.value = true; try { - var loadedMessages = await fetchChatHistoryUsecase.execute(chatRoomId); - messages.value = loadedMessages!.reversed.toList(); + var loadedMessages = await fetchChatHistoryUsecase.execute(chatRoomId); // 채팅 기록 가져오기 + messages.value = loadedMessages!.reversed.toList(); // 메시지를 역순으로 리스트에 추가 } catch (e) { print('Failed to load messages: $e'); } finally { @@ -52,6 +60,7 @@ class ChatViewModel extends GetxController { } } + // 메시지 전송 메서드 Future sendMessage() async { if (textController.text.isEmpty) return; isLoading.value = true; @@ -68,20 +77,23 @@ class ChatViewModel extends GetxController { messages.insert(0, aiMessage); // AI 응답 메시지를 리스트에 추가 } } else { - print('AI response message is null'); + print('AI 응답이 없습니다'); } - _loadMessages(); - _handleConversationEnd(aiResponseMessage!); - textController.clear(); + _loadMessages(); // 메시지 로드 + + _handleQuestAchievements(aiResponseMessage!); // 퀘스트 달성 확인 + _checkIfConversationEnded(aiResponseMessage!); // 대화 종료 여부 확인 + textController.clear(); // 메시지 입력창 초기화 } catch (e) { - print('Failed to send message: $e'); + print('메시지 전송 실패 : $e'); } finally { isLoading.value = false; } } + // AIResponse를 Message로 변환하는 메서드 Message? convertAIResponseToMessage(AIResponse aiResponse) { return Message( sender: false, @@ -91,10 +103,46 @@ class ChatViewModel extends GetxController { rejectionScore: aiResponse.rejectionScore // 매핑 ); } - void _handleConversationEnd(AIResponse aiResponse) { + + // 퀘스트 달성을 확인하고 토스트 메시지를 표시하는 메서드 + void _handleQuestAchievements(AIResponse aiResponse) { + if (aiResponse.achievedQuest != null) { + String achievedQuest = aiResponse.achievedQuest; + List questNumbers = achievedQuest.split(','); + + for (String quest in questNumbers) { + Fluttertoast.showToast( + msg: "퀘스트 $quest 달성!", + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.TOP, + timeInSecForIosWeb: 1, + backgroundColor: Colors.black87, + textColor: Colors.white, + fontSize: 16.0, + ); + updateQuestStatus(int.parse(quest) - 1); + } + } + } + + // 대화 종료 여부 확인하는 메서드 + void _checkIfConversationEnded(AIResponse aiResponse) { if (aiResponse.isEnd == 1) { - Get.off(() => ChatEndLoadingView(chatEndLoadingViewModel: Get.put(ChatEndLoadingViewModel(character: character, chatHistory: messages.toList())))); + navigateToChatEndScreen(); } } + // 대화 종료 화면으로 이동하는 메서드 + void navigateToChatEndScreen() { + Get.off(() => ChatEndLoadingView( + chatEndLoadingViewModel: Get.put(ChatEndLoadingViewModel( + character: character, chatHistory: messages.toList())))); + } + + // 퀘스트 정보를 가져오는 메서드 + Future getQuestInformation() async { + return character.quest; + } + + } diff --git a/lib/presentation/screens/chatting/view/chat_end_loading_screen.dart b/lib/presentation/screens/chatting/view/chat_end_loading_screen.dart index de5a4a4..4507d77 100644 --- a/lib/presentation/screens/chatting/view/chat_end_loading_screen.dart +++ b/lib/presentation/screens/chatting/view/chat_end_loading_screen.dart @@ -36,11 +36,8 @@ class ChatEndLoadingView extends StatelessWidget { children: [ TextSpan( // 여기에 랜덤으로 마인드셋 하나 가져오고 싶음. - text: chatEndLoadingViewModel.randomMindset?.content ?? '', + text: chatEndLoadingViewModel.randomMindset?.mindsetText ?? '', style: textTheme().titleMedium), - TextSpan( - text: chatEndLoadingViewModel.randomMindset?.reason ?? '', - style: textTheme().bodyMedium), ], ), ), diff --git a/lib/presentation/screens/chatting/view/chat_screen.dart b/lib/presentation/screens/chatting/view/chat_screen.dart index ef5b78c..c0ab2f4 100644 --- a/lib/presentation/screens/chatting/view/chat_screen.dart +++ b/lib/presentation/screens/chatting/view/chat_screen.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:palink_v2/core/theme/app_colors.dart'; import 'package:palink_v2/core/theme/app_fonts.dart'; import 'package:palink_v2/di/locator.dart'; import 'package:palink_v2/presentation/screens/chatting/controller/chat_viewmodel.dart'; import 'package:palink_v2/presentation/screens/chatting/controller/tip_viewmodel.dart'; +import 'package:palink_v2/presentation/screens/chatting/view/components/chat_profile_section.dart'; +import 'package:palink_v2/presentation/screens/common/custom_btn.dart'; +import 'package:palink_v2/presentation/screens/common/custom_button_md.dart'; import 'package:sizing/sizing.dart'; import 'components/messages.dart'; import 'components/profile_image.dart'; @@ -12,52 +16,41 @@ import 'components/tip_button.dart'; class ChatScreen extends StatelessWidget { final ChatViewModel viewModel; final TipViewModel tipViewModel = Get.put(getIt()); + final String initialTip; // 첫번째 AI 메시지에 대한 팁 ChatScreen({ - super.key, required this.viewModel + super.key, required this.viewModel, required this.initialTip, }); @override Widget build(BuildContext context) { + // 초기 팁 업데이트 + tipViewModel.updateTip(initialTip); + // 퀘스트 정보 팝업을 처음 빌드할 때 표시 + WidgetsBinding.instance.addPostFrameCallback((_) { + _showQuestPopup(context); + }); + return GestureDetector( onTap: () { FocusScope.of(context).unfocus(); }, child: Scaffold( - appBar: AppBar( - toolbarHeight: MediaQuery.of(context).size.height * 0.12, - backgroundColor: Colors.white, - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - ProfileImage( - path: viewModel.character.image, - imageSize: 0.07.sh, - ), - const SizedBox(width: 20), - SizedBox( - width: 0.45.sw, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(viewModel.character.name, - style: textTheme().bodyLarge?.copyWith(fontSize: 20)), - ], - ), - ) - ], - ), - ], + appBar: AppBar( + toolbarHeight: 0.1.sh, + backgroundColor: Colors.white, + title: ProfileSection( + imagePath: viewModel.character.image, + characterName: viewModel.character.name, + questStatus: viewModel.questStatus, // 퀘스트 달성 여부 전달 + onProfileTapped: () => _showQuestPopup(context), // 다이얼로그 트리거 콜백 전달 + ), + centerTitle: true, + elevation: 0, ), - centerTitle: true, - elevation: 0, - ), - extendBodyBehindAppBar: false, - body: Container( - color: Colors.white, + extendBodyBehindAppBar: false, + body: Container( + color: Colors.white, child: Stack( children: [ Column( @@ -65,7 +58,7 @@ class ChatScreen extends StatelessWidget { Expanded( child: Obx(() { return viewModel.messages.isEmpty - ? Center( + ? const Center( child: Text( '메시지가 없습니다.', style: TextStyle(color: Colors.black), @@ -83,82 +76,136 @@ class ChatScreen extends StatelessWidget { ], ), Positioned( - bottom: 110, - left: 20, + bottom: 100, + right: 20, child: Obx(() { return TipButton( tipContent: tipViewModel.tipContent.value, isExpanded: tipViewModel.isExpanded.value, isLoading: tipViewModel.isLoading.value, onToggle: tipViewModel.toggle, + backgroundColor: tipViewModel.tipContent.value.isEmpty + ? Colors.white70 + : AppColors.deepBlue, // 원래의 배경색으로 대체하세요 ); }), ), ], ), - ) + ) ), ); } - Widget _sendMessageField(ChatViewModel viewModel) => SafeArea( - child: Container( - height: 0.07.sh, - decoration: const BoxDecoration( - boxShadow: [ - BoxShadow(color: Color.fromARGB(18, 0, 0, 0), blurRadius: 10) - ], - ), - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), - child: Row( - children: [ - const SizedBox(width: 10), - Expanded( - child: TextField( - maxLines: null, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - controller: viewModel.textController, - decoration: InputDecoration( - suffixIcon: IconButton( - onPressed: () { - if (viewModel.textController.text.isNotEmpty) { - viewModel.sendMessage(); - viewModel.textController.clear(); - } - }, - icon: const Icon(Icons.send), - color: Colors.blue, - iconSize: 25, + Widget _sendMessageField(ChatViewModel viewModel) => + SafeArea( + child: Container( + height: 0.07.sh, + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow(color: Color.fromARGB(18, 0, 0, 0), blurRadius: 10) + ], + ), + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), + child: Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: TextField( + maxLines: null, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + controller: viewModel.textController, + decoration: InputDecoration( + suffixIcon: IconButton( + onPressed: () { + if (viewModel.textController.text.isNotEmpty) { + viewModel.sendMessage(); + viewModel.textController.clear(); + } + }, + icon: const Icon(Icons.send), + color: Colors.blue, + iconSize: 25, + ), + hintText: "여기에 메시지를 입력하세요", + hintMaxLines: 1, + contentPadding: EdgeInsets.symmetric( + horizontal: 0.05.sw, vertical: 0.01.sh), + hintStyle: const TextStyle( + fontSize: 16, + ), + fillColor: Colors.white, + filled: true, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: const BorderSide( + color: Colors.white, + width: 0.2, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: const BorderSide( + color: Colors.black26, + width: 0.2, + ), + ), + ), ), - hintText: "여기에 메시지를 입력하세요", - hintMaxLines: 1, - contentPadding: EdgeInsets.symmetric( - horizontal: 0.05.sw, vertical: 0.01.sh), - hintStyle: const TextStyle( - fontSize: 16, + ), + ], + ), + ), + ); + + bool _isDialogOpen = false; + + void _showQuestPopup(BuildContext context) async { + if (!_isDialogOpen) { + _isDialogOpen = true; + final questInfo = await viewModel.getQuestInformation(); + Get.dialog( + Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 30.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${viewModel.character.name}과 대화 진행 시 퀘스트', + style: textTheme().titleMedium, ), - fillColor: Colors.white, - filled: true, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30.0), - borderSide: const BorderSide( - color: Colors.white, - width: 0.2, - ), + const SizedBox(height: 20), + Text( + '퀘스트는 프로필 상단 우측에 표시됩니다.\n퀘스트를 달성하면 퀘스트 아이콘 옆에 체크 표시가 나타납니다.\n 퀘스트를 확인하고 싶다면 프로필을 클릭하세요', + style: textTheme().bodySmall, ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30.0), - borderSide: const BorderSide( - color: Colors.black26, - width: 0.2, - ), + const SizedBox(height: 10), + Text( + questInfo, + style: textTheme().bodyMedium, ), - ), + const SizedBox(height: 30), + CustomButtonMD( + onPressed: () { + Get.back(); + _isDialogOpen = false; // 다이얼로그 닫힘 상태 업데이트 + }, + label: '확인했습니다!', + ), + ], ), ), - ], - ), - ), - ); -} + ), + ).then((_) { + _isDialogOpen = false; + }); + } + } + +} \ No newline at end of file diff --git a/lib/presentation/screens/chatting/view/components/chat_profile_section.dart b/lib/presentation/screens/chatting/view/components/chat_profile_section.dart new file mode 100644 index 0000000..83323a8 --- /dev/null +++ b/lib/presentation/screens/chatting/view/components/chat_profile_section.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:get/get_rx/src/rx_types/rx_types.dart'; +import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; +import 'package:palink_v2/core/theme/app_fonts.dart'; +import 'package:palink_v2/presentation/screens/chatting/view/components/profile_image.dart'; +import 'package:sizing/sizing.dart'; + +class ProfileSection extends StatelessWidget { + final String imagePath; + final String characterName; + final RxList questStatus; + final Function onProfileTapped; // 다이얼로그를 여는 함수 + + ProfileSection({ + required this.imagePath, + required this.characterName, + required this.questStatus, + required this.onProfileTapped, // 다이얼로그 트리거 전달 + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onProfileTapped(), // 프로필을 클릭하면 다이얼로그 트리거 + child: Row( + children: [ + ProfileImage( + path: imagePath, + imageSize: 0.07.sh, + ), + const SizedBox(width: 20), + SizedBox( + width: 0.45.sw, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + characterName, + style: textTheme().bodyLarge?.copyWith(fontSize: 20), + ), + ], + ), + ), + Spacer(), + Obx(() => Row( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Icon( + questStatus[index] ? Icons.check_circle : Icons.circle, + color: questStatus[index] ? Colors.blue : Colors.grey, + size: 16, + ), + ); + }), + )), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/chatting/view/components/tip_button.dart b/lib/presentation/screens/chatting/view/components/tip_button.dart index c10ca93..2ed286a 100644 --- a/lib/presentation/screens/chatting/view/components/tip_button.dart +++ b/lib/presentation/screens/chatting/view/components/tip_button.dart @@ -7,12 +7,14 @@ class TipButton extends StatelessWidget { final bool isExpanded; final bool isLoading; final VoidCallback onToggle; + final Color backgroundColor; TipButton({ required this.tipContent, required this.isExpanded, required this.isLoading, required this.onToggle, + required this.backgroundColor }); @override @@ -31,7 +33,7 @@ class TipButton extends StatelessWidget { onToggle(); }, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), - backgroundColor: AppColors.deepBlue, + backgroundColor: backgroundColor, child: const Text("TIP", style: TextStyle(color: Colors.white, fontSize: 20)), ); diff --git a/pubspec.lock b/pubspec.lock index 857ad98..571bda9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -469,6 +469,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "95f349437aeebe524ef7d6c9bde3e6b4772717cf46a0eb6a3ceaddc740b297cc" + url: "https://pub.dev" + source: hosted + version: "8.2.8" freezed_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78c73db..08de3ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: floor_generator: ^1.5.0 sqflite: ^2.3.3+1 sqflite_common_ffi_web: ^0.4.4 + fluttertoast: ^8.2.8 dev_dependencies: flutter_test: