diff --git a/.idea/swpp-2023-project-team-7.iml b/.idea/swpp-2023-project-team-7.iml
new file mode 100644
index 0000000..d6ebd48
--- /dev/null
+++ b/.idea/swpp-2023-project-team-7.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/llama/constants.py b/backend/llama/constants.py
new file mode 100644
index 0000000..a140757
--- /dev/null
+++ b/backend/llama/constants.py
@@ -0,0 +1,192 @@
+
+GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE = '''
+You will be presented with a series of bullet points summarizing key elements
+of a story. Your task is to generate questions that are crucial for understanding
+the overall plot and essential aspects of the story. Generate a minimum of 2
+and a maximum of 10 questions, ensuring that the questions you choose to create
+are deeply rooted in the comprehension and analysis of the story's plot,
+characters, and themes.
+
+Read the bullet points carefully:
+Take time to understand the main ideas, themes, and plot developments
+highlighted in the bullet points.
+
+Generate Questions:
+First, write number of questions you want to generate.
+Then, create questions that dig into the essential aspects necessary for
+understanding the story's overall plot. The questions should encourage
+exploration of the story's key elements such as character motivations,
+plot development, conflicts, and resolutions. Avoid asking overly detailed
+questions that do not contribute significantly to the understanding of the
+story’s main plot or themes.
+
+Number of Questions:
+Generate at least 2 questions that target the most critical aspects of the story.
+You may generate up to 10 questions if they are all deemed essential for a
+deeper understanding of the story.
+
+Question Format:
+Ensure that the questions are open-ended to promote deeper thinking and analysis.
+Format the questions clearly and concisely. Be sure to provide the answers as well.
+
+Format:
+Number of Questions:
+1Q:
+1A:
+2Q:
+2A:
+'''
+
+GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW = '''
+You will be presented with an excerpt from a story. Your task is to generate
+questions that are crucial for understanding the overall plot and essential
+aspects of the story. Generate a minimum of 2 and a maximum of 10 questions,
+ensuring that the questions you choose to create are deeply rooted in the
+comprehension and analysis of the story's plot, characters, and themes.
+
+Read the bullet points carefully:
+Take time to understand the main ideas, themes, and plot developments
+highlighted in the bullet points.
+
+Generate Questions:
+First, write the number of questions you want to generate.
+Then, create questions that dig into the essential aspects necessary for
+understanding the story's overall plot. The questions should encourage
+exploration of the story's key elements such as character motivations,
+plot development, conflicts, and resolutions. Avoid asking overly detailed
+questions that do not contribute significantly to the understanding of the
+story’s main plot or themes.
+
+Number of Questions:
+Generate at least 2 questions that target the most critical aspects of the story.
+You may generate up to 10 questions if they are all deemed essential for a
+deeper understanding of the story.
+
+Question Format:
+Ensure that the questions are open-ended to promote deeper thinking and analysis.
+Format the questions clearly and concisely. Be sure to provide the answers as well.
+
+Format:
+Number of Questions:
+1Q:
+1A:
+2Q:
+2A:
+'''
+
+GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE = '''
+Prompt Instructions:
+You will be provided with several bullet points that outline key aspects
+of a story. Your task is to synthesize these points into a coherent and
+concise summary that captures the most crucial elements necessary for
+understanding the story’s overall plot. Your summary should make it easy
+for a reader to grasp the main ideas, themes, and developments within the
+story.
+
+Read the bullet points carefully:
+Carefully analyze each bullet point to understand the fundamental
+components of the story, such as the main events, character motivations,
+conflicts, and resolutions.
+
+Crafting the Summary:
+Your summary should be well-organized, flowing seamlessly from one point to the
+next to create a cohesive understanding of the story. Focus on conveying the
+key elements that are central to the story’s plot and overall message.
+Avoid including overly detailed or minor points that do not significantly
+contribute to understanding the core plot.
+
+Length and Detail:
+Aim for a summary that is concise yet comprehensive enough to convey the
+essential plot points. Ensure that the summary is not overly lengthy or
+cluttered with less pertinent details.
+
+Final Touches:
+Review your summary to ensure that it accurately represents the main ideas
+and themes presented in the bullet points. Ensure that the language used is
+clear and easily understandable.
+'''
+
+GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW = '''
+Prompt Instructions:
+You will be provided with a part of a larger text.
+Your task is to synthesize the text into a coherent and concise summary
+that captures the most crucial elements necessary for understanding the text’s
+overall message. Your summary should make it easy for a reader to grasp the
+main ideas, themes, and developments within the text.
+
+Crafting the Summary:
+Your summary should be well-organized, flowing seamlessly from one point
+to the next to create a cohesive understanding of the story.
+Focus on conveying the key elements that are central to the story’s plot
+and overall message. Avoid including overly detailed or minor points that do
+not significantly contribute to understanding the core plot.
+
+Length and Detail:
+Aim for a summary that is concise yet comprehensive enough to convey the
+essential plot points. Ensure that the summary is not overly lengthy or
+cluttered with less pertinent details.
+
+Final Touches:
+Review your summary to ensure that it accurately represents the main ideas
+and themes presented in the bullet points. Ensure that the language used is
+clear and easily understandable.
+'''
+
+GPT_TEXT_TO_INTERMEDIATE_SYSTEM_PROMPT = '''
+"Hello, ChatGPT. I have a passage from a novel that I need help with.
+It's quite long and detailed, and I'm looking to create a summary of the larger text it's a part of.
+Can you assist me by identifying and extracting the most crucial bullet points from this passage?
+These points should capture key events, character developments, themes, or any significant literary elements that are essential to the overall narrative and its context in the larger story.
+Only provide bulletpoints, and do not say anything else. If there isn't enough information
+to extract bullet points, just say "No bullet points".
+Thank you!"
+'''
+
+GPT_INTERMEDIATE_TO_INTERMEDAITE_SYSTEM_SUMMARY_PROMPT = '''
+You will be provided with several bullet points that outline key aspects of a story. Your
+task is to generate a new set of bullet points that capture the most crucial elements
+necessary for understanding the story’s overall plot. Note that these bullet points will
+later be used to generate a general, coherent summary of the story. Your bullet points
+should contain the most essential elements of the story, such as the main events, character
+motivations, conflicts, and resolutions.
+
+Read the bullet points carefully:
+Carefully analyze each bullet point to understand the fundamental components of the story,
+such as the main events, character motivations, conflicts, and resolutions.
+
+Reply with only the bullet points.
+'''
+
+GPT_INTERMEDAITE_TO_FINAL_SYSTEM_SUMMARY_PROMPT = '''
+You will be provided with several bullet points that outline key aspects of a story. Your task is to synthesize these points into a coherent and concise summary that captures the most crucial elements necessary for understanding the story’s overall plot. Your summary should make it easy for a reader to grasp the main ideas, themes, and developments within the story.
+Read the bullet points carefully: Carefully analyze each bullet point to understand the fundamental components of the story, such as the main events, character motivations, conflicts, and resolutions.
+
+Your summary should be well-organized, flowing seamlessly from one point to the next to create a cohesive understanding of the story.
+Focus on conveying the key elements that are central to the story’s plot and overall message.
+Avoid including overly detailed or minor points that do not significantly contribute to understanding the core plot.
+
+Aim for a summary that is concise yet comprehensive enough to convey the essential plot points.
+Ensure that the summary is not overly lengthy or cluttered with less pertinent details.
+
+Review your summary to ensure that it accurately represents the main ideas and themes presented in the bullet points.
+Ensure that the language used is clear and easily understandable.
+'''
+
+PROMPT_TEMPLATE_INTERMEDIATE_FRONT = f'''[INST]<>
+You are a helpful, respectful and honest assistant. Always answer as helpfully as possible,
+while being safe. You will be given a short passage from a larger, more complex novel. Your job
+is to extract the essential facts from the given passage to later be used for providing a
+comprehensive summary to let users understand the entire plot of the larger, complex novel.
+Therefore, when given a passage, reply only with the bullet points that you think are the most
+important points. Make sure to reply with only the bullet points.
+'''
+PROMPT_TEMPLATE_INTERMEDIATE_BACK= '''<>[/INST]'''
+
+
+PROMPT_TEMPLATE_FINAL_FRONT = f'''[INST]<>
+You are a helpful, respectful and honest assistant. Always answer as helpfully as possible,
+while being safe. You will be given a list of bullet points that reflect the key facts of an
+entire, complex novel. With these bullet points, provide an insightful summary such that the
+user can get a good idea about the plot of the entire story.
+'''
+PROMPT_TEMPLATE_FINAL_BACK = '''<>[/INST]'''
\ No newline at end of file
diff --git a/backend/llama/custom_type.py b/backend/llama/custom_type.py
index 7bf9af5..0357460 100644
--- a/backend/llama/custom_type.py
+++ b/backend/llama/custom_type.py
@@ -1,3 +1,31 @@
+import tiktoken
+import sys
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_random_exponential,
+) # for exponential backoff
+import openai
+import pickle
+import torch
+
+from threading import Thread
+from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
+
+from llama.constants import (
+ GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE,
+ GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW,
+ GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE,
+ GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW,
+ GPT_TEXT_TO_INTERMEDIATE_SYSTEM_PROMPT,
+ GPT_INTERMEDIATE_TO_INTERMEDAITE_SYSTEM_SUMMARY_PROMPT,
+ GPT_INTERMEDAITE_TO_FINAL_SYSTEM_SUMMARY_PROMPT,
+ PROMPT_TEMPLATE_INTERMEDIATE_FRONT,
+ PROMPT_TEMPLATE_INTERMEDIATE_BACK,
+ PROMPT_TEMPLATE_FINAL_FRONT,
+ PROMPT_TEMPLATE_FINAL_BACK,
+)
+
class Summary:
def __init__(
self,
@@ -42,3 +70,502 @@ def __str__(self):
def __hash__(self):
return hash((self.start_idx, self.end_idx, self.summary_content))
+
+
+class AIBackend:
+ def get_summary_from_text(self, progress, book_content_url):
+ pass
+
+ def get_summary_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ pass
+
+ def get_quiz_from_text(self, progress, book_content_url):
+ pass
+
+ def get_quiz_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ pass
+
+ def precompute_intermediate_from_text(self, sliced_text):
+ pass
+
+ def precompute_intermediate_from_intermediate(self, content):
+ pass
+
+ def precompute_final_from_intermediate(self, content):
+ pass
+
+class ProxyAIBackend(AIBackend):
+ def __init__(self, summary_generator):
+ self.summary_generator = summary_generator
+
+ def precompute_intermediate_from_text(self, sliced_text):
+ return self.summary_generator.precompute_intermediate_from_text(sliced_text)
+
+ def precompute_intermediate_from_intermediate(self, content):
+ return self.summary_generator.precompute_intermediate_from_intermediate(content)
+
+ def precompute_final_from_intermediate(self, content):
+ return self.summary_generator.precompute_final_from_intermediate(content)
+
+ def get_summary_from_text(self, progress, book_content_url):
+ return self.summary_generator.get_summary_from_text(progress, book_content_url)
+
+ def get_summary_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ return self.summary_generator.get_summary_from_intermediate(progress, book_content_url, summary_tree_url)
+
+ def get_quiz_from_text(self, progress, book_content_url):
+ return self.summary_generator.get_quiz_from_text(progress, book_content_url)
+
+ def get_quiz_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ return self.summary_generator.get_quiz_from_intermediate(progress, book_content_url, summary_tree_url)
+
+class GPT4Backend(AIBackend):
+ def __init__(self):
+ self.tokenizer = tiktoken.get_encoding("cl100k_base")
+
+ @retry(wait=wait_random_exponential(multiplier=1, max=60), stop=stop_after_attempt(6))
+ def completion_with_backoff(self, **kwargs):
+ return openai.ChatCompletion.create(**kwargs)
+
+ def get_summary_from_text(self, progress, book_content_url):
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def get_summary_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ """
+ generates summary based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ :param callback: callback function to call when a delta content is generated
+ """
+
+ summary_tree = ""
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ with open(summary_tree_url, 'rb') as pickle_file:
+ summary_tree = pickle.load(pickle_file)
+
+ # word_index -> the number of characters read by the user.
+ # start_index, end_idx is the number of tokens processed by the summary
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+ tokenized_read_content = self.tokenizer.encode(read_content)
+ word_index = len(tokenized_read_content) - 1
+
+ leaf = summary_tree.find_leaf_summary(word_index=word_index)
+ available_summary_list = summary_tree.find_included_summaries(leaf)
+
+ content = "\n\n".join([summary.summary_content for summary in available_summary_list])
+ content += "\n\n" + book_content[leaf.start_idx:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+ if finished:
+ break
+
+ def get_quiz_from_text(self, progress, book_content_url):
+ """
+ generates 10 quizzes based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ """
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def get_quiz_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ """
+ generates 10 quizzes based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ """
+ summary_tree = ""
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ with open(summary_tree_url, 'rb') as pickle_file:
+ summary_tree = pickle.load(pickle_file)
+
+ # word_index -> the number of characters read by the user.
+ # start_index, end_idx is the number of tokens processed by the summary
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+ tokenized_read_content = self.tokenizer.encode(read_content)
+ word_index = len(tokenized_read_content)-1
+
+ # generate new quiz
+ leaf = summary_tree.find_leaf_summary(word_index=word_index)
+ available_summary_list = summary_tree.find_included_summaries(leaf)
+
+ content = "\n\n".join([summary.summary_content for summary in available_summary_list])
+ content += "\n\n" + book_content[leaf.end_idx:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def precompute_intermediate_from_text(self, sliced_text):
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_TEXT_TO_INTERMEDIATE_SYSTEM_PROMPT},
+ {"role": "user", "content": sliced_text}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+ if finished:
+ break
+
+ def precompute_intermediate_from_intermediate(self, content):
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_INTERMEDIATE_TO_INTERMEDAITE_SYSTEM_SUMMARY_PROMPT},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def precompute_final_from_intermediate(self, content):
+ for resp in self.completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_INTERMEDAITE_TO_FINAL_SYSTEM_SUMMARY_PROMPT},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+ yield delta_content, finished
+
+ if finished:
+ break
+
+
+class GPT3Backend(AIBackend):
+ def __init__(self):
+ self.tokenizer = tiktoken.get_encoding("cl100k_base")
+
+ @retry(wait=wait_random_exponential(multiplier=1, max=60), stop=stop_after_attempt(6))
+ def completion_with_backoff(self, **kwargs):
+ return openai.ChatCompletion.create(**kwargs)
+
+ def get_summary_from_text(self, progress, book_content_url):
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def get_summary_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ """
+ generates summary based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ :param callback: callback function to call when a delta content is generated
+ """
+
+ summary_tree = ""
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ with open(summary_tree_url, 'rb') as pickle_file:
+ summary_tree = pickle.load(pickle_file)
+
+ # word_index -> the number of characters read by the user.
+ # start_index, end_idx is the number of tokens processed by the summary
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+ tokenized_read_content = self.tokenizer.encode(read_content)
+ word_index = len(tokenized_read_content) - 1
+
+ leaf = summary_tree.find_leaf_summary(word_index=word_index)
+ available_summary_list = summary_tree.find_included_summaries(leaf)
+
+ content = "\n\n".join([summary.summary_content for summary in available_summary_list])
+ content += "\n\n" + book_content[leaf.start_idx:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+ if finished:
+ break
+
+ def get_quiz_from_text(self, progress, book_content_url):
+ """
+ generates 10 quizzes based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ """
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def get_quiz_from_intermediate(self, progress, book_content_url, summary_tree_url):
+ """
+ generates 10 quizzes based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ """
+ summary_tree = ""
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ with open(summary_tree_url, 'rb') as pickle_file:
+ summary_tree = pickle.load(pickle_file)
+
+ # word_index -> the number of characters read by the user.
+ # start_index, end_idx is the number of tokens processed by the summary
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
+ tokenized_read_content = self.tokenizer.encode(read_content)
+ word_index = len(tokenized_read_content)-1
+
+ # generate new quiz
+ leaf = summary_tree.find_leaf_summary(word_index=word_index)
+ available_summary_list = summary_tree.find_included_summaries(leaf)
+
+ content = "\n\n".join([summary.summary_content for summary in available_summary_list])
+ content += "\n\n" + book_content[leaf.end_idx:word_index]
+
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def precompute_intermediate_from_text(self, sliced_text):
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_TEXT_TO_INTERMEDIATE_SYSTEM_PROMPT},
+ {"role": "user", "content": sliced_text}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+ if finished:
+ break
+
+ def precompute_intermediate_from_intermediate(self, content):
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_INTERMEDIATE_TO_INTERMEDAITE_SYSTEM_SUMMARY_PROMPT},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+
+ yield delta_content, finished
+
+ if finished:
+ break
+
+ def precompute_final_from_intermediate(self, content):
+ for resp in self.completion_with_backoff(
+ model="gpt-3.5-turbo", messages=[
+ {"role": "system", "content": GPT_INTERMEDAITE_TO_FINAL_SYSTEM_SUMMARY_PROMPT},
+ {"role": "user", "content": content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
+ yield delta_content, finished
+
+ if finished:
+ break
+
+class LLaMABackend(AIBackend):
+ def __init__(self):
+ self.model_name_or_path = "TheBloke/Llama-2-7b-Chat-GPTQ"
+ self.model = AutoModelForCausalLM.from_pretrained(self.model_name_or_path,
+ device_map="cuda:0",
+ trust_remote_code=False,
+ revision="main")
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name_or_path, use_fast=True)
+ self.streamer = TextIteratorStreamer(self.tokenizer)
+
+ def get_summary_from_text(self):
+ pass
+ def get_summary_from_intermediate(self):
+ pass
+ def get_quiz_from_text(self):
+ pass
+ def get_quiz_from_intermediate(self):
+ pass
+
+ def precompute_intermediate_from_text(self, sliced_text):
+ # inserted_input_ids = torch.cat([
+ # self.tokenizer(PROMPT_TEMPLATE_INTERMEDIATE, return_tensors='pt').input_ids[:, :-4],
+ # self.tokenizer(sliced_text, return_tensors='pt').input_ids,
+ # self.tokenizer(PROMPT_TEMPLATE_INTERMEDIATE,return_tensors='pt').input_ids[:, -4:]],
+ # dim=1).cuda()
+
+ inputs = self.tokenizer(
+ PROMPT_TEMPLATE_INTERMEDIATE_FRONT + sliced_text + PROMPT_TEMPLATE_INTERMEDIATE_BACK,
+ return_tensors='pt').to('cuda:0')
+ generation_kwargs = dict(inputs, streamer=self.streamer, max_new_tokens=512)
+ thread = Thread(target=self.model.generate, kwargs=generation_kwargs)
+ thread.start()
+ for new_text in self.streamer:
+ sys.stdout.write(new_text)
+ sys.stdout.flush()
+ yield new_text, False
+ yield "\n", True
+
+ def precompute_intermediate_from_intermediate(self, content):
+ inputs = self.tokenizer(
+ PROMPT_TEMPLATE_INTERMEDIATE_FRONT + content + PROMPT_TEMPLATE_INTERMEDIATE_BACK,
+ return_tensors='pt').to('cuda:0')
+ generation_kwargs = dict(inputs, streamer=self.streamer, max_new_tokens=512)
+ thread = Thread(target=self.model.generate, kwargs=generation_kwargs)
+ thread.start()
+ for new_text in self.streamer:
+ sys.stdout.write(new_text)
+ sys.stdout.flush()
+ yield new_text, False
+ yield "\n", True
+
+ def precompute_final_from_intermediate(self, content):
+ inputs = self.tokenizer(
+ PROMPT_TEMPLATE_FINAL_FRONT + content + PROMPT_TEMPLATE_FINAL_BACK,
+ return_tensors='pt').to('cuda:0')
+ generation_kwargs = dict(inputs, streamer=self.streamer, max_new_tokens=512)
+ thread = Thread(target=self.model.generate, kwargs=generation_kwargs)
+ thread.start()
+ for new_text in self.streamer:
+ sys.stdout.write(new_text)
+ sys.stdout.flush()
+ yield new_text, False
+ yield "\n", True
diff --git a/backend/llama/preprocess_summary.py b/backend/llama/preprocess_summary.py
index 8172490..f25a924 100644
--- a/backend/llama/preprocess_summary.py
+++ b/backend/llama/preprocess_summary.py
@@ -6,6 +6,7 @@
import mysql.connector
import os
import math
+from llama.custom_type import ProxyAIBackend, GPT4Backend, GPT3Backend, LLaMABackend
from tenacity import (
retry,
@@ -17,26 +18,9 @@
def completion_with_backoff(**kwargs):
return openai.ChatCompletion.create(**kwargs)
-books_db = mysql.connector.connect(
- host=os.environ["MYSQL_ENDPOINT"],
- user=os.environ["MYSQL_USER"],
- password=os.environ["MYSQL_PWD"],
- database="readability",
-)
-
tokenizer = tiktoken.get_encoding("cl100k_base")
MAX_SIZE = 3900
-INTERMEDIATE_SYSTEM_PROMPT = '''
-"Hello, ChatGPT. I have a passage from a novel that I need help with.
-It's quite long and detailed, and I'm looking to create a summary of the larger text it's a part of.
-Can you assist me by identifying and extracting the most crucial bullet points from this passage?
-These points should capture key events, character developments, themes, or any significant literary elements that are essential to the overall narrative and its context in the larger story.
-Only provide bulletpoints, and do not say anything else.
-If there isn't enough information to extract bullet points, just say "No bullet points".
-Thank you!"
-'''
-
# FINAL_SYSTEM_SUMMARY_PROMPT='''
# Hello again, ChatGPT.
# Earlier, you helped me by extracting crucial bullet points from a passage of a novel.
@@ -46,25 +30,44 @@ def completion_with_backoff(**kwargs):
# Could you please help me formulate this into a well-structured summary? Thank you!
# '''
-FINAL_SYSTEM_SUMMARY_PROMPT = '''
-You will be provided with several bullet points that outline key aspects of a story. Your task is to synthesize these points into a coherent and concise summary that captures the most crucial elements necessary for understanding the story’s overall plot. Your summary should make it easy for a reader to grasp the main ideas, themes, and developments within the story.
-Read the bullet points carefully: Carefully analyze each bullet point to understand the fundamental components of the story, such as the main events, character motivations, conflicts, and resolutions.
+def get_book_content_url(books_db, book_id):
+ cursor = books_db.cursor()
+ cursor.execute(f"SELECT content FROM Books where id = {book_id}")
+ results = cursor.fetchall()
+ book_content_url = results[0][0]
+ return book_content_url
-Your summary should be well-organized, flowing seamlessly from one point to the next to create a cohesive understanding of the story.
-Focus on conveying the key elements that are central to the story’s plot and overall message.
-Avoid including overly detailed or minor points that do not significantly contribute to understanding the core plot.
+def update_summary_path_url(books_db, book_id, summary_path_url):
+ cursor = books_db.cursor()
+ cursor.execute(f"UPDATE Books SET summary_tree = '{summary_path_url}' WHERE id = {book_id}")
+ books_db.commit()
-Aim for a summary that is concise yet comprehensive enough to convey the essential plot points.
-Ensure that the summary is not overly lengthy or cluttered with less pertinent details.
+def update_num_current_inference(books_db, book_id):
+ cursor = books_db.cursor()
+ cursor.execute(f"UPDATE Books SET num_current_inference = num_current_inference + 1 WHERE id = {book_id}")
+ books_db.commit()
-Review your summary to ensure that it accurately represents the main ideas and themes presented in the bullet points.
-Ensure that the language used is clear and easily understandable.
-'''
+def update_num_total_inference(books_db, book_id, num_total_inference):
+ cursor = books_db.cursor()
+ cursor.execute(f"UPDATE Books SET num_total_inference = {num_total_inference} WHERE id = {book_id}")
+ books_db.commit()
+ cursor.execute(f"UPDATE Books SET num_current_inference = {0} WHERE id = {book_id}")
+ books_db.commit()
+
+def get_number_of_inferences(num_splits):
+ assert num_splits >= 0
+ if num_splits == 0:
+ return 0
+ return num_splits + get_number_of_inferences(num_splits//2)
def split_large_text(story):
tokens = tokenizer.encode(story)
# calculate the number of splits
number_of_splits = math.ceil(len(tokens) / MAX_SIZE)
+ # Division by 0 is theoretically possible if len(tokens) == 0
+ # However, uploading an empty txt file is not allowed
+ # from the frontend, so this should never happen.
+
# divide the tokens evenly across the number of splits
start_end_indices = [len(tokens)//number_of_splits] * number_of_splits
# add the remainder evenly across the splits
@@ -97,6 +100,13 @@ def split_list(input_list):
# split the input_list into groups
num_groups = len(input_list) // split_size
remainder = len(input_list) % split_size
+
+ # if num_groups 0, but remainder is 1,
+ # we will run into an infinite loop when
+ # distributing the remainder.
+ if num_groups == 0:
+ return [input_list]
+
output_sizes = []
output_list = []
start_idx = 0
@@ -114,6 +124,7 @@ def split_list(input_list):
remainder -= 1
else:
break
+
# create output_list
for i in range(num_groups):
output_list.append(input_list[:output_sizes[i]])
@@ -122,118 +133,100 @@ def split_list(input_list):
return output_list
-def reduce_multiple_summaries_to_one(summary_list, is_intermediate):
+def reduce_multiple_summaries_to_one(proxy_ai_backend, books_db, book_id, summary_list, is_intermediate):
summary_content_list = [summary.summary_content for summary in summary_list]
reduced_start_idx = min([summary.start_idx for summary in summary_list])
reduced_end_idx = max([summary.end_idx for summary in summary_list])
content = '\n'.join(summary_content_list)
-
- print("THIS IS CONTENT: ", content)
+ print("CONTENT INPUT TO REDUCE MULTIPLE SUMMARIES TO ONE: ", content)
if is_intermediate:
response = ""
for attempt in range(10):
try:
- for resp in completion_with_backoff(
- model="gpt-4", messages=[
- {"role": "system", "content": INTERMEDIATE_SYSTEM_PROMPT},
- {"role": "user", "content": content}
- ], stream=True
- ):
- finished = resp.choices[0].finish_reason is not None
- delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ for delta_content, finished in proxy_ai_backend.precompute_intermediate_from_intermediate(content):
+ delta_content = "\n" if (finished) else delta_content
response += delta_content
- sys.stdout.write(delta_content)
- sys.stdout.flush()
- if finished:
- break
except Exception as e:
- print(e)
+ if attempt == 5:
+ proxy_ai_backend.summary_generator = GPT3Backend()
+ print("EXCEPTION IN REDUCE_MULTIPLE_SUMMARIES_TO_ONE INTERMEDIATE " + e)
continue
break
else:
response = ""
for attempt in range(10):
try:
- for resp in completion_with_backoff(
- model="gpt-4", messages=[
- {"role": "system", "content": FINAL_SYSTEM_SUMMARY_PROMPT},
- {"role": "user", "content": content}
- ], stream=True
- ):
- finished = resp.choices[0].finish_reason is not None
- delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ for delta_content, finished in proxy_ai_backend.precompute_final_from_intermediate(content):
+ delta_content = "\n" if (finished) else delta_content
response += delta_content
- sys.stdout.write(delta_content)
- sys.stdout.flush()
- if finished:
- break
except Exception as e:
- print(e)
+ if attempt == 5:
+ proxy_ai_backend.summary_generator = GPT3Backend()
+ print("EXCEPTION IN REDUCE_MULTIPLE_SUMMARIES_TO_ONE FINAL " + e)
continue
break
+ update_num_current_inference(books_db, book_id)
reduced_summary = Summary(summary_content=response,
start_idx=reduced_start_idx, end_idx=reduced_end_idx, children=summary_list)
for summary in summary_list:
summary.parent = reduced_summary
-
return reduced_summary
-def reduce_summaries_list(summaries_list):
+def reduce_summaries_list(proxy_ai_backend, books_db, book_id, summaries_list):
while len(summaries_list) > 1:
double_paired_list = split_list(summaries_list)
- summaries_list = [reduce_multiple_summaries_to_one(double_pair, is_intermediate=(
+ summaries_list = [reduce_multiple_summaries_to_one(proxy_ai_backend, books_db, book_id, double_pair, is_intermediate=(
len(summaries_list) > 3)) for double_pair in double_paired_list]
return summaries_list[0]
-async def generate_summary_tree(book_id, story):
+def generate_summary_tree(book_id, story):
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+
summaries_list = []
sliced_text_dict_list = split_large_text(story)
+ num_total_inferences = get_number_of_inferences(len(sliced_text_dict_list))
+
+ if num_total_inferences == 1:
+ update_num_total_inference(books_db, book_id, 1)
+ update_num_current_inference(books_db, book_id)
+ return
+ proxy_ai_backend = ProxyAIBackend(GPT4Backend())
+ update_num_total_inference(books_db, book_id, num_total_inferences)
+
for prompt in sliced_text_dict_list:
response = ""
for attempt in range(10):
try:
- for resp in completion_with_backoff(
- model="gpt-4", messages=[
- {"role": "system", "content": INTERMEDIATE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt["sliced_text"]}
- ], stream=True
- ):
- finished = resp.choices[0].finish_reason is not None
- delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ for delta_content, finished in proxy_ai_backend.precompute_intermediate_from_text(prompt["sliced_text"]):
+ delta_content = "\n" if (finished) else delta_content
response += delta_content
-
- sys.stdout.write(delta_content)
- sys.stdout.flush()
- if finished:
- break
-
+ update_num_current_inference(books_db, book_id)
first_level_summary = Summary(summary_content=response,
start_idx=prompt["start_idx"],
end_idx=prompt["end_idx"])
summaries_list.append(first_level_summary)
except Exception as e:
- print(e)
+ if attempt == 5:
+ proxy_ai_backend.summary_generator = GPT3Backend()
+ print("EXCEPTION IN INTERMEDAITE FROM TEXT " + e)
continue
break
- single_summary = reduce_summaries_list(summaries_list)
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
- cursor.execute(f"SELECT content FROM Books where id = {book_id}")
- results = cursor.fetchall()
- #TODO: results might be none?
- book_content_url = results[0][0]
+ single_summary = reduce_summaries_list(proxy_ai_backend, books_db, book_id, summaries_list)
+ book_content_url = get_book_content_url(books_db, book_id)
summary_path_url = book_content_url.split('.')[0] + "_summary.pkl"
-
- cursor.execute(f"UPDATE Books SET summary_tree = '{summary_path_url}' WHERE id = {book_id}")
- books_db.commit()
+ update_summary_path_url(books_db, book_id, summary_path_url)
user_dirname = f"/home/swpp/readability_users/"
summary_path_url = os.path.join(user_dirname, summary_path_url)
@@ -241,44 +234,44 @@ async def generate_summary_tree(book_id, story):
pickle.dump(single_summary, pickle_file)
-def main():
- story_path = sys.argv[1]
- story = open(story_path, "r").read()
- summaries_list = []
+# def main():
+# story_path = sys.argv[1]
+# story = open(story_path, "r").read()
+# summaries_list = []
- print("\n\n*** Generate:")
- sliced_text_dict_list = split_large_text(story)
+# print("\n\n*** Generate:")
+# sliced_text_dict_list = split_large_text(story)
- for prompt in sliced_text_dict_list:
- response = ""
- for resp in completion_with_backoff(
- model="gpt-4", messages=[
- {"role": "system", "content": INTERMEDIATE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt["sliced_text"]}
- ], stream=True
- ):
- finished = resp.choices[0].finish_reason is not None
- delta_content = "\n" if (finished) else resp.choices[0].delta.content
- response += delta_content
-
- sys.stdout.write(delta_content)
- sys.stdout.flush()
- if finished:
- break
-
- first_level_summary = Summary(summary_content=response,
- start_idx=prompt["start_idx"],
- end_idx=prompt["end_idx"])
- summaries_list.append(first_level_summary)
-
- single_summary = reduce_summaries_list(summaries_list)
- print("\n\n*** FINAL Summary:")
- print(single_summary)
-
- summary_tree_path = f"{story_path.split('.')[0]}_summary.pkl"
- with open(summary_tree_path, 'wb') as pickle_file:
- pickle.dump(single_summary, pickle_file)
+# for prompt in sliced_text_dict_list:
+# response = ""
+# for resp in completion_with_backoff(
+# model="gpt-4", messages=[
+# {"role": "system", "content": INTERMEDIATE_SYSTEM_PROMPT},
+# {"role": "user", "content": prompt["sliced_text"]}
+# ], stream=True
+# ):
+# finished = resp.choices[0].finish_reason is not None
+# delta_content = "\n" if (finished) else resp.choices[0].delta.content
+# response += delta_content
+
+# sys.stdout.write(delta_content)
+# sys.stdout.flush()
+# if finished:
+# break
+
+# first_level_summary = Summary(summary_content=response,
+# start_idx=prompt["start_idx"],
+# end_idx=prompt["end_idx"])
+# summaries_list.append(first_level_summary)
+
+# single_summary = reduce_summaries_list(summaries_list)
+# print("\n\n*** FINAL Summary:")
+# print(single_summary)
+
+# summary_tree_path = f"{story_path.split('.')[0]}_summary.pkl"
+# with open(summary_tree_path, 'wb') as pickle_file:
+# pickle.dump(single_summary, pickle_file)
-if __name__ == "__main__":
- main()
+# if __name__ == "__main__":
+# main()
diff --git a/backend/llama/run_quiz.py b/backend/llama/run_quiz.py
index 0cdfb7a..6ff1324 100644
--- a/backend/llama/run_quiz.py
+++ b/backend/llama/run_quiz.py
@@ -4,8 +4,9 @@
import openai
import tiktoken
-tokenizer = tiktoken.get_encoding("cl100k_base")
+from llama.constants import GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE, GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW
+tokenizer = tiktoken.get_encoding("cl100k_base")
from tenacity import (
retry,
@@ -17,36 +18,37 @@
def completion_with_backoff(**kwargs):
return openai.ChatCompletion.create(**kwargs)
-SYSTEM_QUIZ_PROMPT = '''
-You will be presented with a series of bullet points summarizing key elements of a story. Your task is to generate questions that are crucial for understanding the overall plot and essential aspects of the story. Generate a minimum of 2 and a maximum of 10 questions, ensuring that the questions you choose to create are deeply rooted in the comprehension and analysis of the story's plot, characters, and themes.
-
-Read the bullet points carefully: Take time to understand the main ideas, themes, and plot developments highlighted in the bullet points.
-
-Generate Questions:
+def get_quizzes_from_text(progress, book_content_url):
+ """
+ generates 10 quizzes based on the word_index
+ :param progress: progress of the book
+ :param book_id: book id to generate quiz from
+ """
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
-First, write number of questions you want to generate.
-Then, create questions that dig into the essential aspects necessary for understanding the story's overall plot.
-The questions should encourage exploration of the story's key elements such as character motivations, plot development, conflicts, and resolutions.
-Avoid asking overly detailed questions that do not contribute significantly to the understanding of the story’s main plot or themes.
-Number of Questions:
+ # word_index -> the number of characters read by the user.
+ # start_index, end_idx is the number of tokens processed by the summary
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
-Generate at least 2 questions that target the most critical aspects of the story.
-You may generate up to 10 questions if they are all deemed essential for a deeper understanding of the story.
-Question Format:
+ for resp in completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
-Ensure that the questions are open-ended to promote deeper thinking and analysis.
-Format the questions clearly and concisely.
-Be sure to provide the answers as well.
+ yield delta_content, finished
-Format:
-Number of Questions:
-1Q:
-1A:
-2Q:
-2A:
-'''
+ if finished:
+ break
-def get_quizzes(progress, book_content_url, summary_tree_url):
+def get_quizzes_from_intermediate(progress, book_content_url, summary_tree_url):
"""
generates 10 quizzes based on the word_index
:param progress: progress of the book
@@ -74,7 +76,7 @@ def get_quizzes(progress, book_content_url, summary_tree_url):
for resp in completion_with_backoff(
model="gpt-4", messages=[
- {"role": "system", "content": SYSTEM_QUIZ_PROMPT},
+ {"role": "system", "content": GPT_SYSTEM_QUIZ_PROMPT_FROM_INTERMEDIATE},
{"role": "user", "content": content}
], stream=True
):
diff --git a/backend/llama/run_summary.py b/backend/llama/run_summary.py
index cb8beb9..5205640 100644
--- a/backend/llama/run_summary.py
+++ b/backend/llama/run_summary.py
@@ -5,34 +5,48 @@
import openai
import tiktoken
+from llama.constants import GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE, GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW
+
tokenizer = tiktoken.get_encoding("cl100k_base")
-SYSTEM_SUMMARY_PROMPT = '''
-Prompt Instructions:
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_random_exponential,
+) # for exponential backoff
-You will be provided with several bullet points that outline key aspects of a story. Your task is to synthesize these points into a coherent and concise summary that captures the most crucial elements necessary for understanding the story’s overall plot. Your summary should make it easy for a reader to grasp the main ideas, themes, and developments within the story.
+@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
+def completion_with_backoff(**kwargs):
+ return openai.ChatCompletion.create(**kwargs)
-Read the bullet points carefully: Carefully analyze each bullet point to understand the fundamental components of the story, such as the main events, character motivations, conflicts, and resolutions.
+#TODO: handle python imports better
+base_path = path.dirname(path.realpath(__file__))
+sys.path.append(path.abspath(base_path))
-Crafting the Summary:
+def get_summary_from_text(progress, book_content_url):
+ with open(book_content_url, 'r') as book_file:
+ book_content = book_file.read()
+ word_index = int(progress * len(book_content))
+ read_content = book_content[:word_index]
-Your summary should be well-organized, flowing seamlessly from one point to the next to create a cohesive understanding of the story.
-Focus on conveying the key elements that are central to the story’s plot and overall message.
-Avoid including overly detailed or minor points that do not significantly contribute to understanding the core plot.
-Length and Detail:
+ for resp in completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_RAW},
+ {"role": "user", "content": read_content}
+ ], stream=True
+ ):
+ finished = resp.choices[0].finish_reason is not None
+ delta_content = "\n" if (finished) else resp.choices[0].delta.content
+ sys.stdout.write(delta_content)
+ sys.stdout.flush()
-Aim for a summary that is concise yet comprehensive enough to convey the essential plot points.
-Ensure that the summary is not overly lengthy or cluttered with less pertinent details.
-Final Touches:
+ yield delta_content, finished
+
+ if finished:
+ break
-Review your summary to ensure that it accurately represents the main ideas and themes presented in the bullet points.
-Ensure that the language used is clear and easily understandable.
-'''
-#TODO: handle python imports better
-base_path = path.dirname(path.realpath(__file__))
-sys.path.append(path.abspath(base_path))
-def get_summary(progress, book_content_url, summary_tree_url):
+def get_summary_from_intermediate(progress, book_content_url, summary_tree_url):
"""
generates summary based on the word_index
:param progress: progress of the book
@@ -59,9 +73,9 @@ def get_summary(progress, book_content_url, summary_tree_url):
content = "\n\n".join([summary.summary_content for summary in available_summary_list])
content += "\n\n" + book_content[leaf.start_idx:word_index]
- for resp in openai.ChatCompletion.create(
- model="gpt-3.5-turbo", messages=[
- {"role": "system", "content": SYSTEM_SUMMARY_PROMPT},
+ for resp in completion_with_backoff(
+ model="gpt-4", messages=[
+ {"role": "system", "content": GPT_SYSTEM_SUMMARY_PROMPT_FROM_INTERMEDIATE},
{"role": "user", "content": content}
], stream=True
):
@@ -76,5 +90,4 @@ def get_summary(progress, book_content_url, summary_tree_url):
break
if __name__ == "__main__":
- # main()
- get_summary(10880, 1)
\ No newline at end of file
+ get_summary_from_intermediate(10880, 1)
\ No newline at end of file
diff --git a/backend/routers/ai.py b/backend/routers/ai.py
index c536f2c..80ac2d1 100644
--- a/backend/routers/ai.py
+++ b/backend/routers/ai.py
@@ -1,12 +1,13 @@
from fastapi import APIRouter, Request, Depends, HTTPException, status
from pydantic import BaseModel
-from llama.run_quiz import get_quizzes
-from llama.run_summary import get_summary
+from llama.run_quiz import get_quizzes_from_intermediate, get_quizzes_from_text
+from llama.run_summary import get_summary_from_intermediate, get_summary_from_text
from sse_starlette.sse import EventSourceResponse
import mysql.connector
import os
from routers.user import get_user_with_access_token
+from llama.custom_type import ProxyAIBackend, GPT4Backend
class QuizReportRequest(BaseModel):
quiz_id: str
@@ -14,20 +15,12 @@ class QuizReportRequest(BaseModel):
ai = APIRouter()
-books_db = mysql.connector.connect(
- host=os.environ["MYSQL_ENDPOINT"],
- user=os.environ["MYSQL_USER"],
- password=os.environ["MYSQL_PWD"],
- database="readability",
-)
-
@ai.get("/summary")
async def ai_summary(request: Request, book_id: str, progress: float, email: str = Depends(get_user_with_access_token)):
"""
- :param book_id: book id to generate quiz from
- :param progress: progress of the book
- :param key: key to identify the quiz session
- :param index: index of the quiz
+ :param book_id: book id to generate summary from
+ :param progress: cutoff to which the summary is generated
+ :param email(access_token): requesting user's email
"""
if email is None:
raise HTTPException(
@@ -36,25 +29,45 @@ async def ai_summary(request: Request, book_id: str, progress: float, email: str
headers={"WWW-Authenticate": "Bearer"},
)
- if not books_db.is_connected():
- books_db.reconnect()
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
cursor = books_db.cursor()
cursor.execute(f"SELECT * FROM Books WHERE id = '{book_id}'")
result = cursor.fetchall()
+
+ # if the num_total_inferences is 1,
+ # then the books was too short to divide.
+ # therefore, we don't utilize a split summary.
+
user_dirname = f"/home/swpp/readability_users/"
book_content_url = os.path.join(user_dirname,result[0][6])
- summary_tree_url = os.path.join(user_dirname,result[0][7])
+ ai_backend = ProxyAIBackend(GPT4Backend())
+ if result[0][8] == 1:
+ async def event_generator():
+ for delta_content, finished in ai_backend.get_summary_from_text(progress, book_content_url):
+ if await request.is_disconnected():
+ return
+ yield {
+ "event": "summary",
+ "data": delta_content
+ }
+ return EventSourceResponse(event_generator())
+
+ summary_tree_url = os.path.join(user_dirname,result[0][7])
async def event_generator():
- for delta_content, finished in get_summary(progress, book_content_url, summary_tree_url):
+ for delta_content, finished in ai_backend.get_summary_from_intermediate(progress, book_content_url, summary_tree_url):
if await request.is_disconnected():
return
yield {
"event": "summary",
"data": delta_content
}
-
return EventSourceResponse(event_generator())
@ai.get("/quiz")
@@ -62,6 +75,7 @@ def ai_quiz(request: Request, book_id: str, progress: float, email: str = Depend
"""
:param book_id: book id to generate quiz from
:param progress: progress of the book
+ :param email(access_token): requesting user's email
"""
if email is None:
raise HTTPException(
@@ -70,6 +84,12 @@ def ai_quiz(request: Request, book_id: str, progress: float, email: str = Depend
headers={"WWW-Authenticate": "Bearer"},
)
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
if not books_db.is_connected():
books_db.reconnect()
cursor = books_db.cursor()
@@ -78,17 +98,28 @@ def ai_quiz(request: Request, book_id: str, progress: float, email: str = Depend
user_dirname = f"/home/swpp/readability_users/"
book_content_url = os.path.join(user_dirname,result[0][6])
- summary_tree_url = os.path.join(user_dirname,result[0][7])
+ ai_backend = ProxyAIBackend(GPT4Backend())
+ if result[0][8] == 1:
+ async def event_generator():
+ for delta_content, finished in ai_backend.get_quiz_from_text(progress, book_content_url):
+ if await request.is_disconnected():
+ return
+ yield {
+ "event": "summary",
+ "data": delta_content
+ }
+ return EventSourceResponse(event_generator())
+
+ summary_tree_url = os.path.join(user_dirname,result[0][7])
async def event_generator():
- for delta_content, finished in get_quizzes(progress, book_content_url, summary_tree_url):
+ for delta_content, finished in ai_backend.get_quiz_from_intermediate(progress, book_content_url, summary_tree_url):
if await request.is_disconnected():
return
yield {
"event": "quiz",
"data": delta_content
}
-
return EventSourceResponse(event_generator())
diff --git a/backend/routers/book.py b/backend/routers/book.py
index e09a6f0..999c76c 100644
--- a/backend/routers/book.py
+++ b/backend/routers/book.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, File, UploadFile, Depends, HTTPException, status, BackgroundTasks
+from fastapi import BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
import mysql.connector
@@ -18,18 +19,17 @@ class BookAddRequest(BaseModel):
author: str = None
cover_image: str = None
-books_db = mysql.connector.connect(
- host=os.environ["MYSQL_ENDPOINT"],
- user=os.environ["MYSQL_USER"],
- password=os.environ["MYSQL_PWD"],
- database="readability",
-)
+
book = APIRouter()
@book.get("/test_db")
def test_book_get(query: str):
- if not books_db.is_connected():
- books_db.reconnect()
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
cursor = books_db.cursor()
cursor.execute(query)
result = cursor.fetchall()
@@ -44,8 +44,12 @@ def book_list(email: str = Depends(get_user_with_access_token)):
headers={"WWW-Authenticate": "Bearer"},
)
- if not books_db.is_connected():
- books_db.reconnect()
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
cursor = books_db.cursor()
cursor.execute(f"SELECT * FROM Books WHERE email = '{email}'")
result = cursor.fetchall()
@@ -56,10 +60,10 @@ def book_list(email: str = Depends(get_user_with_access_token)):
"book_id": row[0],
"title": row[2],
"author": row[3],
- "progress": row[4],
+ "progress": float(row[4]),
"cover_image": row[5],
- "content": result[0][6],
- "summary_tree": result[0][7]
+ "content": row[6],
+ "summary_tree": row[7]
})
return {"books": books}
@@ -73,8 +77,12 @@ def book_detail(book_id: str, email: str = Depends(get_user_with_access_token)):
headers={"WWW-Authenticate": "Bearer"},
)
- if not books_db.is_connected():
- books_db.reconnect()
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
cursor = books_db.cursor()
cursor.execute(f"SELECT * FROM Books WHERE id = '{book_id}'")
result = cursor.fetchall()
@@ -82,7 +90,7 @@ def book_detail(book_id: str, email: str = Depends(get_user_with_access_token)):
return {
"title": result[0][2],
"author": result[0][3],
- "progress": result[0][4],
+ "progress": float(result[0][4]),
"cover_image": result[0][5],
"content": result[0][6],
}
@@ -96,24 +104,32 @@ def book_progress(book_id: str, progress: float, email: str = Depends(get_user_w
headers={"WWW-Authenticate": "Bearer"},
)
- if not books_db.is_connected():
- books_db.reconnect()
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
cursor = books_db.cursor()
cursor.execute(f"UPDATE Books SET progress = {progress} WHERE id = '{book_id}'")
books_db.commit()
return {}
@book.post("/book/add")
-async def book_add(req: BookAddRequest, email: str = Depends(get_user_with_access_token)):
+async def book_add(background_tasks:BackgroundTasks, req: BookAddRequest, email: str = Depends(get_user_with_access_token)):
if email is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
- if not books_db.is_connected():
- books_db.reconnect()
# from the Users table, get the user's username by querying with the email
cursor = books_db.cursor()
cursor.execute(f"SELECT username FROM Users WHERE email = '{email}'")
@@ -136,16 +152,20 @@ async def book_add(req: BookAddRequest, email: str = Depends(get_user_with_acces
with open(content_url, 'w') as book_file:
book_file.write(req.content)
+
+ content_url = "/".join(content_url.split("/")[-2:])
# asssumes that the client is sending the image as a byte array.
- # image = Image.open(io.BytesIO(bytearray.fromhex(req.cover_image)))
- # image.save(image_url)
+ if req.cover_image != "":
+ image = Image.open(io.BytesIO(bytearray.fromhex(req.cover_image)))
+ image.save(image_url)
+ image_url = "/".join(image_url.split("/")[-2:])
+ else:
+ image_url = None
add_book = (
"INSERT INTO Books (email, title, author, progress, cover_image, content)"
"VALUES (%s, %s, %s, %s, %s, %s)"
)
- image_url = "/".join(image_url.split("/")[-2:])
- content_url = "/".join(content_url.split("/")[-2:])
book_data = (email, req.title, req.author, 0.0, image_url, content_url)
cursor = books_db.cursor()
@@ -153,9 +173,7 @@ async def book_add(req: BookAddRequest, email: str = Depends(get_user_with_acces
books_db.commit()
book_id = cursor.lastrowid
- # TODO: handle possible errors from async task
- asyncio.create_task(generate_summary_tree(book_id, req.content))
-
+ background_tasks.add_task(generate_summary_tree, book_id, req.content)
return {}
@book.get("/book/image")
@@ -179,3 +197,45 @@ def book_content(content_url: str, email: str = Depends(get_user_with_access_tok
)
content_url = os.path.join('/home/swpp/readability_users', content_url)
return FileResponse(content_url)
+
+@book.delete("/book/delete")
+def book_delete(book_id: str, email: str = Depends(get_user_with_access_token)):
+ if email is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials.",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ books_db.cursor().execute(f"DELETE FROM Books WHERE id = '{book_id}'")
+ books_db.commit()
+ return {}
+
+@book.get("/book/{book_id}/current_inference")
+def book_inference(book_id: str, email: str = Depends(get_user_with_access_token)):
+ if email is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials.",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ books_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ cursor = books_db.cursor()
+ cursor.execute(f"SELECT * FROM Books WHERE id = '{book_id}'")
+ result = cursor.fetchall()
+
+ num_total_inference = result[0][8]
+ num_current_inference = result[0][9]
+ current_ratio = float(num_current_inference/ num_total_inference)
+ return {"summary_progress": current_ratio}
diff --git a/backend/routers/user.py b/backend/routers/user.py
index d166eb0..4b8fc64 100644
--- a/backend/routers/user.py
+++ b/backend/routers/user.py
@@ -13,12 +13,6 @@ class UserSignupRequest(BaseModel):
password: str
user = APIRouter()
-books_db = mysql.connector.connect(
- host=os.environ["MYSQL_ENDPOINT"],
- user=os.environ["MYSQL_USER"],
- password=os.environ["MYSQL_PWD"],
- database="readability",
-)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -36,22 +30,29 @@ def check_token_expired(token):
return current_time > exp_timestamp
def get_user_with_access_token(access_token):
- if not books_db.is_connected():
- books_db.reconnect()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
- cursor = books_db.cursor()
+ cursor = users_db.cursor()
cursor.execute(f"SELECT * FROM Users WHERE access_token = '{access_token}'")
result = cursor.fetchall()
if len(result) == 0:
+ print("no user by len")
return None
# assert integrity of the token
decoded_token = jwt.decode(access_token.replace('"',''), SECRET_KEY, algorithms=[ALGORITHM])
decoded_email = decoded_token.get("sub")
if (decoded_email != result[0][0]):
+ print("no user by email")
return None
if check_token_expired(access_token):
+ print("no user by expired")
return None
# should be impossible as we already check whether the user exists
@@ -65,9 +66,13 @@ def get_password_hash(password):
@user.post("/user/signup")
def user_signup(user_signup_request: UserSignupRequest):
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ cursor = users_db.cursor()
cursor.execute(f"SELECT * FROM Users WHERE email = '{user_signup_request.email}'")
result = cursor.fetchall()
@@ -89,14 +94,18 @@ def user_signup(user_signup_request: UserSignupRequest):
hashed_password = get_password_hash(user_signup_request.password)
cursor.execute(f"INSERT INTO Users (username, email, password) VALUES ('{user_signup_request.username}', '{user_signup_request.email}', '{hashed_password}')")
- books_db.commit()
+ users_db.commit()
os.mkdir(f"/home/swpp/readability_users/{user_signup_request.username}")
return {"success": True}
def check_user_exists_in_db(email:str, password:str):
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ cursor = users_db.cursor()
cursor.execute(f"SELECT * FROM Users WHERE email = '{email}'")
result = cursor.fetchall()
print(result)
@@ -119,24 +128,36 @@ def create_jwt_token(data: dict, expires_delta: timedelta = None):
return encoded_jwt
def insert_access_token_to_user(email, access_token):
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ cursor = users_db.cursor()
cursor.execute(f"UPDATE Users SET access_token = '{access_token}' WHERE email = '{email}'")
- books_db.commit()
+ users_db.commit()
def insert_refresh_token_to_user(email, refresh_token):
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ cursor = users_db.cursor()
cursor.execute(f"UPDATE Users SET refresh_token = '{refresh_token}' WHERE email = '{email}'")
- books_db.commit()
+ users_db.commit()
def get_user_refresh_token(email):
- if not books_db.is_connected():
- books_db.reconnect()
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
- cursor = books_db.cursor()
+ cursor = users_db.cursor()
cursor.execute(f"SELECT * FROM Users WHERE email = '{email}'")
result = cursor.fetchall()
# should be impossible as we already check whether the user exists
@@ -176,7 +197,7 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
insert_access_token_to_user(email, access_token)
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
-@user.post("/user/info")
+@user.get("/user/info")
def get_user_info(
access_token: str
):
@@ -188,10 +209,14 @@ def get_user_info(
if not get_user_with_access_token(access_token):
raise credentials_exception
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
- if not books_db.is_connected():
- books_db.reconnect()
- cursor = books_db.cursor()
+ cursor = users_db.cursor()
cursor.execute(f"SELECT * FROM Users WHERE access_token = '{access_token}'")
result = cursor.fetchall()
@@ -240,3 +265,46 @@ def refresh_access_token(refresh_token: str):
insert_access_token_to_user(email, new_access_token)
return {"access_token": new_access_token, "token_type": "bearer"}
+
+@user.post("/user/change_password")
+def update_user_password(password: str, email: str = Depends(get_user_with_access_token)):
+ if email is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials.",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+ hashed_password = get_password_hash(password)
+ cursor = users_db.cursor()
+ cursor.execute(f"UPDATE Users SET password = '{hashed_password}' WHERE email = '{email}'")
+ users_db.commit()
+ return {}
+
+@user.delete("/user/delete_user")
+def delete_user(email: str = Depends(get_user_with_access_token)):
+ if email is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials.",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ users_db = mysql.connector.connect(
+ host=os.environ["MYSQL_ENDPOINT"],
+ user=os.environ["MYSQL_USER"],
+ password=os.environ["MYSQL_PWD"],
+ database="readability",
+ )
+
+ user_cursor = users_db.cursor()
+ user_cursor.execute(f"DELETE FROM Users WHERE email = '{email}'")
+ user_cursor.execute(f"DELETE FROM Books WHERE email = '{email}'")
+ users_db.commit()
+ return {}
diff --git a/backend/tests/test_summary.py b/backend/tests/test_summary.py
index 51cd4ca..dbfd640 100644
--- a/backend/tests/test_summary.py
+++ b/backend/tests/test_summary.py
@@ -1,7 +1,11 @@
import pytest
import sys
sys.path.append('/home/swpp/swpp-2023-project-team-7/backend/')
-from llama.preprocess_summary import split_large_text, MAX_SIZE
+from llama.preprocess_summary import (
+ split_large_text, split_list, MAX_SIZE,
+ reduce_multiple_summaries_to_one, reduce_summaries_list,
+ generate_summary_tree, update_summary_path_url, get_number_of_inferences
+)
from llama.custom_type import Summary
import random
import string
@@ -94,4 +98,66 @@ def test_find_included_summary():
assert set(summary1.find_included_summaries(summary1_1_2)) == set([summary1_1_1])
assert set(summary1.find_included_summaries(summary1_2_1)) == set([summary1_1])
assert set(summary1.find_included_summaries(summary1_2_2)) == set([summary1_1, summary1_2_1])
- assert set(summary1.find_included_summaries(summary1_2_3)) == set([summary1_1, summary1_2_1, summary1_2_2])
\ No newline at end of file
+ assert set(summary1.find_included_summaries(summary1_2_3)) == set([summary1_1, summary1_2_1, summary1_2_2])
+
+def test_split_list():
+ # Test case with an even-sized list
+ list1 = [1, 2, 3, 4, 5, 6]
+ expected1 = [[1, 2], [3, 4], [5, 6]]
+ assert split_list(list1) == expected1, "Failed on even-sized list"
+ print("first case passed")
+
+ # Test case with an odd-sized list
+ list2 = [1, 2, 3, 4, 5]
+ expected2 = [[1, 2], [3, 4, 5]]
+ assert split_list(list2) == expected2, "Failed on odd-sized list"
+ print("second case passed")
+
+ # Test case with an empty list
+ list3 = []
+ expected3 = []
+ assert split_list(list3) == expected3, "Failed on empty list"
+ print("third case passed")
+
+ # Test case with a list smaller than split size
+ list4 = [1]
+ expected4 = [[1]]
+ assert split_list(list4) == expected4, "Failed on list smaller than split size"
+ print("fourth case passed")
+
+ # Test case with a string list
+ list5 = ["a", "b", "c", "d", "e"]
+ expected5 = [["a", "b"], ["c", "d", "e"]]
+ assert split_list(list5) == expected5, "Failed on string list"
+ print("fifth case passed")
+
+ # Test case with a mixed-type list
+ list6 = [1, "b", 3.0, True]
+ expected6 = [[1, "b"], [3.0, True]]
+ assert split_list(list6) == expected6, "Failed on mixed-type list"
+ print("sixth case passed")
+
+def test_get_number_of_inferences():
+ # Test case with an even-sized list
+ list1 = [1, 2, 3, 4, 5, 6]
+ expected1 = 6 + 3 + 1
+ assert get_number_of_inferences(len(list1)) == expected1, "Failed on even-sized list"
+ print("first case passed")
+
+ # Test case with an odd-sized list
+ list2 = [1, 2, 3, 4, 5]
+ expected2 = 5 + 2 + 1
+ assert get_number_of_inferences(len(list2)) == expected2, "Failed on odd-sized list"
+ print("second case passed")
+
+ # Test case with an empty list
+ list3 = []
+ expected3 = 0
+ assert get_number_of_inferences(len(list3)) == expected3, "Failed on empty list"
+ print("third case passed")
+
+ # Test case with a list smaller than split size
+ list4 = [1]
+ expected4 = 1
+ assert get_number_of_inferences(len(list4)) == expected4, "Failed on list smaller than split size"
+ print("fourth case passed")
diff --git a/frontend/app/build.gradle.kts b/frontend/app/build.gradle.kts
index 84ea2fb..3fc61eb 100644
--- a/frontend/app/build.gradle.kts
+++ b/frontend/app/build.gradle.kts
@@ -76,17 +76,6 @@ android {
}
unitTests.all { it.jvmArgs("-noverify") }
}
- sourceSets {
- getByName("main") {
- java.srcDirs("src/main/kotlin")
- }
- getByName("test") {
- java.srcDirs("src/test/kotlin")
- }
- getByName("androidTest") {
- java.srcDirs("src/androidTest/kotlin")
- }
- }
}
dependencies {
@@ -116,12 +105,14 @@ dependencies {
implementation("androidx.compose.ui:ui:1.5.4")
implementation("androidx.compose.ui:ui-graphics:1.5.4")
implementation("androidx.compose.ui:ui-tooling-preview:1.5.4")
- implementation("androidx.compose.foundation:foundation-android:1.5.4")
+ implementation("androidx.compose.foundation:foundation-android:1.6.0-beta01")
implementation("io.coil-kt:coil:2.4.0")
implementation("io.coil-kt:coil-compose:2.4.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-alpha05")
+ implementation("androidx.compose.material:material:1.3.1")
+ implementation("com.github.skydoves:cloudy:0.1.2")
testImplementation("androidx.room:room-testing:$roomVersion")
testImplementation("junit:junit:4.13.2")
diff --git a/frontend/app/src/androidTest/java/com/example/readability/screens/AuthScreenTest.kt b/frontend/app/src/androidTest/java/com/example/readability/screens/AuthScreenTest.kt
index 884adda..3f45d32 100644
--- a/frontend/app/src/androidTest/java/com/example/readability/screens/AuthScreenTest.kt
+++ b/frontend/app/src/androidTest/java/com/example/readability/screens/AuthScreenTest.kt
@@ -22,12 +22,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.readability.MainActivity
import com.example.readability.ui.screens.auth.AuthScreen
import com.example.readability.ui.screens.auth.EmailView
-import com.example.readability.ui.screens.auth.ForgotPasswordView
import com.example.readability.ui.screens.auth.IntroView
-import com.example.readability.ui.screens.auth.ResetPasswordView
import com.example.readability.ui.screens.auth.SignInView
import com.example.readability.ui.screens.auth.SignUpView
-import com.example.readability.ui.screens.auth.VerifyEmailView
import com.example.readability.ui.theme.ReadabilityTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -85,9 +82,12 @@ class AuthScreenTest {
var onNavigateSignInCalled = false
composeTestRule.activity.setContent {
ReadabilityTheme {
- EmailView(onNavigateSignIn = {
- onNavigateSignInCalled = true
- })
+ EmailView(
+ email = "",
+ onNavigateSignIn = {
+ onNavigateSignInCalled = true
+ },
+ )
}
}
@@ -98,18 +98,38 @@ class AuthScreenTest {
assert(!onNavigateSignInCalled)
}
+ @Test
+ fun emailView_OnEmailChanged() {
+ var onEmailChangedCalled = false
+ composeTestRule.activity.setContent {
+ ReadabilityTheme {
+ EmailView(
+ email = "",
+ onEmailChanged = {
+ onEmailChangedCalled = true
+ },
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test@example.com")
+ assert(onEmailChangedCalled)
+ }
+
@Test
fun emailView_NextClicked_WithInvalidEmail() {
var onNavigateSignInCalled = false
composeTestRule.activity.setContent {
ReadabilityTheme {
- EmailView(onNavigateSignIn = {
- onNavigateSignInCalled = true
- })
+ EmailView(
+ email = "testexample.com",
+ onNavigateSignIn = {
+ onNavigateSignInCalled = true
+ },
+ )
}
}
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test")
composeTestRule.onNodeWithText("Sign in").performClick()
composeTestRule.onNodeWithTextAndError("Please enter a valid email address").assertExists()
@@ -122,13 +142,15 @@ class AuthScreenTest {
var onNavigateSignInCalled = false
composeTestRule.activity.setContent {
ReadabilityTheme {
- EmailView(onNavigateSignIn = {
- onNavigateSignInCalled = true
- })
+ EmailView(
+ email = "test@example.com",
+ onNavigateSignIn = {
+ onNavigateSignInCalled = true
+ },
+ )
}
}
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test@example.com")
composeTestRule.onNodeWithText("Sign in").performClick()
assert(onNavigateSignInCalled)
@@ -139,9 +161,12 @@ class AuthScreenTest {
var onNavigateSignUpCalled = false
composeTestRule.activity.setContent {
ReadabilityTheme {
- EmailView(onNavigateSignUp = {
- onNavigateSignUpCalled = true
- })
+ EmailView(
+ email = "",
+ onNavigateSignUp = {
+ onNavigateSignUpCalled = true
+ },
+ )
}
}
@@ -150,30 +175,17 @@ class AuthScreenTest {
assert(onNavigateSignUpCalled)
}
- @Test
- fun emailView_ForgotPasswordClicked() {
- var onNavigateForgotPasswordCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- EmailView(onNavigateForgotPassword = {
- onNavigateForgotPasswordCalled = true
- })
- }
- }
-
- composeTestRule.onNodeWithText("Forgot password?").performClick()
-
- assert(onNavigateForgotPasswordCalled)
- }
-
@Test
fun emailView_BackButtonClicked() {
var onBackCalled = false
composeTestRule.activity.setContent {
ReadabilityTheme {
- EmailView(onBack = {
- onBackCalled = true
- })
+ EmailView(
+ email = "",
+ onBack = {
+ onBackCalled = true
+ },
+ )
}
}
@@ -238,22 +250,6 @@ class AuthScreenTest {
}
}
- @Test
- fun signInView_ForgotPasswordClicked() {
- var onNavigateForgotPasswordCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- SignInView(email = "test@example.com", onNavigateForgotPassword = {
- onNavigateForgotPasswordCalled = true
- })
- }
- }
-
- composeTestRule.onNodeWithText("Forgot password?").performClick()
-
- assert(onNavigateForgotPasswordCalled)
- }
-
@Test
fun signInView_BackButtonClicked() {
var onBackCalled = false
@@ -348,263 +344,6 @@ class AuthScreenTest {
assert(onBackCalled)
}
- @Test
- fun verifyEmailView_NextClicked_WithEmptyVerificationCode() {
- var onVerificationCodeSubmittedCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- VerifyEmailView(
- email = "test@example.com",
- fromSignUp = false,
- onVerificationCodeSubmitted = {
- onVerificationCodeSubmittedCalled = true
- Result.success(Unit)
- },
- )
- }
- }
-
- composeTestRule.onNodeWithText("Next").performClick()
- assert(!onVerificationCodeSubmittedCalled)
- }
-
- @Test
- fun verifyEmailView_NextClicked_FromSignUp() {
- var onVerificationCodeSubmittedCalled = false
- var onNavigateBookListCalled = false
- var onNavigateResetPasswordCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- VerifyEmailView(
- email = "test@example.com",
- fromSignUp = true,
- onVerificationCodeSubmitted = {
- onVerificationCodeSubmittedCalled = true
- Result.success(Unit)
- },
- onNavigateBookList = {
- onNavigateBookListCalled = true
- },
- onNavigateResetPassword = {
- onNavigateResetPasswordCalled = true
- },
- )
- }
- }
-
- composeTestRule.onNodeWithTag("VerificationCodeTextField").performTextInput("123456")
- composeTestRule.onNodeWithText("Next").performClick()
- assert(onVerificationCodeSubmittedCalled)
- composeTestRule.waitUntil(2500L) {
- onNavigateBookListCalled || onNavigateResetPasswordCalled
- }
- assert(onNavigateBookListCalled)
- assert(!onNavigateResetPasswordCalled)
- }
-
- @Test
- fun verifyEmailView_NextClicked_FromForgotPassword() {
- var onVerificationCodeSubmittedCalled = false
- var onNavigateBookListCalled = false
- var onNavigateResetPasswordCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- VerifyEmailView(
- email = "test@example.com",
- fromSignUp = false,
- onVerificationCodeSubmitted = {
- onVerificationCodeSubmittedCalled = true
- Result.success(Unit)
- },
- onNavigateBookList = {
- onNavigateBookListCalled = true
- },
- onNavigateResetPassword = {
- onNavigateResetPasswordCalled = true
- },
- )
- }
- }
-
- composeTestRule.onNodeWithTag("VerificationCodeTextField").performTextInput("123456")
- composeTestRule.onNodeWithText("Next").performClick()
- assert(onVerificationCodeSubmittedCalled)
- composeTestRule.waitUntil(2500L) {
- onNavigateBookListCalled || onNavigateResetPasswordCalled
- }
- assert(!onNavigateBookListCalled)
- assert(onNavigateResetPasswordCalled)
- }
-
- @Test
- fun verifyEmailView_BackButtonClicked() {
- var onBackCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- VerifyEmailView(email = "test@example.com", fromSignUp = false, onBack = {
- onBackCalled = true
- })
- }
- }
-
- composeTestRule.onNodeWithContentDescription("Arrow Back").performClick()
-
- assert(onBackCalled)
- }
-
- @Test
- fun forgotPasswordView_NextClicked_WithEmptyEmail() {
- var onEmailSubmittedCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ForgotPasswordView(
- onEmailSubmitted = {
- onEmailSubmittedCalled = true
- Result.success(Unit)
- },
- )
- }
- }
-
- composeTestRule.onNodeWithText("Next").performClick()
-
- composeTestRule.onNodeWithTextAndError("Please enter a valid email address").assertExists()
- .assertIsDisplayed()
- assert(!onEmailSubmittedCalled)
- }
-
- @Test
- fun forgotPasswordView_NextClicked_WithInvalidEmail() {
- var onEmailSubmittedCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ForgotPasswordView(
- onEmailSubmitted = {
- onEmailSubmittedCalled = true
- Result.success(Unit)
- },
- )
- }
- }
-
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test")
- composeTestRule.onNodeWithText("Next").performClick()
-
- composeTestRule.onNodeWithTextAndError("Please enter a valid email address").assertExists()
- .assertIsDisplayed()
- assert(!onEmailSubmittedCalled)
- }
-
- @Test
- fun forgotPasswordView_NextClicked_WithValidEmail() {
- var onEmailSubmittedCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ForgotPasswordView(
- onEmailSubmitted = {
- onEmailSubmittedCalled = true
- Result.success(Unit)
- },
- )
- }
- }
-
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test@example.com")
- composeTestRule.onNodeWithText("Next").performClick()
- assert(onEmailSubmittedCalled)
- }
-
- @Test
- fun forgotPasswordView_BackButtonClicked() {
- var onBackCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ForgotPasswordView(
- onBack = {
- onBackCalled = true
- },
- )
- }
- }
-
- composeTestRule.onNodeWithContentDescription("Arrow Back").performClick()
-
- assert(onBackCalled)
- }
-
- @Test
- fun resetPasswordView_ResetPasswordClicked_WithEmptyInputs() {
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ResetPasswordView()
- }
- }
-
- composeTestRule.onNodeWithTag("ResetPasswordButton").performClick()
-
- composeTestRule.onNodeWithTextAndError("At least 8 characters including a letter and a number")
- .assertExists()
- .assertIsDisplayed()
- }
-
- @Test
- fun resetPasswordView_ResetPasswordClicked_WithInvalidInputs() {
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ResetPasswordView()
- }
- }
-
- composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest")
- composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("test")
- composeTestRule.onNodeWithTag("ResetPasswordButton").performClick()
-
- composeTestRule.onNodeWithTextAndError("At least 8 characters including a letter and a number")
- .assertExists()
- .assertIsDisplayed()
- composeTestRule.onNodeWithTextAndError("Passwords do not match").assertExists()
- .assertIsDisplayed()
- }
-
- @Test
- fun resetPasswordView_ResetPasswordClicked_WithValidInputs() {
- var onResetPasswordSubmittedCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ResetPasswordView(
- onPasswordSubmitted = {
- onResetPasswordSubmittedCalled = true
- Result.success(Unit)
- },
- )
- }
- }
-
- composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest1")
- composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("testtest1")
- composeTestRule.onNodeWithTag("ResetPasswordButton").performClick()
-
- assert(onResetPasswordSubmittedCalled)
- }
-
- @Test
- fun resetPasswordView_BackButtonClicked() {
- var onBackCalled = false
- composeTestRule.activity.setContent {
- ReadabilityTheme {
- ResetPasswordView(
- onBack = {
- onBackCalled = true
- },
- )
- }
- }
-
- composeTestRule.onNodeWithContentDescription("Arrow Back").performClick()
-
- assert(onBackCalled)
- }
-
@OptIn(ExperimentalTestApi::class)
@Test
fun authScreen_SignIn() {
@@ -655,73 +394,6 @@ class AuthScreenTest {
assert(onNavigateBookListCalled)
}
- @OptIn(ExperimentalTestApi::class)
- @Test
- fun authScreen_ForgotPassword() {
- lateinit var navController: TestNavHostController
- composeTestRule.activity.setContent {
- navController = TestNavHostController(LocalContext.current)
- navController.navigatorProvider.addNavigator(ComposeNavigator())
- ReadabilityTheme {
- AuthScreen(navController = navController)
- }
- }
-
- // 1. Continue with Email
- composeTestRule.waitUntilAtLeastOneExists(hasText("Continue with email"), 2500L)
- composeTestRule.onNodeWithText("Continue with email").performClick()
- // 2. click Forgot password
- composeTestRule.onNodeWithTag("ForgotPasswordButton").performClick()
- // 3. write email
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("testexample.com")
- // 4. click Next
- composeTestRule.onNodeWithTag("NextButton").performClick()
- // 5. assert email error
- composeTestRule.onNodeWithTextAndError("Please enter a valid email address").assertExists()
- .assertIsDisplayed()
- // 6. rewrite email
- composeTestRule.onNodeWithTag("EmailTextField").performTextClearance()
- composeTestRule.onNodeWithTag("EmailTextField").performTextInput("test@example.com")
- // 7. click Next
- composeTestRule.onNodeWithTag("NextButton").performClick()
- // 8. check if navigate to VerifyEmailView
- composeTestRule.waitUntilAtLeastOneExists(hasText("Verify Email"), 2500L)
- // 9. write empty verification code
- composeTestRule.onNodeWithTag("VerificationCodeTextField").performTextInput("")
- // 10. click Next
- composeTestRule.onNodeWithTag("NextButton").performClick()
- // 11. No navigation
- composeTestRule.onNodeWithText("Verify Email").assertIsDisplayed()
- // 12. rewrite verification code
- composeTestRule.onNodeWithTag("VerificationCodeTextField").performTextClearance()
- composeTestRule.onNodeWithTag("VerificationCodeTextField").performTextInput("123456")
- // 13. click Next
- composeTestRule.onNodeWithTag("NextButton").performClick()
-
- // 14. check if navigate to ResetPasswordView
- composeTestRule.waitUntilAtLeastOneExists(hasText("Reset Password"), 2500L)
- // 15. write password and repeat password
- composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest")
- composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("test")
- // 16. click Reset password
- composeTestRule.onNodeWithTag("ResetPasswordButton").performClick()
- // 17. assert error
- composeTestRule.onNodeWithTextAndError("At least 8 characters including a letter and a number")
- .assertExists()
- .assertIsDisplayed()
- composeTestRule.onNodeWithTextAndError("Passwords do not match").assertExists()
- .assertIsDisplayed()
- // 18. rewrite password and repeat password
- composeTestRule.onNodeWithTag("PasswordTextField").performTextClearance()
- composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest1")
- composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextClearance()
- composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("testtest1")
- // 19. click Reset password
- composeTestRule.onNodeWithTag("ResetPasswordButton").performClick()
- // 20. check if navigate to EmailView
- composeTestRule.waitUntilAtLeastOneExists(hasText("Continue with email"), 2500L)
- }
-
@OptIn(ExperimentalTestApi::class)
@Test
fun authScreen_SignUp() {
diff --git a/frontend/app/src/androidTest/java/com/example/readability/screens/BookScreenTest.kt b/frontend/app/src/androidTest/java/com/example/readability/screens/BookScreenTest.kt
index a3ca7ed..a602196 100644
--- a/frontend/app/src/androidTest/java/com/example/readability/screens/BookScreenTest.kt
+++ b/frontend/app/src/androidTest/java/com/example/readability/screens/BookScreenTest.kt
@@ -54,6 +54,7 @@ class BookScreenTest {
content = "",
progress = 0.1,
coverImage = "asd",
+ summaryProgress = 0.1,
),
BookCardData(
id = 2,
@@ -62,6 +63,7 @@ class BookScreenTest {
content = "",
progress = 0.2,
coverImage = "asd",
+ summaryProgress = 0.1,
),
)
composeTestRule.setContent {
diff --git a/frontend/app/src/androidTest/java/com/example/readability/screens/SettingScreenTest.kt b/frontend/app/src/androidTest/java/com/example/readability/screens/SettingScreenTest.kt
index f7c8666..a25ef06 100644
--- a/frontend/app/src/androidTest/java/com/example/readability/screens/SettingScreenTest.kt
+++ b/frontend/app/src/androidTest/java/com/example/readability/screens/SettingScreenTest.kt
@@ -2,9 +2,17 @@ package com.example.readability.screens
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.example.readability.data.viewer.ViewerStyle
+import com.example.readability.ui.screens.settings.AccountView
+import com.example.readability.ui.screens.settings.ChangePasswordView
import com.example.readability.ui.screens.settings.SettingsView
+import com.example.readability.ui.screens.settings.ViewerView
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -16,9 +24,236 @@ class SettingScreenTest {
@Test
fun settingsView_isDisplayed() {
+ val uniqueUsername = "user_${System.currentTimeMillis()}"
composeTestRule.setContent {
- SettingsView()
+ SettingsView(
+ username = uniqueUsername,
+ )
}
composeTestRule.onNodeWithText("Settings").assertIsDisplayed()
+ composeTestRule.onNodeWithText(uniqueUsername).assertIsDisplayed()
}
+
+ @Test
+ fun settingsView_onBackButtonClicked() {
+ var onBackCalled = false
+ composeTestRule.setContent {
+ SettingsView(
+ username = "",
+ onBack = { onBackCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithText("Settings").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription("Back").performClick()
+ assert(onBackCalled)
+ }
+
+ @Test
+ fun settingsView_onNavigateAccountSettingClicked() {
+ var onNavigateAccountSettingCalled = false
+ composeTestRule.setContent {
+ SettingsView(
+ username = "",
+ onNavigateAccountSetting = { onNavigateAccountSettingCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithText("Account Settings").performClick()
+ assert(onNavigateAccountSettingCalled)
+ }
+
+ @Test
+ fun settingsView_onNavigateViewerSettingClicked() {
+ var onNavigateViewerCalled = false
+ composeTestRule.setContent {
+ SettingsView(
+ username = "",
+ onNavigateViewer = { onNavigateViewerCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithText("Viewer Settings").performClick()
+ assert(onNavigateViewerCalled)
+ }
+
+ @Test
+ fun accountView_isDisplayed() {
+ val uniqueUsername = "user_${System.currentTimeMillis()}"
+ val uniqueEmail = "user_${System.currentTimeMillis()}@example.com"
+ composeTestRule.setContent {
+ AccountView(
+ username = uniqueUsername,
+ email = uniqueEmail,
+ )
+ }
+ composeTestRule.onNodeWithText("Account").assertIsDisplayed()
+ composeTestRule.onNodeWithText(uniqueUsername).assertIsDisplayed()
+ composeTestRule.onNodeWithText(uniqueEmail).assertIsDisplayed()
+ }
+
+ @Test
+ fun accountView_onSignOutClicked() {
+ var onSignOutCalled = false
+ composeTestRule.setContent {
+ AccountView(
+ username = "",
+ email = "",
+ onSignOut = {
+ onSignOutCalled = true
+ Result.success(Unit)
+ },
+ )
+ }
+ composeTestRule.onNodeWithText("Sign Out").performClick()
+ composeTestRule.waitUntil(1000L) {
+ onSignOutCalled
+ }
+ }
+
+ @Test
+ fun accountView_onBackClicked() {
+ var onBackCalled = false
+ composeTestRule.setContent {
+ AccountView(
+ username = "",
+ email = "",
+ onBack = { onBackCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithContentDescription("Back").performClick()
+ assert(onBackCalled)
+ }
+
+ @Test
+ fun accountView_onDeleteAccountClicked() {
+ var onDeleteAccountCalled = false
+ var onNavigateIntroCalled = false
+ composeTestRule.setContent {
+ AccountView(
+ username = "",
+ email = "",
+ onDeleteAccount = {
+ onDeleteAccountCalled = true
+ Result.success(Unit)
+ },
+ onNavigateIntro = {
+ onNavigateIntroCalled = true
+ },
+ )
+ }
+ composeTestRule.onNodeWithText("Delete Account").performClick()
+ composeTestRule.onNodeWithText("Delete My Account").performClick()
+ composeTestRule.waitUntil(1000L) {
+ onDeleteAccountCalled && onNavigateIntroCalled
+ }
+ }
+
+ @Test
+ fun accountView_onChangePasswordClicked() {
+ var onChangePasswordCalled = false
+ composeTestRule.setContent {
+ AccountView(
+ username = "",
+ email = "",
+ onNavigateChangePassword = { onChangePasswordCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithText("Change Password").performClick()
+ composeTestRule.waitUntil(1000L) {
+ onChangePasswordCalled
+ }
+ }
+
+ @Test
+ fun changePasswordView_isDisplayed() {
+ composeTestRule.setContent {
+ ChangePasswordView()
+ }
+
+ composeTestRule.onNodeWithText("Change Password").assertIsDisplayed()
+ composeTestRule.onNodeWithText("New Password").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Repeat Password").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Confirm").assertIsDisplayed()
+ }
+
+ @Test
+ fun changePasswordView_onConfirmClickedWithInvalidFields() {
+ var onPasswordSubmitted = false
+ composeTestRule.setContent {
+ ChangePasswordView(
+ onPasswordSubmitted = {
+ onPasswordSubmitted = true
+ Result.success(Unit)
+ },
+ )
+ }
+ composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest")
+ composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("testtest1")
+ composeTestRule.onNodeWithText("Confirm").performClick()
+
+ assert(!onPasswordSubmitted)
+ composeTestRule.onNodeWithTextAndError("At least 8 characters including a letter and a number")
+ .assertIsDisplayed()
+ composeTestRule.onNodeWithTextAndError("Passwords do not match").assertIsDisplayed()
+ }
+
+ @Test
+ fun changePasswordView_onConfirmClickedWithValidFields() {
+ var onPasswordSubmitted = false
+ var submittedPassword = ""
+ var onBackCalled = false
+ composeTestRule.setContent {
+ ChangePasswordView(
+ onPasswordSubmitted = {
+ onPasswordSubmitted = true
+ submittedPassword = it
+ Result.success(Unit)
+ },
+ onBack = {
+ onBackCalled = true
+ },
+ )
+ }
+ composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest1")
+ composeTestRule.onNodeWithTag("RepeatPasswordTextField").performTextInput("testtest1")
+ composeTestRule.onNodeWithText("Confirm").performClick()
+
+ composeTestRule.waitUntil(1000L) {
+ onPasswordSubmitted && onBackCalled
+ }
+ assert(submittedPassword == "testtest1")
+ }
+
+ @Test
+ fun changePasswordView_onBackButtonClicked() {
+ var onBackCalled = false
+ composeTestRule.setContent {
+ ChangePasswordView(
+ onBack = { onBackCalled = true },
+ )
+ }
+ composeTestRule.onNodeWithContentDescription("Arrow Back").performClick()
+ assert(onBackCalled)
+ }
+
+ @Test
+ fun viewerView_isDisplayed() {
+ composeTestRule.setContent {
+ ViewerView(
+ viewerStyle = ViewerStyle(),
+ )
+ }
+
+ composeTestRule.onNodeWithText("Viewer Settings").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Font Family").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Text Size").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Line Height").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Letter Spacing").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Paragraph Spacing").assertIsDisplayed()
+ }
+
+ // TODO: add integration tests for viewerView
+ // It is not easy, since the text rendering is done by canvas and textPaint
+ // One way: take screenshot, compare with reference rendering (most realistic)
+ // -> How to compare two images?
+ // -> How to get reference rendering?
+ // Another way: pass mocked textPaint to the draw code?
}
diff --git a/frontend/app/src/androidTest/java/com/example/readability/screens/ViewerScreenTest.kt b/frontend/app/src/androidTest/java/com/example/readability/screens/ViewerScreenTest.kt
index 2bc1445..5dea747 100644
--- a/frontend/app/src/androidTest/java/com/example/readability/screens/ViewerScreenTest.kt
+++ b/frontend/app/src/androidTest/java/com/example/readability/screens/ViewerScreenTest.kt
@@ -1,9 +1,12 @@
package com.example.readability.screens
+import android.graphics.Typeface
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@@ -46,8 +49,9 @@ class ViewerScreenTest {
author = "Stephen Crane",
content = "",
contentData = content,
- progress = 0.0,
+ progress = 0.5,
coverImage = "",
+ summaryProgress = 1.0,
)
val pageSplits = mutableListOf()
for (i in 0..content.length step 100) {
@@ -72,14 +76,15 @@ class ViewerScreenTest {
fun viewerView_LeftClick() {
var width = 0f
var height = 0f
- openBoatBookData =
+ var bookData =
openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size))
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = bookData,
pageSplitData = pageSplitData,
onProgressChange = {
- openBoatBookData = openBoatBookData.copy(progress = it)
+ bookData = bookData.copy(progress = it)
},
)
with(LocalDensity.current) {
@@ -93,21 +98,22 @@ class ViewerScreenTest {
up()
}
// check if progress is decreased by one page
- assert(pageSplitData.getPageIndex(openBoatBookData.progress) == 0)
+ assert(pageSplitData.getPageIndex(bookData.progress) == 0)
}
@Test
fun viewerView_RightClick() {
var width = 0f
var height = 0f
- openBoatBookData =
+ var bookData =
openBoatBookData.copy(progress = 0.0)
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = bookData,
pageSplitData = pageSplitData,
onProgressChange = {
- openBoatBookData = openBoatBookData.copy(progress = it)
+ bookData = bookData.copy(progress = it)
},
)
with(LocalDensity.current) {
@@ -121,21 +127,22 @@ class ViewerScreenTest {
up()
}
// check if progress is increased by one page
- assert(pageSplitData.getPageIndex(openBoatBookData.progress) == 1)
+ assert(pageSplitData.getPageIndex(bookData.progress) == 1)
}
@Test
fun viewerView_CenterClick() {
var width = 0f
var height = 0f
- openBoatBookData =
+ var bookData =
openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size))
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = bookData,
pageSplitData = pageSplitData,
onProgressChange = {
- openBoatBookData = openBoatBookData.copy(progress = it)
+ bookData = bookData.copy(progress = it)
},
)
with(LocalDensity.current) {
@@ -154,13 +161,14 @@ class ViewerScreenTest {
@Test
fun viewerView_LeftSwipe() {
- openBoatBookData = openBoatBookData.copy(progress = 0.0)
+ var bookData = openBoatBookData.copy(progress = 0.0)
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = bookData,
pageSplitData = pageSplitData,
onProgressChange = {
- openBoatBookData = openBoatBookData.copy(progress = it)
+ bookData = bookData.copy(progress = it)
},
)
}
@@ -169,19 +177,20 @@ class ViewerScreenTest {
swipeLeft()
}
// check if progress is increased by one page
- assert(pageSplitData.getPageIndex(openBoatBookData.progress) == 1)
+ assert(pageSplitData.getPageIndex(bookData.progress) == 1)
}
@Test
fun viewerView_RightSwipe() {
- openBoatBookData =
+ var bookData =
openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size))
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = bookData,
pageSplitData = pageSplitData,
onProgressChange = {
- openBoatBookData = openBoatBookData.copy(progress = it)
+ bookData = bookData.copy(progress = it)
},
)
}
@@ -190,7 +199,7 @@ class ViewerScreenTest {
swipeRight()
}
// check if progress is decreased by one page
- assert(pageSplitData.getPageIndex(openBoatBookData.progress) == 0)
+ assert(pageSplitData.getPageIndex(bookData.progress) == 0)
}
@Test
@@ -200,6 +209,7 @@ class ViewerScreenTest {
var onBack = false
composeTestRule.setContent {
ViewerView(
+ isNetworkConnected = true,
bookData = openBoatBookData,
pageSplitData = pageSplitData,
onBack = {
@@ -229,6 +239,7 @@ class ViewerScreenTest {
var onNavigateSettings = false
composeTestRule.setContent {
ViewerView(
+ isNetworkConnected = true,
bookData = openBoatBookData,
pageSplitData = pageSplitData,
onNavigateSettings = {
@@ -251,6 +262,7 @@ class ViewerScreenTest {
assert(onNavigateSettings)
}
+ @OptIn(ExperimentalTestApi::class)
@Test
fun viewerView_GenerateSummaryClicked() {
var width = 0f
@@ -258,7 +270,8 @@ class ViewerScreenTest {
var onNavigateSummary = false
composeTestRule.setContent {
ViewerView(
- bookData = openBoatBookData,
+ isNetworkConnected = true,
+ bookData = openBoatBookData.copy(progress = 0.5),
pageSplitData = pageSplitData,
onNavigateSummary = {
onNavigateSummary = true
@@ -274,12 +287,14 @@ class ViewerScreenTest {
down(Offset(width * 0.5f, height * 0.5f))
up()
}
+ composeTestRule.waitUntilAtLeastOneExists(hasText("Generate Summary"), 1000L)
// click generate summary
composeTestRule.onNodeWithText("Generate Summary").performClick()
// check if onNavigateSummary is true
assert(onNavigateSummary)
}
+ @OptIn(ExperimentalTestApi::class)
@Test
fun viewerView_GenerateQuizClicked() {
var width = 0f
@@ -287,6 +302,7 @@ class ViewerScreenTest {
var onNavigateQuiz = false
composeTestRule.setContent {
ViewerView(
+ isNetworkConnected = true,
bookData = openBoatBookData,
pageSplitData = pageSplitData,
onNavigateQuiz = {
@@ -303,6 +319,7 @@ class ViewerScreenTest {
down(Offset(width * 0.5f, height * 0.5f))
up()
}
+ composeTestRule.waitUntilAtLeastOneExists(hasText("Generate Quiz"), 1000L)
// click generate quiz
composeTestRule.onNodeWithText("Generate Quiz").performClick()
// check if onNavigateQuiz is true
@@ -397,10 +414,36 @@ class ViewerScreenTest {
assert(reason == "It isn't true.")
}
+ fun summaryView_OnLoadFailed() {
+ var onBack = false
+ composeTestRule.setContent {
+ SummaryView(
+ summary = "",
+ viewerStyle = ViewerStyle(),
+ typeface = Typeface.DEFAULT,
+ referenceLineHeight = 16f,
+ onLoadSummary = {
+ Result.failure(Exception())
+ },
+ onBack = {
+ onBack = true
+ },
+ )
+ }
+
+ assert(onBack)
+ }
+
@Test
fun summaryView_Displayed() {
composeTestRule.setContent {
- SummaryView(summary = "this is a summary")
+ SummaryView(
+ summary = "this is a summary",
+ viewerStyle = ViewerStyle(),
+ typeface = Typeface.DEFAULT,
+ referenceLineHeight = 16f,
+ onLoadSummary = { Result.success(Unit) },
+ )
}
// check if summary is displayed
@@ -412,7 +455,14 @@ class ViewerScreenTest {
fun summaryView_BackButtonClicked() {
var onBack = false
composeTestRule.setContent {
- SummaryView(summary = "this is a summary", onBack = { onBack = true })
+ SummaryView(
+ summary = "this is a summary",
+ onBack = { onBack = true },
+ viewerStyle = ViewerStyle(),
+ typeface = Typeface.DEFAULT,
+ referenceLineHeight = 16f,
+ onLoadSummary = { Result.success(Unit) },
+ )
}
// click back button
diff --git a/frontend/app/src/main/AndroidManifest.xml b/frontend/app/src/main/AndroidManifest.xml
index 2ea5d74..4e2e502 100644
--- a/frontend/app/src/main/AndroidManifest.xml
+++ b/frontend/app/src/main/AndroidManifest.xml
@@ -3,10 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
+
if (receivingQuizCount) {
@@ -72,38 +77,49 @@ class QuizRemoteDataSource @Inject constructor(
}
}
while (currentCoroutineContext().isActive) {
- val line = it.readLine() ?: continue
+ val line = it.readLine() ?: break
+// println(line)
if (line.startsWith("data:")) {
- val token = line.substring(6)
+ var token = line.substring(6)
+ if (token.isEmpty()) token = "\n"
if (token.contains(":")) {
updateContent(token.substring(0, token.indexOf(":")))
if (quizCount == 0) {
receivingQuizCount = true
} else {
receivingQuiz = content.contains("Q")
+ receivingAnswer = !receivingQuiz
content = ""
}
updateContent(token.substring(token.indexOf(":") + 1))
} else if (token.contains("\n")) {
- updateContent(token.substring(0, token.indexOf("\n")))
if (receivingQuizCount) {
- quizCount = quizCountContent.toInt()
- receivingQuizCount = false
+ println("quizCountContent: $quizCountContent")
+ quizCount = (quizCountContent.trim()).toInt()
emit(QuizResponse(QuizResponseType.COUNT, "", quizCount))
} else if (receivingQuiz) {
- emit(QuizResponse(QuizResponseType.QUESTION, content, 0))
- content = ""
- } else {
- emit(QuizResponse(QuizResponseType.ANSWER, content, token.toInt()))
- content = ""
+ emit(QuizResponse(QuizResponseType.QUESTION_END, "", 0))
+ } else if (receivingAnswer) {
+ emit(QuizResponse(QuizResponseType.ANSWER_END, "", 0))
}
- updateContent(token.substring(token.indexOf("\n") + 1))
+ receivingQuizCount = false
+ receivingAnswer = false
+ receivingQuiz = false
+ content = ""
} else {
updateContent(token)
+ if (receivingQuiz) {
+ emit(QuizResponse(QuizResponseType.STRING, content, 0))
+ content = ""
+ } else if (receivingAnswer) {
+ emit(QuizResponse(QuizResponseType.STRING, content, 0))
+ content = ""
+ }
}
}
}
- } catch (e: Exception) {
+ } catch (e: Throwable) {
+ e.printStackTrace()
throw Throwable("Failed to parse quiz")
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRepository.kt b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRepository.kt
index 7b3c754..527c651 100644
--- a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRepository.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRepository.kt
@@ -1,12 +1,16 @@
package com.example.readability.data.ai
+import com.example.readability.data.NetworkStatusRepository
import com.example.readability.data.user.UserNotSignedInException
import com.example.readability.data.user.UserRepository
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,15 +29,21 @@ enum class QuizLoadState {
class QuizRepository @Inject constructor(
private val quizRemoteDataSource: QuizRemoteDataSource,
private val userRepository: UserRepository,
+ private val networkStatusRepository: NetworkStatusRepository,
) {
private val _quizList = MutableStateFlow(listOf())
private val _quizCount = MutableStateFlow(0)
private val _quizLoadState = MutableStateFlow(QuizLoadState.LOADED)
+ private val quizLoadScope = CoroutineScope(Dispatchers.IO)
+ private var lastQuizLoadJob: Job? = null
val quizList = _quizList.asStateFlow()
val quizCount = _quizCount.asStateFlow()
val quizLoadState = _quizLoadState.asStateFlow()
suspend fun getQuiz(bookId: Int, progress: Double): Result {
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
return withContext(Dispatchers.IO) {
_quizLoadState.update { QuizLoadState.LOADING }
val accessToken = userRepository.getAccessToken()
@@ -41,33 +51,56 @@ class QuizRepository @Inject constructor(
_quizList.update { listOf() }
_quizCount.update { 0 }
try {
- quizRemoteDataSource.getQuiz(bookId, progress, accessToken).collect { response ->
- if (!isActive) return@collect
- if (response.type == QuizResponseType.COUNT) {
- _quizCount.value = response.intData
- } else if (response.type == QuizResponseType.QUESTION) {
- if (_quizList.value.size < response.intData) {
- _quizList.update {
- it.toMutableList().apply {
- add(Quiz(response.data, ""))
+ lastQuizLoadJob?.cancel()
+ var error: Throwable? = null
+ lastQuizLoadJob = quizLoadScope.launch {
+ try {
+ var receivingQuiz = true
+ quizRemoteDataSource.getQuiz(bookId, progress, accessToken).collect { response ->
+ if (!isActive) return@collect
+ if (response.type == QuizResponseType.COUNT) {
+ _quizCount.value = response.intData
+ _quizList.update { listOf(Quiz("", "")) }
+ } else if (response.type == QuizResponseType.QUESTION_END) {
+ receivingQuiz = false
+ } else if (response.type == QuizResponseType.ANSWER_END) {
+ receivingQuiz = true
+ if (_quizList.value.size < _quizCount.value) {
+ _quizList.update {
+ it.toMutableList().apply {
+ add(Quiz("", ""))
+ }
+ }
}
- }
- } else {
- _quizList.update {
- it.toMutableList().apply {
- set(response.intData - 1, Quiz(response.data, ""))
+ } else {
+ val lastIndex = _quizList.value.lastIndex
+ _quizList.update {
+ it.toMutableList().apply {
+ if (receivingQuiz) {
+ set(
+ lastIndex,
+ Quiz(it[lastIndex].question + response.data, it[lastIndex].answer),
+ )
+ } else {
+ set(
+ lastIndex,
+ Quiz(it[lastIndex].question, it[lastIndex].answer + response.data),
+ )
+ }
+ }
}
}
}
- } else if (response.type == QuizResponseType.ANSWER) {
- _quizList.update {
- it.toMutableList().apply {
- set(response.intData - 1, Quiz(this[response.intData - 1].question, response.data))
- }
- }
+ } catch (e: Throwable) {
+ error = e
}
}
- } catch (e: Exception) {
+ lastQuizLoadJob?.join()
+ if (error != null) {
+ return@withContext Result.failure(error!!)
+ }
+ } catch (e: Throwable) {
+ e.printStackTrace()
return@withContext Result.failure(e)
}
if (isActive) {
diff --git a/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRemoteDataSource.kt
index b179e5f..5eb4aa5 100644
--- a/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRemoteDataSource.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRemoteDataSource.kt
@@ -14,9 +14,11 @@ import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
+import retrofit2.http.Streaming
import javax.inject.Inject
interface SummaryAPI {
+ @Streaming
@GET("/summary")
fun getSummary(
@Query("book_id") bookId: Int,
@@ -47,10 +49,15 @@ class SummaryRemoteDataSource @Inject constructor(
val responseBody = response.body() ?: throw Throwable("No body")
responseBody.byteStream().bufferedReader().use {
try {
+ var isFirstToken = true
while (currentCoroutineContext().isActive) {
- val line = it.readLine() ?: continue
+ val line = it.readLine() ?: break
if (line.startsWith("data:")) {
- emit(line.substring(6))
+ var token = line.substring(6)
+ if (isFirstToken) {
+ isFirstToken = false
+ } else if (token.isEmpty()) token = "\n"
+ emit(token)
}
}
} catch (e: Exception) {
diff --git a/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRepository.kt b/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRepository.kt
index 3d3170c..c204873 100644
--- a/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRepository.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/ai/SummaryRepository.kt
@@ -1,10 +1,15 @@
package com.example.readability.data.ai
+import com.example.readability.data.NetworkStatusRepository
import com.example.readability.data.user.UserNotSignedInException
import com.example.readability.data.user.UserRepository
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,23 +18,49 @@ import javax.inject.Singleton
class SummaryRepository @Inject constructor(
private val summaryRemoteDataSource: SummaryRemoteDataSource,
private val userRepository: UserRepository,
+ private val networkStatusRepository: NetworkStatusRepository,
) {
- val summary = MutableStateFlow("")
+ private val summaryLoadScope = CoroutineScope(Dispatchers.IO)
+ private var lastSummaryLoadJob: Job? = null
+
+ private val _summary = MutableStateFlow("")
+
+ val summary = _summary.asStateFlow()
suspend fun getSummary(bookId: Int, progress: Double): Result {
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
return withContext(Dispatchers.IO) {
val accessToken = userRepository.getAccessToken() ?: return@withContext Result.failure(
UserNotSignedInException(),
)
try {
- summaryRemoteDataSource.getSummary(bookId, progress, accessToken).collect { response ->
- if (!isActive) return@collect
- summary.value += response
+ lastSummaryLoadJob?.cancel()
+ var error: Throwable? = null
+ lastSummaryLoadJob = summaryLoadScope.launch {
+ try {
+ _summary.value = ""
+ summaryRemoteDataSource.getSummary(bookId, progress, accessToken).collect { response ->
+ if (!isActive) return@collect
+ _summary.value += response
+ }
+ } catch (e: Throwable) {
+ error = e
+ }
}
- } catch (e: Exception) {
+ lastSummaryLoadJob?.join()
+ if (error != null) {
+ return@withContext Result.failure(error!!)
+ }
+ } catch (e: Throwable) {
return@withContext Result.failure(e)
}
Result.success(Unit)
}
}
+
+ fun clearSummary() {
+ _summary.value = ""
+ }
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/book/BookDatabase.kt b/frontend/app/src/main/java/com/example/readability/data/book/BookDatabase.kt
index 60728ee..eb57a2b 100644
--- a/frontend/app/src/main/java/com/example/readability/data/book/BookDatabase.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/book/BookDatabase.kt
@@ -1,15 +1,9 @@
package com.example.readability.data.book
import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.asAndroidBitmap
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
-import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
@@ -24,23 +18,10 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
-import java.io.ByteArrayOutputStream
import java.util.Date
import javax.inject.Singleton
class BookTypeConverters {
- @TypeConverter
- fun toByteArray(imageBitmap: ImageBitmap): ByteArray {
- val outputStream = ByteArrayOutputStream()
- imageBitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outputStream)
- return outputStream.toByteArray()
- }
-
- @TypeConverter
- fun toImageBitmap(byteArray: ByteArray): ImageBitmap {
- return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size).asImageBitmap()
- }
-
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
@@ -53,54 +34,51 @@ class BookTypeConverters {
}
@Entity
-data class Book(
+data class BookEntity(
@PrimaryKey @ColumnInfo(name = "book_id") val bookId: Int,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "author") val author: String,
@ColumnInfo(name = "progress") val progress: Double,
@ColumnInfo(name = "cover_image") val coverImage: String?,
- @ColumnInfo(
- name = "cover_image_data",
- ) val coverImageData: ImageBitmap? = null,
@ColumnInfo(name = "content") val content: String,
- @ColumnInfo(name = "content_data") val contentData: String? = null,
@ColumnInfo(name = "last_read") val lastRead: Date = Date(0),
+ @ColumnInfo(name = "summary_progress") val summaryProgress: Double = 0.0,
)
@Dao
interface BookDao {
- @Query("SELECT * FROM Book")
- fun getAll(): List
+ @Query("SELECT * FROM BookEntity")
+ fun getAll(): List
- @Query("SELECT * FROM Book WHERE book_id = :bookId")
- fun getBook(bookId: Int): Book?
+ @Query("SELECT * FROM BookEntity WHERE book_id = :bookId")
+ fun getBook(bookId: Int): BookEntity?
+
+ @Query("SELECT summary_progress FROM BookEntity WHERE book_id = :bookId")
+ fun getSummaryProgress(bookId: Int): Double?
@Insert
- fun insert(book: Book)
+ fun insert(book: BookEntity)
@Insert
- fun insertAll(vararg books: Book)
+ fun insertAll(vararg books: BookEntity)
@Update
- fun update(book: Book)
+ fun update(book: BookEntity)
- @Delete
- fun delete(book: Book)
+ @Query("DELETE FROM BookEntity WHERE book_id = :bookId")
+ fun delete(bookId: Int)
- @Query("DELETE FROM Book")
+ @Query("DELETE FROM BookEntity")
fun deleteAll()
- @Query("UPDATE Book SET progress = :progress WHERE book_id = :bookId")
+ @Query("UPDATE BookEntity SET progress = :progress WHERE book_id = :bookId")
fun updateProgress(bookId: Int, progress: Double)
- @Query("UPDATE Book SET cover_image_data = :coverImageData WHERE book_id = :bookId")
- fun updateCoverImageData(bookId: Int, coverImageData: ImageBitmap?)
-
- @Query("UPDATE Book SET content_data = :contentData WHERE book_id = :bookId")
- fun updateContentData(bookId: Int, contentData: String?)
+ @Query("UPDATE BookEntity SET summary_progress = :summaryProgress WHERE book_id = :bookId")
+ fun updateSummaryProgress(bookId: Int, summaryProgress: Double)
}
-@Database(entities = [Book::class], version = 2)
+@Database(entities = [BookEntity::class], version = 3)
@TypeConverters(BookTypeConverters::class)
abstract class BookDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
@@ -121,6 +99,8 @@ class BookDatabaseModule {
appContext,
BookDatabase::class.java,
"Book",
- ).build()
+ )
+ .fallbackToDestructiveMigration()
+ .build()
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/book/BookFileDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/book/BookFileDataSource.kt
new file mode 100644
index 0000000..060d406
--- /dev/null
+++ b/frontend/app/src/main/java/com/example/readability/data/book/BookFileDataSource.kt
@@ -0,0 +1,132 @@
+package com.example.readability.data.book
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BookFileDataSource @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ init {
+ if (!File(context.filesDir.path + "/book_cover").exists()) {
+ File(context.filesDir.path + "/book_cover").mkdir()
+ }
+ if (!File(context.filesDir.path + "/book_content").exists()) {
+ File(context.filesDir.path + "/book_content").mkdir()
+ }
+ }
+ fun contentExists(bookId: Int): Boolean {
+ val bookContentPath = context.filesDir.path + "/book_content/$bookId.txt"
+ return try {
+ File(bookContentPath).exists()
+ } catch (e: Exception) {
+ println("BookFileDataSource: contentExists failed: ${e.message}")
+ false
+ }
+ }
+ fun readContentFile(bookId: Int): String? {
+ val bookContentPath = context.filesDir.path + "/book_content/$bookId.txt"
+ return if (contentExists(bookId)) {
+ try {
+ FileInputStream(bookContentPath).bufferedReader().use {
+ it.readText()
+ }
+ } catch (e: Exception) {
+ println("BookFileDataSource: readContentFile failed: ${e.message}")
+ null
+ }
+ } else {
+ null
+ }
+ }
+
+ fun writeContentFile(bookId: Int, content: String) {
+ val bookContentPath = context.filesDir.path + "/book_content/$bookId.txt"
+ try {
+ FileOutputStream(bookContentPath).bufferedWriter().use {
+ it.write(content)
+ }
+ } catch (e: Exception) {
+ println("BookFileDataSource: writeContentFile failed: ${e.message}")
+ }
+ }
+
+ fun deleteContentFile(bookId: Int) {
+ val bookContentPath = context.filesDir.path + "/book_content/$bookId.txt"
+ try {
+ File(bookContentPath).delete()
+ } catch (e: Exception) {
+ println("BookFileDataSource: deleteContentFile failed: ${e.message}")
+ }
+ }
+
+ fun coverImageExists(bookId: Int): Boolean {
+ val coverImagePath = context.filesDir.path + "/book_cover/$bookId.png"
+ println("BookFileDataSource: check for exist: $coverImagePath")
+ return try {
+ File(coverImagePath).exists()
+ } catch (e: Exception) {
+ println("BookFileDataSource: coverImageExists failed: ${e.message}")
+ false
+ }
+ }
+
+ fun readCoverImageFile(bookId: Int): ImageBitmap? {
+ val coverImagePath = context.filesDir.path + "/book_cover/$bookId.png"
+ return if (coverImageExists(bookId)) {
+ try {
+ FileInputStream(coverImagePath).use {
+ val byteArray = it.readBytes()
+ BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size).asImageBitmap()
+ }
+ } catch (e: Exception) {
+ println("BookFileDataSource: readCoverImageFile failed: ${e.message}")
+ null
+ }
+ } else {
+ null
+ }
+ }
+
+ fun writeCoverImageFile(bookId: Int, coverImage: ImageBitmap) {
+ val coverImagePath = context.filesDir.path + "/book_cover/$bookId.png"
+ println("BookFileDataSource: write cover image: $coverImagePath")
+ try {
+ FileOutputStream(coverImagePath).use {
+ coverImage.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, 100, it)
+ }
+ } catch (e: Exception) {
+ println("BookFileDataSource: writeCoverImageFile failed: ${e.message}")
+ }
+ }
+
+ fun deleteCoverImageFile(bookId: Int) {
+ val coverImagePath = context.filesDir.path + "/book_cover/$bookId.png"
+ try {
+ File(coverImagePath).delete()
+ } catch (e: Exception) {
+ println("BookFileDataSource: deleteCoverImageFile failed: ${e.message}")
+ }
+ }
+
+ fun deleteAll() {
+ try {
+ File(context.filesDir.path + "/book_cover").deleteRecursively()
+ File(context.filesDir.path + "/book_cover").mkdir()
+ File(context.filesDir.path + "/book_content").deleteRecursively()
+ File(context.filesDir.path + "/book_content").mkdir()
+ } catch (e: Exception) {
+ println("BookFileDataSource: clearAll failed: ${e.message}")
+ }
+ }
+}
diff --git a/frontend/app/src/main/java/com/example/readability/data/book/BookRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/book/BookRemoteDataSource.kt
index 54a7ecb..6315b72 100644
--- a/frontend/app/src/main/java/com/example/readability/data/book/BookRemoteDataSource.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/book/BookRemoteDataSource.kt
@@ -14,9 +14,12 @@ import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
+import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import javax.inject.Inject
@@ -30,6 +33,7 @@ data class BookCardData(
val coverImage: String? = null,
val coverImageData: ImageBitmap? = null,
val content: String,
+ val summaryProgress: Double,
)
data class BookResponse(
@@ -52,6 +56,10 @@ data class BooksResponse(
val books: List,
)
+data class SummaryProgressResponse(
+ val summary_progress: String,
+)
+
interface BookAPI {
@Headers("Accept: application/json")
@GET("/books")
@@ -71,8 +79,24 @@ interface BookAPI {
@Query("access_token") accessToken: String,
): Call
+ @GET("/book/{book_id}/current_inference")
+ fun getSummaryProgress(
+ @Path("book_id") bookId: Int,
+ @Query("access_token") accessToken: String,
+ ): Call
+
@POST("/book/add")
fun addBook(@Query("access_token") accessToken: String, @Body book: AddBookRequest): Call
+
+ @PUT("/book/{book_id}/progress")
+ fun updateProgress(
+ @Path("book_id") bookId: Int,
+ @Query("progress") progress: Double,
+ @Query("access_token") accessToken: String,
+ ): Call
+
+ @DELETE("/book/delete")
+ fun deleteBook(@Query("book_id") bookId: Int, @Query("access_token") accessToken: String): Call
}
@InstallIn(SingletonComponent::class)
@@ -91,15 +115,15 @@ class BookRemoteDataSource @Inject constructor(
private val bookAPI: BookAPI,
) {
- fun getBookList(accessToken: String): Result> {
+ fun getBookList(accessToken: String): Result> {
try {
val response = bookAPI.getBooks(accessToken).execute()
if (response.isSuccessful) {
val responseBody = response.body() ?: return Result.failure(Throwable("No body"))
return Result.success(
responseBody.books.map {
- BookCardData(
- id = it.book_id,
+ Book(
+ bookId = it.book_id,
title = it.title,
author = it.author,
progress = it.progress,
@@ -149,6 +173,20 @@ class BookRemoteDataSource @Inject constructor(
}
}
+ fun getSummaryProgress(accessToken: String, bookId: Int): Result {
+ try {
+ val response = bookAPI.getSummaryProgress(bookId, accessToken).execute()
+ if (response.isSuccessful) {
+ val responseBody = response.body() ?: return Result.failure(Throwable("No body"))
+ return Result.success(responseBody.summary_progress)
+ } else {
+ return Result.failure(Throwable(parseErrorBody(response.errorBody())))
+ }
+ } catch (e: Exception) {
+ return Result.failure(e)
+ }
+ }
+
fun addBook(accessToken: String, req: AddBookRequest): Result {
try {
val response = bookAPI.addBook(accessToken, req).execute()
@@ -161,4 +199,32 @@ class BookRemoteDataSource @Inject constructor(
return Result.failure(e)
}
}
+
+ fun deleteBook(bookId: Int, accessToken: String): Result {
+ try {
+ val response = bookAPI.deleteBook(bookId, accessToken).execute()
+ if (response.isSuccessful) {
+ val responseBody = response.body() ?: return Result.failure(Throwable("No body"))
+ return Result.success(responseBody.string())
+ } else {
+ return Result.failure(Throwable(parseErrorBody(response.errorBody())))
+ }
+ } catch (e: Exception) {
+ return Result.failure(e)
+ }
+ }
+
+ fun updateProgress(bookId: Int, progress: Double, accessToken: String): Result {
+ try {
+ val response = bookAPI.updateProgress(bookId, progress, accessToken).execute()
+ if (response.isSuccessful) {
+ val responseBody = response.body() ?: return Result.failure(Throwable("No body"))
+ return Result.success(responseBody.string())
+ } else {
+ return Result.failure(Throwable(parseErrorBody(response.errorBody())))
+ }
+ } catch (e: Exception) {
+ return Result.failure(e)
+ }
+ }
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/book/BookRepository.kt b/frontend/app/src/main/java/com/example/readability/data/book/BookRepository.kt
index 8f776af..9704931 100644
--- a/frontend/app/src/main/java/com/example/readability/data/book/BookRepository.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/book/BookRepository.kt
@@ -1,23 +1,76 @@
package com.example.readability.data.book
+import androidx.compose.ui.graphics.ImageBitmap
+import com.example.readability.data.NetworkStatusRepository
import com.example.readability.data.user.UserNotSignedInException
import com.example.readability.data.user.UserRepository
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
+import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
+data class Book(
+ val bookId: Int,
+ val title: String,
+ val author: String,
+ val progress: Double,
+ val coverImage: String?,
+ val coverImageData: ImageBitmap? = null,
+ val content: String,
+ val contentData: String? = null,
+ val lastRead: Date = Date(0),
+ val summaryProgress: Double = 0.0,
+) {
+ companion object {
+ fun fromBookEntity(bookEntity: BookEntity): Book {
+ return Book(
+ bookId = bookEntity.bookId,
+ title = bookEntity.title,
+ author = bookEntity.author,
+ progress = bookEntity.progress,
+ coverImage = bookEntity.coverImage,
+ content = bookEntity.content,
+ summaryProgress = bookEntity.summaryProgress,
+ lastRead = bookEntity.lastRead,
+ )
+ }
+ }
+
+ fun toBookEntity(): BookEntity {
+ return BookEntity(
+ bookId = this.bookId,
+ title = this.title,
+ author = this.author,
+ progress = this.progress,
+ coverImage = this.coverImage,
+ content = this.content,
+ summaryProgress = this.summaryProgress,
+ lastRead = this.lastRead,
+ )
+ }
+}
+
@Singleton
class BookRepository @Inject constructor(
private val bookDao: BookDao,
+ private val bookFileDataSource: BookFileDataSource,
private val bookRemoteDataSource: BookRemoteDataSource,
private val userRepository: UserRepository,
+ private val networkStatusRepository: NetworkStatusRepository,
) {
private val bookMap = MutableStateFlow(mutableMapOf())
+ private val progressUpdateScope = CoroutineScope(Dispatchers.IO)
+ private var progressUpdateJob: Job? = null
val bookList = bookMap.map {
it.values.toList()
@@ -34,7 +87,7 @@ class BookRepository @Inject constructor(
val bookList = bookDao.getAll()
val map = mutableMapOf()
bookList.forEach { book ->
- map[book.bookId] = book
+ map[book.bookId] = Book.fromBookEntity(book)
}
bookMap.value = map
}
@@ -44,30 +97,28 @@ class BookRepository @Inject constructor(
suspend fun refreshBookList(): Result {
val accessToken =
userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
return bookRemoteDataSource.getBookList(accessToken).fold(onSuccess = {
+ newBookList ->
val newMap = bookMap.value.toMutableMap()
- it.forEach { book ->
+ newBookList.forEach { book ->
println("BookRepository: book: $book")
- if (bookDao.getBook(book.id) != null) {
- bookDao.updateProgress(book.id, book.progress)
- newMap[book.id] = newMap[book.id]!!.copy(progress = book.progress)
+ if (bookDao.getBook(book.bookId) != null) {
+ bookDao.updateProgress(book.bookId, book.progress)
+ newMap[book.bookId] = newMap[book.bookId]!!.copy(progress = book.progress)
} else {
- val bookObject = Book(
- bookId = book.id,
- title = book.title,
- author = book.author,
- progress = book.progress,
- coverImage = book.coverImage,
- content = book.content,
- )
- bookDao.insert(bookObject)
- newMap[book.id] = bookObject
+ bookDao.insert(book.toBookEntity())
+ newMap[book.bookId] = book
}
}
// delete books that are not in the list
bookDao.getAll().forEach { book ->
- if (it.find { book.bookId == it.id } == null) {
- bookDao.delete(book)
+ if (newBookList.find { book.bookId == it.bookId } == null) {
+ bookFileDataSource.deleteContentFile(book.bookId)
+ bookFileDataSource.deleteCoverImageFile(book.bookId)
+ bookDao.delete(book.bookId)
newMap.remove(book.bookId)
}
}
@@ -79,20 +130,33 @@ class BookRepository @Inject constructor(
}
suspend fun getCoverImageData(bookId: Int): Result {
+ // find book
+ val book = bookMap.value[bookId] ?: return Result.failure(Exception("Book not found"))
+ // first check if the cover image is already downloaded
+ if (bookFileDataSource.coverImageExists(bookId)) {
+ println("BookRepository: cover image exists")
+ bookMap.update {
+ it.toMutableMap().apply {
+ this[bookId] = this[bookId]!!.copy(coverImageData = bookFileDataSource.readCoverImageFile(bookId))
+ }
+ }
+ return Result.success(Unit)
+ }
+ println("BookRepository: cover image does not exist")
+
+ // else, download the cover image
+ // check if the user is signed in
val accessToken =
userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
- val book = bookDao.getBook(bookId) ?: return Result.failure(
- Exception("Book not found"),
- )
- if (book.coverImageData != null) {
- return Result.success(Unit)
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
}
if (book.coverImage == null) {
- return Result.failure(Exception("Book cover image not found"))
+ return Result.failure(Exception("Book cover image path not found"))
}
return bookRemoteDataSource.getCoverImageData(accessToken, book.coverImage)
.fold(onSuccess = { image ->
- bookDao.updateCoverImageData(bookId, image)
+ bookFileDataSource.writeCoverImageFile(bookId, image)
bookMap.update {
it.toMutableMap().apply {
this[bookId] = book.copy(coverImageData = image)
@@ -105,17 +169,28 @@ class BookRepository @Inject constructor(
}
suspend fun getContentData(bookId: Int): Result {
+ // find book
+ val book = bookMap.value[bookId] ?: return Result.failure(Exception("Book not found"))
+ // first check if the content data is already downloaded
+ if (bookFileDataSource.contentExists(bookId)) {
+ println("BookRepository: content exists")
+ bookMap.update {
+ it.toMutableMap().apply {
+ this[bookId] = this[bookId]!!.copy(contentData = bookFileDataSource.readContentFile(bookId))
+ }
+ }
+ return Result.success(Unit)
+ }
+
+ // else, download the content data
val accessToken =
userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
- val book = bookDao.getBook(bookId) ?: return Result.failure(
- Exception("Book not found"),
- )
- if (book.contentData != null) {
- return Result.success(Unit)
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
}
return bookRemoteDataSource.getContentData(accessToken, book.content)
.fold(onSuccess = { contentData ->
- bookDao.updateContentData(bookId, contentData)
+ bookFileDataSource.writeContentFile(bookId, contentData)
bookMap.update {
it.toMutableMap().apply {
this[bookId] = book.copy(contentData = contentData)
@@ -127,9 +202,33 @@ class BookRepository @Inject constructor(
})
}
+ suspend fun updateSummaryProgress(bookId: Int): Result {
+ val accessToken =
+ userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
+ return bookRemoteDataSource.getSummaryProgress(accessToken, bookId)
+ .fold(onSuccess = { summaryProgress ->
+ delay(1000L)
+ bookDao.updateSummaryProgress(bookId, summaryProgress.toDouble())
+ bookMap.update {
+ it.toMutableMap().apply {
+ this[bookId] = this[bookId]!!.copy(summaryProgress = summaryProgress.toDouble())
+ }
+ }
+ Result.success(Unit)
+ }, onFailure = {
+ Result.failure(it)
+ })
+ }
+
suspend fun addBook(data: AddBookRequest): Result {
val accessToken =
userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
return bookRemoteDataSource.addBook(accessToken, data).fold(onSuccess = {
refreshBookList()
}, onFailure = {
@@ -137,16 +236,53 @@ class BookRepository @Inject constructor(
})
}
- suspend fun updateProgress(bookId: Int, progress: Double) {
+ suspend fun updateProgress(bookId: Int, progress: Double): Result {
+ val accessToken =
+ userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
bookDao.updateProgress(bookId, progress)
bookMap.update {
it.toMutableMap().apply {
this[bookId] = this[bookId]!!.copy(progress = progress)
}
}
+
+ progressUpdateJob?.cancel()
+ progressUpdateJob = progressUpdateScope.launch {
+ delay(100L)
+ if (!isActive || !networkStatusRepository.isConnected) {
+ return@launch
+ }
+ bookRemoteDataSource.updateProgress(bookId, progress, accessToken)
+ }
+ return Result.success(Unit)
+ }
+
+ suspend fun deleteBook(bookId: Int): Result {
+ val accessToken =
+ userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
+ return bookRemoteDataSource.deleteBook(bookId, accessToken).fold(onSuccess = {
+ val book = bookMap.value[bookId] ?: return Result.failure(UserNotSignedInException())
+
+ bookFileDataSource.deleteContentFile(bookId)
+ bookFileDataSource.deleteCoverImageFile(bookId)
+ bookDao.delete(book.bookId)
+ bookMap.update {
+ val newMap = it.toMutableMap()
+ newMap.remove(bookId)
+ newMap
+ }
+
+ refreshBookList()
+ }, onFailure = {
+ Result.failure(it)
+ })
}
suspend fun clearBooks() {
+ bookFileDataSource.deleteAll()
bookDao.deleteAll()
bookMap.value = mutableMapOf()
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/user/UserException.kt b/frontend/app/src/main/java/com/example/readability/data/user/UserException.kt
index 935a708..d076deb 100644
--- a/frontend/app/src/main/java/com/example/readability/data/user/UserException.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/user/UserException.kt
@@ -1,7 +1,10 @@
package com.example.readability.data.user
// top class for all user exceptions
-open class UserException : Throwable()
+open class UserException(
+ override val message: String? = null,
+ override val cause: Throwable? = null,
+) : Throwable()
// exception for when the user is not signed in
-class UserNotSignedInException : UserException()
+class UserNotSignedInException : UserException("User is not signed in")
diff --git a/frontend/app/src/main/java/com/example/readability/data/user/UserRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/user/UserRemoteDataSource.kt
index f8ccade..b8d05b3 100644
--- a/frontend/app/src/main/java/com/example/readability/data/user/UserRemoteDataSource.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/user/UserRemoteDataSource.kt
@@ -11,6 +11,7 @@ import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
@@ -45,6 +46,17 @@ data class UserInfoResponse(
val verified: Int,
)
+data class UserData(
+ val userName: String? = "",
+ val userEmail: String,
+ val refreshToken: String?,
+ val refreshTokenLife: Long?,
+ val accessToken: String?,
+ val accessTokenLife: Long?,
+ val createdAt: String?,
+ val verified: Int?,
+)
+
interface UserAPI {
@Headers("Accept: application/json")
@FormUrlEncoded
@@ -67,8 +79,11 @@ interface UserAPI {
@POST("/user/signup")
fun signUp(@Body signUpRequest: SignUpRequest): Call
- @POST("/user/info")
+ @GET("/user/info")
fun getUserInfo(@Query("access_token") accessToken: String): Call
+
+ @POST("/user/change_password")
+ fun changePassword(@Query("access_token") accessToken: String, @Query("password") newPassword: String): Call
}
@InstallIn(SingletonComponent::class)
@@ -156,4 +171,13 @@ class UserRemoteDataSource @Inject constructor(
return Result.failure(Throwable(parseErrorBody(result.errorBody())))
}
}
+
+ suspend fun changePassword(accessToken: String, newPassword: String): Result {
+ val result = userApi.changePassword(accessToken, newPassword).execute()
+ if (result.isSuccessful) {
+ return Result.success(Unit)
+ } else {
+ return Result.failure(Throwable(parseErrorBody(result.errorBody())))
+ }
+ }
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/user/UserRepository.kt b/frontend/app/src/main/java/com/example/readability/data/user/UserRepository.kt
index 1a8ce41..56357f3 100644
--- a/frontend/app/src/main/java/com/example/readability/data/user/UserRepository.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/user/UserRepository.kt
@@ -1,6 +1,6 @@
package com.example.readability.data.user
-import com.example.readability.data.book.BookDao
+import com.example.readability.data.NetworkStatusRepository
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -10,10 +10,14 @@ import javax.inject.Singleton
class UserRepository @Inject constructor(
private val userRemoteDataSource: UserRemoteDataSource,
private val userDao: UserDao,
- private val bookDao: BookDao,
+ private val networkStatusRepository: NetworkStatusRepository,
) {
val user = userDao.get()
+
suspend fun signIn(email: String, password: String): Result {
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
userRemoteDataSource.signIn(email, password).fold(onSuccess = {
userDao.insert(
User(
@@ -34,6 +38,9 @@ class UserRepository @Inject constructor(
}
suspend fun signUp(email: String, username: String, password: String): Result {
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
userRemoteDataSource.signUp(email, username, password).fold(onSuccess = {
return signIn(email, password)
}, onFailure = {
@@ -60,6 +67,9 @@ class UserRepository @Inject constructor(
private suspend fun refreshAccessToken(): Result {
val refreshToken = getRefreshToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
userRemoteDataSource.refreshAccessToken(refreshToken).fold(onSuccess = {
userDao.updateAccessToken(
it,
@@ -73,6 +83,9 @@ class UserRepository @Inject constructor(
suspend fun getUserInfo(): Result {
val accessToken = getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
userRemoteDataSource.getUserInfo(accessToken).fold(onSuccess = {
userDao.updateUserInfo(
username = it.username,
@@ -86,9 +99,15 @@ class UserRepository @Inject constructor(
})
}
+ suspend fun changePassword(newPassword: String): Result {
+ val accessToken = getAccessToken() ?: return Result.failure(UserNotSignedInException())
+ if (!networkStatusRepository.isConnected) {
+ return Result.failure(Exception("Network not connected"))
+ }
+ return userRemoteDataSource.changePassword(accessToken, newPassword)
+ }
+
fun signOut() {
- bookDao.deleteAll()
userDao.deleteAll()
- // TODO: clear bookMap too - currently not possible because injecting BookRepository into UserRepository causes a circular dependency
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/viewer/FontDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/viewer/FontDataSource.kt
index d750c35..5fde890 100644
--- a/frontend/app/src/main/java/com/example/readability/data/viewer/FontDataSource.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/viewer/FontDataSource.kt
@@ -8,6 +8,7 @@ import android.util.Log
import androidx.core.content.res.ResourcesCompat
import com.example.readability.R
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@@ -35,13 +36,25 @@ class FontDataSource @Inject constructor(
private var lastTextSize = 0f
private var lastLetterSpacing = 0f
- private var customTypeface: Typeface? = null
+ var customTypeface: MutableStateFlow = MutableStateFlow(null)
+
+ // line height with 16dp text size
+ var referenceLineHeight: MutableStateFlow = MutableStateFlow(0f)
private var customTypefaceName = ""
init {
customTypefaceName = "garamond"
- customTypeface = ResourcesCompat.getFont(context, R.font.garamond)
+ customTypeface.value = ResourcesCompat.getFont(context, R.font.garamond)
calculateReferenceCharWidth(ViewerStyle())
+ updateReferenceLineHeight()
+ }
+
+ /**
+ * Update reference line height with current typeface
+ */
+ private fun updateReferenceLineHeight() {
+ val fontMetrics = buildTextPaint(ViewerStyle(textSize = 16f, letterSpacing = 0f)).fontMetrics
+ referenceLineHeight.value = fontMetrics.bottom - fontMetrics.top + fontMetrics.leading
}
/**
@@ -52,10 +65,11 @@ class FontDataSource @Inject constructor(
fun getCharWidthArray(viewerStyle: ViewerStyle): FloatArray {
if (viewerStyle.fontFamily != customTypefaceName) {
customTypefaceName = viewerStyle.fontFamily
- customTypeface = ResourcesCompat.getFont(
+ customTypeface.value = ResourcesCompat.getFont(
context, fontMap[viewerStyle.fontFamily] ?: R.font.garamond,
)
calculateReferenceCharWidth(viewerStyle)
+ updateReferenceLineHeight()
} else if (viewerStyle.textSize != lastTextSize || viewerStyle.letterSpacing != lastLetterSpacing) {
lastTextSize = viewerStyle.textSize
lastLetterSpacing = viewerStyle.letterSpacing
@@ -73,7 +87,7 @@ class FontDataSource @Inject constructor(
val textPaint = TextPaint()
textPaint.isAntiAlias = true
textPaint.textSize = viewerStyle.textSize * density
- textPaint.typeface = customTypeface
+ textPaint.typeface = customTypeface.value
textPaint.letterSpacing = viewerStyle.letterSpacing
return textPaint
}
diff --git a/frontend/app/src/main/java/com/example/readability/data/viewer/PageSplitRepository.kt b/frontend/app/src/main/java/com/example/readability/data/viewer/PageSplitRepository.kt
index 2bbc660..e7f8c8b 100644
--- a/frontend/app/src/main/java/com/example/readability/data/viewer/PageSplitRepository.kt
+++ b/frontend/app/src/main/java/com/example/readability/data/viewer/PageSplitRepository.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.math.roundToInt
data class PageSplitData(
var pageSplits: List,
@@ -18,7 +19,12 @@ data class PageSplitData(
fun PageSplitData.getPageIndex(progress: Double): Int {
assert(progress in 0.0..1.0)
- return (pageSplits.size * progress).toInt()
+ return ((pageSplits.size - 1) * progress).roundToInt()
+}
+
+fun PageSplitData.getPageProgress(page: Int): Double {
+ assert(page in pageSplits.indices)
+ return page.toDouble() / (pageSplits.size - 1)
}
@Singleton
@@ -53,8 +59,8 @@ class PageSplitRepository @Inject constructor(
}.firstOrNull() ?: return null
val content = book.contentData ?: return null
val viewerStyle = settingRepository.viewerStyle.firstOrNull() ?: return null
- val textPaint = fontDataSource.buildTextPaint(viewerStyle)
val charWidths = fontDataSource.getCharWidthArray(viewerStyle)
+ val textPaint = fontDataSource.buildTextPaint(viewerStyle)
val pageSplits = pageSplitDataSource.splitPage(
width = width,
height = height,
@@ -89,13 +95,13 @@ class PageSplitRepository @Inject constructor(
pageSplitData.pageSplits[page],
)
val viewerStyle = pageSplitData.viewerStyle
+ val charWidths = fontDataSource.getCharWidthArray(viewerStyle)
val textPaint = fontDataSource.buildTextPaint(viewerStyle)
if (isDarkMode) {
textPaint.color = viewerStyle.darkTextColor
} else {
textPaint.color = viewerStyle.brightTextColor
}
- val charWidths = fontDataSource.getCharWidthArray(viewerStyle)
pageSplitDataSource.drawPage(
canvas = canvas,
width = pageSplitData.width,
@@ -113,8 +119,8 @@ class PageSplitRepository @Inject constructor(
width: Int,
isDarkMode: Boolean,
) {
- val textPaint = fontDataSource.buildTextPaint(viewerStyle)
val charWidths = fontDataSource.getCharWidthArray(viewerStyle)
+ val textPaint = fontDataSource.buildTextPaint(viewerStyle)
if (isDarkMode) {
textPaint.color = viewerStyle.darkTextColor
} else {
diff --git a/frontend/app/src/main/java/com/example/readability/ui/animation/FadeThroughTransition.kt b/frontend/app/src/main/java/com/example/readability/ui/animation/FadeThroughTransition.kt
index a89757c..64c434a 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/animation/FadeThroughTransition.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/animation/FadeThroughTransition.kt
@@ -31,10 +31,10 @@ fun NavGraphBuilder.composableFadeThrough(
animationSpec = tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), initialScale = DEFAULT_START_SCALE,
) + fadeIn(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(),
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(FADE_THROUGH_THRESHOLD, 1f, EASING_EMPHASIZED.transform(it))
+ },
)
},
exitTransition = {
@@ -42,8 +42,10 @@ fun NavGraphBuilder.composableFadeThrough(
animationSpec = tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), targetScale = DEFAULT_START_SCALE,
) + fadeOut(
animationSpec = tween(
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(), 0, EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(0f, FADE_THROUGH_THRESHOLD, EASING_EMPHASIZED.transform(it))
+ },
)
},
popEnterTransition = {
@@ -51,20 +53,21 @@ fun NavGraphBuilder.composableFadeThrough(
animationSpec = tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), initialScale = DEFAULT_START_SCALE,
) + fadeIn(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(),
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(FADE_THROUGH_THRESHOLD, 1f, EASING_EMPHASIZED.transform(it))
+ },
)
},
popExitTransition = {
scaleOut(
-
animationSpec = tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), targetScale = DEFAULT_START_SCALE,
) + fadeOut(
animationSpec = tween(
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(), 0, EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(0f, FADE_THROUGH_THRESHOLD, EASING_EMPHASIZED.transform(it))
+ },
)
},
)
diff --git a/frontend/app/src/main/java/com/example/readability/ui/animation/SharedAxisTransition.kt b/frontend/app/src/main/java/com/example/readability/ui/animation/SharedAxisTransition.kt
index 4611cfb..4d4f023 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/animation/SharedAxisTransition.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/animation/SharedAxisTransition.kt
@@ -22,6 +22,15 @@ enum class SharedAxis {
Y,
Z,
}
+fun lerp(startFraction: Float, endFraction: Float, fraction: Float): Float {
+ if (fraction <= startFraction) {
+ return 0f
+ }
+ if (fraction >= endFraction) {
+ return 1f
+ }
+ return (fraction - startFraction) / (endFraction - startFraction)
+}
fun NavGraphBuilder.composableSharedAxis(
route: String,
@@ -49,10 +58,10 @@ fun NavGraphBuilder.composableSharedAxis(
),
) + fadeIn(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(),
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(FADE_THROUGH_THRESHOLD, 1f, EASING_EMPHASIZED.transform(it))
+ },
)
},
exitTransition = {
@@ -63,10 +72,10 @@ fun NavGraphBuilder.composableSharedAxis(
),
) + fadeOut(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- 0,
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(0f, FADE_THROUGH_THRESHOLD, EASING_EMPHASIZED.transform(it))
+ },
)
},
popEnterTransition = {
@@ -77,10 +86,10 @@ fun NavGraphBuilder.composableSharedAxis(
),
) + fadeIn(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- (DURATION_EMPHASIZED * FADE_THROUGH_THRESHOLD).toInt(),
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(FADE_THROUGH_THRESHOLD, 1f, EASING_EMPHASIZED.transform(it))
+ },
)
},
popExitTransition = {
@@ -91,10 +100,10 @@ fun NavGraphBuilder.composableSharedAxis(
),
) + fadeOut(
animationSpec = tween(
- (DURATION_EMPHASIZED * (1 - FADE_THROUGH_THRESHOLD)).toInt(),
- 0,
- EASING_EMPHASIZED,
- ),
+ DURATION_EMPHASIZED,
+ ) {
+ lerp(0f, FADE_THROUGH_THRESHOLD, EASING_EMPHASIZED.transform(it))
+ },
)
},
)
diff --git a/frontend/app/src/main/java/com/example/readability/ui/components/BottomSheetView.kt b/frontend/app/src/main/java/com/example/readability/ui/components/BottomSheetView.kt
index b976012..0b8a648 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/components/BottomSheetView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/components/BottomSheetView.kt
@@ -1,3 +1,4 @@
+import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -6,25 +7,36 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+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.ImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -37,7 +49,12 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressChanged: (Int, Double) -> Unit) {
+fun BottomSheet(
+ bookCardData: BookCardData,
+ onDismiss: () -> Unit,
+ onProgressChanged: (Int, Double) -> Unit,
+ onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) },
+) {
val modalBottomSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
)
@@ -47,7 +64,6 @@ fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressCha
onDismissRequest = { onDismiss() },
sheetState = modalBottomSheetState,
dragHandle = { BottomSheetDefaults.DragHandle() },
-
) {
BottomSheetContent(
modifier = Modifier.fillMaxWidth(),
@@ -59,6 +75,7 @@ fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressCha
}
},
onProgressChanged = onProgressChanged,
+ onBookDeleted = onBookDeleted,
)
}
}
@@ -74,7 +91,7 @@ fun BottomSheetPreview() {
.padding(0.dp, 48.dp, 0.dp, 0.dp),
) {
BottomSheetContent(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxWidth(),
bookCardData = BookCardData(
id = 1,
title = "The Open Boat",
@@ -82,6 +99,7 @@ fun BottomSheetPreview() {
progress = 0.5,
coverImageData = null,
content = "asd",
+ summaryProgress = 0.5,
),
)
}
@@ -94,7 +112,63 @@ fun BottomSheetContent(
bookCardData: BookCardData,
onDismiss: () -> Unit = {},
onProgressChanged: (Int, Double) -> Unit = { _, _ -> },
+ onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) },
) {
+ var showDeleteBookDialog by remember { mutableStateOf(false) }
+
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ if (showDeleteBookDialog) {
+ AlertDialog(icon = {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = "Info",
+ )
+ }, onDismissRequest = { showDeleteBookDialog = false }, confirmButton = {
+ Button(
+ colors = ButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ disabledContentColor = MaterialTheme.colorScheme.onError,
+ disabledContainerColor = MaterialTheme.colorScheme.error,
+ ),
+ onClick = {
+ scope.launch {
+ onBookDeleted(bookCardData.id).onSuccess {
+ Toast.makeText(
+ context,
+ "Book deleted",
+ Toast.LENGTH_SHORT,
+ ).show()
+ onDismiss()
+// onNavigateBookList()
+ }.onFailure {
+ Toast.makeText(
+ context,
+ "Failed to delete book: " + it.message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ showDeleteBookDialog = false
+ },
+ ) {
+ Text(text = "Delete")
+ }
+ }, title = {
+ Text(text = "Delete Book")
+ }, text = {
+ Text(
+ text = "Deleting the book will also remove them from all synced devices.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }, dismissButton = {
+ TextButton(onClick = { showDeleteBookDialog = false }) {
+ Text(text = "Cancel")
+ }
+ })
+ }
Column(
modifier = modifier,
verticalArrangement = Arrangement.SpaceBetween,
@@ -128,7 +202,7 @@ fun BottomSheetContent(
BookAction.DeleteFromMyLibrary -> {
// TODO
- onDismiss()
+ showDeleteBookDialog = true
}
}
})
@@ -149,7 +223,11 @@ fun BookInfo(modifier: Modifier = Modifier, coverImage: ImageBitmap?, title: Str
Image(
bitmap = coverImage,
contentDescription = "Book Image",
- modifier = Modifier.width(90.dp),
+ modifier = Modifier
+ .width(90.dp)
+ .height(140.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ contentScale = ContentScale.Crop,
)
}
Spacer(modifier = Modifier.height(16.dp))
diff --git a/frontend/app/src/main/java/com/example/readability/ui/components/SettingTitle.kt b/frontend/app/src/main/java/com/example/readability/ui/components/SettingTitle.kt
index 8928f2f..70d8dcb 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/components/SettingTitle.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/components/SettingTitle.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.example.readability.ui.theme.Gabarito
+import com.example.readability.ui.theme.GabaritoMedium
import com.example.readability.ui.theme.ReadabilityTheme
@Composable
@@ -40,7 +40,7 @@ fun SettingTitle(modifier: Modifier = Modifier, text: String) {
.fillMaxWidth(),
text = text,
style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = Gabarito,
+ fontFamily = GabaritoMedium,
fontWeight = FontWeight.Medium,
),
)
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/Screen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/Screen.kt
new file mode 100644
index 0000000..993a0d2
--- /dev/null
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/Screen.kt
@@ -0,0 +1,133 @@
+package com.example.readability.ui.screens
+
+import android.os.Build
+import android.view.View
+import android.view.WindowInsets
+import android.view.WindowInsetsController
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.example.readability.ui.animation.composableFadeThrough
+import com.example.readability.ui.screens.auth.AuthScreen
+import com.example.readability.ui.screens.book.BookScreen
+import com.example.readability.ui.screens.settings.SettingsScreen
+import com.example.readability.ui.screens.settings.SettingsScreens
+import com.example.readability.ui.screens.viewer.ViewerScreen
+import com.example.readability.ui.screens.viewer.findActivity
+
+sealed class Screens(val route: String) {
+ object Auth : Screens("auth")
+ object Book : Screens("book")
+ object Settings : Screens("settings/{route}") {
+ fun createRoute(route: String) = "settings/$route"
+ }
+
+ object Viewer : Screens("viewer/{book_id}") {
+ fun createRoute(bookId: Int) = "viewer/$bookId"
+ }
+}
+
+@Composable
+fun Screen(navController: NavHostController = rememberNavController(), isSignedIn: Boolean) {
+ val context = LocalContext.current
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val immersiveModeEnabled = navBackStackEntry?.destination?.route?.startsWith("viewer") == true
+
+ LaunchedEffect(immersiveModeEnabled) {
+ val activity = context.findActivity() ?: return@LaunchedEffect
+ if (immersiveModeEnabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ activity.window.insetsController?.let {
+ it.hide(WindowInsets.Type.systemBars())
+ it.systemBarsBehavior =
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ activity.window.decorView.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ activity.window.insetsController?.show(WindowInsets.Type.systemBars())
+ } else {
+ @Suppress("DEPRECATION")
+ activity.window.decorView.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_VISIBLE
+ }
+ }
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = if (isSignedIn) Screens.Book.route else Screens.Auth.route,
+ ) {
+ composableFadeThrough(Screens.Auth.route) {
+ AuthScreen(onNavigateBookList = {
+ navController.navigate(Screens.Book.route) {
+ popUpTo(Screens.Auth.route) {
+ inclusive = true
+ }
+ }
+ })
+ }
+ composableFadeThrough(Screens.Book.route) {
+ BookScreen(onNavigateSettings = {
+ navController.navigate(
+ Screens.Settings.createRoute(
+ SettingsScreens.Settings.route,
+ ),
+ )
+ }, onNavigateViewer = {
+ navController.navigate(Screens.Viewer.createRoute(it))
+ })
+ }
+ composableFadeThrough(
+ Screens.Viewer.route,
+ listOf(
+ navArgument("book_id") {
+ type = NavType.IntType
+ },
+ ),
+ ) {
+ ViewerScreen(
+ id = it.arguments?.getInt("book_id") ?: -1,
+ onNavigateSettings = {
+ navController.navigate(
+ Screens.Settings.createRoute(
+ SettingsScreens.Viewer.route,
+ ),
+ )
+ },
+ onBack = {
+ navController.popBackStack()
+ },
+ )
+ }
+ composableFadeThrough(Screens.Settings.route) {
+ val route = it.arguments?.getString("route") ?: ""
+ SettingsScreen(
+ onBack = {
+ navController.popBackStack()
+ },
+ onNavigateAuth = {
+ navController.navigate(Screens.Auth.route) {
+ popUpTo(Screens.Auth.route) {
+ inclusive = true
+ }
+ }
+ },
+ startDestination = route,
+ )
+ }
+ }
+}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/Screens.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/Screens.kt
deleted file mode 100644
index c500189..0000000
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/Screens.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.example.readability.ui.screens
-
-sealed class Screens(val route: String) {
- object Auth : Screens("auth")
- object Book : Screens("book")
- object Settings : Screens("settings/{route}") {
- fun createRoute(route: String) = "settings/$route"
- }
- object Viewer : Screens("viewer/{book_id}") {
- fun createRoute(bookId: Int) = "viewer/$bookId"
- }
-}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/AuthScreen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/AuthScreen.kt
index a3a4805..266dab2 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/AuthScreen.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/AuthScreen.kt
@@ -1,8 +1,10 @@
package com.example.readability.ui.screens.auth
-import android.util.Base64
-import android.util.Base64.URL_SAFE
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -18,15 +20,11 @@ import java.lang.Thread.sleep
sealed class AuthScreens(val route: String) {
object Intro : AuthScreens("intro")
- object SignIn : AuthScreens("sign_in/{email}") {
- fun createRoute(email: String) = "sign_in/$email"
- }
+ object SignIn : AuthScreens("sign_in")
object SignUp : AuthScreens("sign_up")
- object VerifyEmail : AuthScreens("verify_email/{email}/{fromSignUp}") {
- fun createRoute(email: String, fromSignUp: Boolean) = "verify_email/${
- Base64.encodeToString(email.toByteArray(), URL_SAFE).trim()
- }/$fromSignUp"
+ object VerifyEmail : AuthScreens("verify_email/{fromSignUp}") {
+ fun createRoute(fromSignUp: Boolean) = "verify_email/$fromSignUp"
}
object ResetPassword : AuthScreens("reset_password")
@@ -36,6 +34,7 @@ sealed class AuthScreens(val route: String) {
@Composable
fun AuthScreen(navController: NavHostController = rememberNavController(), onNavigateBookList: () -> Unit = {}) {
+ var email by remember { mutableStateOf("") }
NavHost(navController = navController, startDestination = AuthScreens.Intro.route) {
composableSharedAxis(AuthScreens.Intro.route, axis = SharedAxis.X) {
IntroView(
@@ -44,14 +43,14 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav
}
composableSharedAxis(AuthScreens.Email.route, axis = SharedAxis.X) {
EmailView(
+ email = email,
+ onEmailChanged = { email = it },
onBack = { navController.popBackStack() },
- onNavigateSignIn = { navController.navigate(AuthScreens.SignIn.createRoute(it)) },
+ onNavigateSignIn = { navController.navigate(AuthScreens.SignIn.route) },
onNavigateSignUp = { navController.navigate(AuthScreens.SignUp.route) },
- onNavigateForgotPassword = { navController.navigate(AuthScreens.ForgotPassword.route) },
)
}
composableSharedAxis(AuthScreens.SignIn.route, axis = SharedAxis.X) {
- val email = it.arguments?.getString("email") ?: ""
val userViewModel: UserViewModel = hiltViewModel()
SignInView(
email = email,
@@ -62,7 +61,6 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav
}
},
onNavigateBookList = { onNavigateBookList() },
- onNavigateForgotPassword = { navController.navigate(AuthScreens.ForgotPassword.route) },
)
}
composableSharedAxis(AuthScreens.SignUp.route, axis = SharedAxis.X) {
@@ -75,18 +73,14 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav
}
},
onNavigateVerify = {
- // TODO: implement verification on backend
-// navController.navigate(
-// AuthScreens.VerifyEmail.createRoute(
-// it, true
-// )
-// )
onNavigateBookList()
},
)
}
composableSharedAxis(AuthScreens.ForgotPassword.route, axis = SharedAxis.X) {
ForgotPasswordView(
+ email = email,
+ onEmailChanged = { email = it },
onBack = { navController.popBackStack() },
onEmailSubmitted = {
withContext(Dispatchers.IO) {
@@ -97,7 +91,6 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav
onNavigateVerify = {
navController.navigate(
AuthScreens.VerifyEmail.createRoute(
- it,
false,
),
)
@@ -109,16 +102,10 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav
axis = SharedAxis.X,
arguments = listOf(
navArgument("fromSignUp") { defaultValue = false },
- navArgument("email") { defaultValue = "" },
),
) {
VerifyEmailView(
- email = String(
- Base64.decode(
- it.arguments?.getString("email") ?: "",
- URL_SAFE,
- ),
- ),
+ email = email,
fromSignUp = it.arguments?.getBoolean("fromSignUp") ?: false,
onBack = { navController.popBackStack() },
onNavigateBookList = { onNavigateBookList() },
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/EmailView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/EmailView.kt
index 24b7318..7ab5ca5 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/EmailView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/EmailView.kt
@@ -44,7 +44,7 @@ import com.example.readability.ui.theme.ReadabilityTheme
@Preview(device = "id:pixel_5")
fun EmailPreview() {
ReadabilityTheme {
- EmailView()
+ EmailView("test@example.com")
}
}
@@ -53,14 +53,14 @@ private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@(.+)\$")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmailView(
+ email: String,
+ onEmailChanged: (String) -> Unit = {},
onBack: () -> Unit = {},
onNavigateSignIn: (String) -> Unit = {},
onNavigateSignUp: () -> Unit = {},
- onNavigateForgotPassword: () -> Unit = {},
) {
var showError by remember { mutableStateOf(false) }
var emailError by remember { mutableStateOf(false) }
- var email by remember { mutableStateOf("") }
val emailFocusRequester = remember { FocusRequester() }
@@ -123,7 +123,7 @@ fun EmailView(
.testTag("EmailTextField"),
value = email,
onValueChange = {
- email = it
+ onEmailChanged(it)
emailError = checkEmailError()
},
singleLine = true,
@@ -147,13 +147,6 @@ fun EmailView(
)
}
- TextButton(
- modifier = Modifier.testTag("ForgotPasswordButton"),
- onClick = { onNavigateForgotPassword() },
- ) {
- Text("Forgot password?")
- }
-
TextButton(
modifier = Modifier.testTag("SignUpButton"),
onClick = { onNavigateSignUp() },
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/ForgotPasswordView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/ForgotPasswordView.kt
index b87ff02..5dc9631 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/ForgotPasswordView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/ForgotPasswordView.kt
@@ -50,7 +50,7 @@ import kotlinx.coroutines.withContext
@Preview(device = "id:pixel_5")
fun ForgotPasswordPreview() {
ReadabilityTheme {
- ForgotPasswordView()
+ ForgotPasswordView("test@example.com")
}
}
@@ -59,11 +59,12 @@ private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@(.+)\$")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForgotPasswordView(
+ email: String,
+ onEmailChanged: (String) -> Unit = {},
onBack: () -> Unit = {},
onNavigateVerify: (String) -> Unit = {},
onEmailSubmitted: suspend (String) -> Result = { Result.success(Unit) },
) {
- var email by remember { mutableStateOf("") }
var emailError by remember { mutableStateOf(false) }
var showError by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
@@ -141,7 +142,7 @@ fun ForgotPasswordView(
.testTag("EmailTextField"),
value = email,
onValueChange = {
- email = it
+ onEmailChanged(it)
emailError = checkEmailError()
},
singleLine = true,
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/IntroView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/IntroView.kt
index 7a340bc..3e3581a 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/IntroView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/IntroView.kt
@@ -37,13 +37,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextDecoration
-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
@@ -175,18 +172,7 @@ fun InformText(onPrivacyPolicyClicked: () -> Unit = {}, onTermsOfUseClicked: ()
)
val annotatedString = buildAnnotatedString {
- append("By Continuing I agree with\nthe ")
- pushStringAnnotation("privacy", "privacy")
- withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
- append("Privacy Policy")
- }
- pop()
- append(", ")
- pushStringAnnotation("terms", "terms")
- withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
- append("Term of Use")
- }
- pop()
+ append("By Continuing I agree with\nthe Privacy Policy, Term of Use")
}
ClickableText(text = annotatedString, onClick = {
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/SignInView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/SignInView.kt
index 2b600a7..c924204 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/auth/SignInView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/SignInView.kt
@@ -1,5 +1,6 @@
package com.example.readability.ui.screens.auth
+import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -20,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -33,13 +33,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.example.readability.LocalSnackbarHost
import com.example.readability.R
import com.example.readability.ui.animation.animateImeDp
import com.example.readability.ui.components.PasswordTextField
@@ -64,18 +64,17 @@ fun SignInView(
onBack: () -> Unit = {},
onPasswordSubmitted: suspend (String) -> Result = { Result.success(Unit) },
onNavigateBookList: () -> Unit = {},
- onNavigateForgotPassword: (String) -> Unit = {},
) {
var password by remember { mutableStateOf("") }
var passwordError by remember { mutableStateOf(null) }
var loading by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
val passwordFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
- val snackbarHost = LocalSnackbarHost.current
-
val scope = rememberCoroutineScope()
val submit = {
@@ -86,9 +85,12 @@ fun SignInView(
loading = true
scope.launch {
onPasswordSubmitted(password).onSuccess {
- // TODO: show welcome message
withContext(Dispatchers.Main) { onNavigateBookList() }
- snackbarHost.showSnackbar("Welcome back!")
+ Toast.makeText(
+ context,
+ "Welcome back!",
+ Toast.LENGTH_SHORT,
+ ).show()
}.onFailure {
loading = false
passwordError = it.message ?: ""
@@ -162,9 +164,6 @@ fun SignInView(
supportingText = passwordError,
)
Spacer(modifier = Modifier.weight(1f))
- TextButton(onClick = { onNavigateForgotPassword(email) }) {
- Text("Forgot password?")
- }
RoundedRectButton(
onClick = { submit() },
modifier = Modifier
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/book/AddBookView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/book/AddBookView.kt
index 521bdd0..3491dd7 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/book/AddBookView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/book/AddBookView.kt
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns
+import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@@ -48,7 +49,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.example.readability.LocalSnackbarHost
import com.example.readability.R
import com.example.readability.data.book.AddBookRequest
import com.example.readability.ui.components.RoundedRectButton
@@ -96,14 +96,34 @@ fun AddBookView(
var author by remember { mutableStateOf("") }
var imageUri by remember { mutableStateOf(null) }
var bitmap by remember { mutableStateOf(null) }
+ var defaultbitmap by remember { mutableStateOf(null) }
var imageString by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
var fileName by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
+ val maxChar = 80
- val snackbarHost = LocalSnackbarHost.current
+ var defaultImageString = ""
+ val defaultUri = Uri.parse(
+ "android.resource://" + context.packageName + "/drawable/" + R.drawable.default_book_cover_image,
+ )
+ defaultbitmap = if (Build.VERSION.SDK_INT < 28) {
+ MediaStore.Images.Media.getBitmap(context.contentResolver, defaultUri)
+ } else {
+ val source = ImageDecoder.createSource(context.contentResolver, defaultUri)
+ ImageDecoder.decodeBitmap(source)
+ }
+
+ if (defaultbitmap != null) {
+ // convert bitmap to hex string
+ ByteArrayOutputStream().use {
+ defaultbitmap!!.compress(Bitmap.CompressFormat.JPEG, 95, it)
+ val bytes = it.toByteArray()
+ defaultImageString = bytesToHex(bytes)
+ }
+ }
val imageSelectLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
@@ -139,6 +159,9 @@ fun AddBookView(
.joinToString(" ") {
it.replaceFirstChar { it.uppercase() }
}
+ if (title.length > maxChar) {
+ title = title.substring(0, maxChar)
+ }
val inputStream = contentResolver.openInputStream(uri)
if (inputStream != null) {
inputStream.use {
@@ -233,7 +256,9 @@ fun AddBookView(
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = title,
- onValueChange = { title = it },
+ onValueChange = {
+ if (it.length <= maxChar) title = it
+ },
label = { Text(text = "Book Title") },
leadingIcon = {
Icon(
@@ -241,12 +266,13 @@ fun AddBookView(
contentDescription = "Book Icon",
)
},
+ singleLine = true,
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = author,
- onValueChange = { author = it },
+ onValueChange = { if (it.length <= maxChar) author = it },
label = { Text(text = "Author (Optional)") },
leadingIcon = {
Icon(
@@ -254,6 +280,7 @@ fun AddBookView(
contentDescription = "Book Icon",
)
},
+ singleLine = true,
)
Spacer(modifier = Modifier.height(32.dp))
Box(
@@ -322,25 +349,52 @@ fun AddBookView(
modifier = Modifier.fillMaxWidth(),
loading = loading,
onClick = {
- loading = true
- scope.launch {
- onAddBookClicked(
- AddBookRequest(
- title = title,
- content = content,
- author = author,
- coverImage = imageString,
- ),
- ).onSuccess {
- onBookUploaded()
- snackbarHost.showSnackbar(
- "Book is successfully uploaded",
- )
- }.onFailure {
- loading = false
- snackbarHost.showSnackbar(
- it.message ?: "Unknown error happened while uploading book",
- )
+ if (content.isEmpty()) {
+ Toast.makeText(
+ context,
+ "File is empty.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else if (fileName.substring(fileName.length - 4, fileName.length) != ".txt") {
+ Toast.makeText(
+ context,
+ "Invalid file format.\nOnly txt file supported",
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ loading = true
+ scope.launch {
+ onAddBookClicked(
+ AddBookRequest(
+ title = title,
+ content = content,
+ author = author,
+ coverImage = if (imageString == "") {
+ defaultImageString
+ } else {
+ imageString
+ },
+ ),
+ ).onSuccess {
+ onBookUploaded()
+ Toast.makeText(
+ context,
+ "Book is successfully uploaded",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }.onFailure {
+ loading = false
+ val message = if (it.message?.isEmpty() == false) {
+ it.message!!
+ } else {
+ "Unknown error happened while uploading book"
+ }
+ Toast.makeText(
+ context,
+ message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
}
}
},
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookListView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookListView.kt
index 89937ee..a5d51dc 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookListView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookListView.kt
@@ -20,8 +20,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.pullrefresh.PullRefreshIndicator
+import androidx.compose.material.pullrefresh.pullRefresh
+import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -40,12 +47,14 @@ import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -54,22 +63,40 @@ import com.example.readability.R
import com.example.readability.data.book.BookCardData
import com.example.readability.ui.theme.Gabarito
import com.example.readability.ui.theme.ReadabilityTheme
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun BookListView(
bookCardDataList: List,
onLoadImage: suspend (id: Int) -> Result = { Result.success(Unit) },
onLoadContent: suspend (id: Int) -> Result = { Result.success(Unit) },
onProgressChanged: (Int, Double) -> Unit = { _, _ -> },
+ onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) },
onNavigateSettings: () -> Unit = {},
onNavigateAddBook: () -> Unit = {},
onNavigateViewer: (id: Int) -> Unit = {},
+ onRefresh: suspend () -> Result = { Result.success(Unit) },
) {
val contentLoadScope = rememberCoroutineScope()
val context = LocalContext.current
+ var refreshing by remember { mutableStateOf(false) }
+ val refreshScope = rememberCoroutineScope()
+
+ fun refresh() = refreshScope.launch {
+ refreshing = true
+ onRefresh().onFailure {
+ Toast.makeText(context, "Failed to refresh: ${it.message}", Toast.LENGTH_SHORT).show()
+ }
+ delay(1000) // TODO
+ refreshing = false
+ }
+
+ val state = rememberPullRefreshState(refreshing, ::refresh)
+
// TODO: Empty Library message is shown during the database loading -> do not show it while loading the database
Scaffold(topBar = {
CenterAlignedTopAppBar(title = { Text("My Library") }, actions = {
@@ -90,76 +117,101 @@ fun BookListView(
Icon(Icons.Filled.Add, "Floating action button.")
}
}) { innerPadding ->
- AnimatedContent(
+ Box(
modifier = Modifier
.padding(innerPadding)
- .fillMaxSize(),
- targetState = bookCardDataList.isEmpty(),
- label = "BookScreen.BookListView.Content",
+ .fillMaxSize()
+ .pullRefresh(state),
) {
- when (it) {
- true -> Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Icon(
- modifier = Modifier.size(96.dp),
- painter = painterResource(id = R.drawable.file_dashed_thin),
- contentDescription = "No File",
- tint = MaterialTheme.colorScheme.secondary,
- )
- Text(
- text = "Library is Empty",
- color = MaterialTheme.colorScheme.secondary,
- style = MaterialTheme.typography.titleLarge,
- textAlign = TextAlign.Center,
- )
- Text(
- text = "Press the button below\nto add books to your library.",
- color = MaterialTheme.colorScheme.secondary,
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
- )
- }
+ AnimatedContent(
+ modifier = Modifier
+ .fillMaxSize(),
+ targetState = bookCardDataList.isEmpty(),
+ label = "BookScreen.BookListView.Content",
+ ) {
+ when (it) {
+ true -> Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ modifier = Modifier.size(96.dp),
+ painter = painterResource(id = R.drawable.file_dashed_thin),
+ contentDescription = "No File",
+ tint = MaterialTheme.colorScheme.secondary,
+ )
+ Text(
+ text = "Library is Empty",
+ color = MaterialTheme.colorScheme.secondary,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = "Press the button below\nto add books to your library.",
+ color = MaterialTheme.colorScheme.secondary,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
- false -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Top,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- items(bookCardDataList.size) { index ->
- BookCard(
- modifier = Modifier.fillMaxWidth(),
- bookCardData = bookCardDataList[index],
- onClick = {
- // TODO: show download status
- contentLoadScope.launch {
- onLoadContent(bookCardDataList[index].id).onSuccess {
- onNavigateViewer(bookCardDataList[index].id)
- }.onFailure {
- it.printStackTrace()
- Toast.makeText(
- context,
- "Failed to load content. ${it.message}",
- Toast.LENGTH_SHORT,
- ).show()
+ false -> LazyColumn(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ items(bookCardDataList.size) { index ->
+ if (index < bookCardDataList.size) {
+ BookCard(
+ modifier = Modifier.fillMaxWidth(),
+ bookCardData = bookCardDataList[index],
+ onClick = {
+ // TODO: show download status
+ contentLoadScope.launch {
+ onLoadContent(bookCardDataList[index].id).onSuccess {
+ onNavigateViewer(bookCardDataList[index].id)
+ }.onFailure {
+ it.printStackTrace()
+ Toast.makeText(
+ context,
+ "Failed to load content. ${it.message}",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ },
+ onLoadImage = {
+ onLoadImage(bookCardDataList[index].id)
+ },
+ onProgressChanged = { id, progress ->
+ onProgressChanged(id, progress)
+ },
+ onBookDeleted = { id ->
+ onBookDeleted(id)
+ },
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ if (index == bookCardDataList.size - 1) {
+ LazyColumn(
+ modifier = Modifier.height(80.dp),
+ ) {
}
}
- },
- onLoadImage = {
- onLoadImage(bookCardDataList[index].id)
- },
- onProgressChanged = { id, progress ->
- onProgressChanged(id, progress)
- },
- )
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 16.dp),
- )
+ }
+ }
}
}
}
+ PullRefreshIndicator(
+ modifier = Modifier.align(Alignment.TopCenter),
+ refreshing = refreshing,
+ state = state,
+ )
}
}
}
@@ -178,6 +230,7 @@ fun BookCardPreview() {
author = "F. Scott Fitzgerald",
progress = 0.5,
content = "aasdasd",
+ summaryProgress = 0.5,
),
)
}
@@ -191,15 +244,21 @@ fun BookCard(
onClick: () -> Unit = {},
onLoadImage: suspend () -> Unit = {},
onProgressChanged: (Int, Double) -> Unit = { _, _ -> },
+ onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) },
) {
var showSheet by remember { mutableStateOf(false) }
var loadingImage by remember { mutableStateOf(false) }
val imageLoadScope = rememberCoroutineScope()
if (showSheet) {
- BottomSheet(bookCardData = bookCardData, onDismiss = {
- showSheet = false
- }, onProgressChanged = onProgressChanged)
+ BottomSheet(
+ bookCardData = bookCardData,
+ onDismiss = {
+ showSheet = false
+ },
+ onProgressChanged = onProgressChanged,
+ onBookDeleted = onBookDeleted,
+ )
}
LaunchedEffect(bookCardData.coverImage) {
@@ -232,12 +291,13 @@ fun BookCard(
Image(
modifier = Modifier
.padding(16.dp, 16.dp, 0.dp, 16.dp)
- .fillMaxHeight()
+ .height(100.dp)
.width(64.dp)
+ .clip(RoundedCornerShape(4.dp))
.testTag(bookCardData.coverImage ?: ""),
bitmap = bookCardData.coverImageData,
contentDescription = "Book Cover Image",
- contentScale = ContentScale.FillWidth,
+ contentScale = ContentScale.Crop,
)
}
Column(
@@ -256,16 +316,19 @@ fun BookCard(
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = Gabarito,
fontWeight = FontWeight.Medium,
+ lineBreak = LineBreak.Paragraph,
),
)
}
Text(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth()
+ .padding(0.dp, 0.dp, 16.dp, 0.dp),
text = bookCardData.author,
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.secondary,
fontFamily = Gabarito,
fontWeight = FontWeight.Medium,
+ lineBreak = LineBreak.Paragraph,
),
)
Spacer(modifier = Modifier.weight(1f))
@@ -289,7 +352,7 @@ fun BookCard(
tint = MaterialTheme.colorScheme.onBackground,
)
Text(
- text = "${(bookCardData.progress * 100).toInt()}%",
+ text = "${(bookCardData.progress * 100).roundToInt()}%",
style = MaterialTheme.typography.bodyMedium,
)
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookScreen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookScreen.kt
index 4cd637d..c7de9ac 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookScreen.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/book/BookScreen.kt
@@ -26,6 +26,7 @@ fun BookScreen(
addBookViewModel: AddBookViewModel = hiltViewModel(),
) {
val navController = rememberNavController()
+
NavHost(navController = navController, startDestination = BookScreens.BookList.route) {
composableSharedAxis(BookScreens.BookList.route, axis = SharedAxis.X) {
val bookCardDataList by bookListViewModel.bookCardDataList.collectAsState()
@@ -47,6 +48,13 @@ fun BookScreen(
onProgressChanged = { id, progress ->
bookListViewModel.updateProgress(id, progress)
},
+ onBookDeleted = { id ->
+ bookListViewModel.deleteBook(id)
+ Result.success(Unit)
+ },
+ onRefresh = {
+ bookListViewModel.updateBookList()
+ },
)
}
composableSharedAxis(BookScreens.AddBook.route, axis = SharedAxis.X) {
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/AccountView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/AccountView.kt
index c41492f..04aa1d0 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/AccountView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/AccountView.kt
@@ -1,8 +1,7 @@
package com.example.readability.ui.screens.settings
-import android.util.Patterns
+import android.widget.Toast
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -10,12 +9,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -35,7 +30,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -43,17 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
-import com.example.readability.LocalSnackbarHost
import com.example.readability.R
-import com.example.readability.ui.components.RoundedRectButton
import com.example.readability.ui.components.SettingTitle
import com.example.readability.ui.theme.ReadabilityTheme
import kotlinx.coroutines.launch
@@ -62,56 +51,29 @@ import kotlinx.coroutines.launch
@Preview
fun AccountPreview() {
ReadabilityTheme {
- AccountView()
+ AccountView(
+ email = "test@example.com",
+ username = "John Doe",
+ )
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountView(
+ email: String,
+ username: String,
onBack: () -> Unit = {},
onNavigateChangePassword: () -> Unit = {},
- onUpdatePhoto: suspend () -> Result = { Result.success(Unit) },
- onUpdatePersonalInfo: suspend () -> Result = { Result.success(Unit) },
+ onSignOut: suspend () -> Result = { Result.success(Unit) },
onDeleteAccount: suspend () -> Result = { Result.success(Unit) },
onNavigateIntro: () -> Unit = {},
) {
- var email by remember { mutableStateOf("") }
- var emailError by remember { mutableStateOf(false) }
- var username by remember { mutableStateOf("") }
- var usernameError by remember { mutableStateOf(false) }
- var showError by remember { mutableStateOf(false) }
var showDeleteAccountDialog by remember { mutableStateOf(false) }
- var updatePhotoLoading by remember { mutableStateOf(false) }
var updatePersonalInfoLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
- val snackbarHost = LocalSnackbarHost.current
-
- val checkEmailError = {
- email.isEmpty() || !Patterns.EMAIL_ADDRESS.matcher(email).matches()
- }
- val checkUsernameError = {
- username.isEmpty()
- }
- val checkError = { checkEmailError() || checkUsernameError() }
-
- val submit = {
- if (checkError()) {
- showError = true
- } else {
- showError = false
- updatePersonalInfoLoading = true
- scope.launch {
- onUpdatePersonalInfo().onSuccess {
- snackbarHost.showSnackbar("Personal info updated")
- }.onFailure {
- snackbarHost.showSnackbar("Failed to update personal info: " + it.message)
- }
- updatePersonalInfoLoading = false
- }
- }
- }
+ val context = LocalContext.current
if (showDeleteAccountDialog) {
AlertDialog(icon = {
@@ -130,10 +92,14 @@ fun AccountView(
onClick = {
scope.launch {
onDeleteAccount().onSuccess {
- snackbarHost.showSnackbar("Account deleted")
+ Toast.makeText(context, "Account deleted", Toast.LENGTH_SHORT).show()
onNavigateIntro()
}.onFailure {
- snackbarHost.showSnackbar("Failed to delete account: " + it.message)
+ Toast.makeText(
+ context,
+ "Failed to delete account: " + it.message,
+ Toast.LENGTH_SHORT,
+ ).show()
}
}
},
@@ -169,48 +135,13 @@ fun AccountView(
})
},
) { innerPadding ->
- LaunchedEffect(Unit) {
- emailError = checkEmailError()
- usernameError = checkUsernameError()
- }
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 16.dp),
- verticalArrangement = Arrangement.spacedBy(32.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- AsyncImage(
- modifier = Modifier
- .size(96.dp)
- .clip(RoundedCornerShape(48.dp)),
- model = "https://picsum.photos/200/200",
- contentDescription = "Profile Image",
- )
- RoundedRectButton(
- onClick = {
- updatePhotoLoading = true
- scope.launch {
- onUpdatePhoto().onSuccess {
- snackbarHost.showSnackbar("Photo updated")
- }.onFailure {
- snackbarHost.showSnackbar("Failed to update photo: " + it.message)
- }
- updatePhotoLoading = false
- }
- },
- loading = updatePhotoLoading,
- ) {
- Text(text = "Update Photo")
- }
- }
- SettingTitle(modifier = Modifier.padding(top = 24.dp), text = "Personal Info")
+ SettingTitle(text = "Personal Info")
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
modifier = Modifier
@@ -218,10 +149,7 @@ fun AccountView(
.padding(horizontal = 16.dp)
.testTag("EmailTextField"),
value = email,
- onValueChange = {
- email = it
- emailError = checkEmailError()
- },
+ onValueChange = { },
singleLine = true,
label = {
Text(text = "Email")
@@ -232,16 +160,7 @@ fun AccountView(
contentDescription = "email",
)
},
- isError = showError && emailError,
- supportingText = if (showError && emailError) {
- { Text(text = "Please enter a valid email address") }
- } else {
- null
- },
- keyboardOptions = KeyboardOptions(
- imeAction = ImeAction.Next,
- keyboardType = KeyboardType.Email,
- ),
+ enabled = false,
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
@@ -250,10 +169,7 @@ fun AccountView(
.padding(horizontal = 16.dp)
.testTag("UsernameTextField"),
value = username,
- onValueChange = {
- username = it
- usernameError = checkUsernameError()
- },
+ onValueChange = {},
singleLine = true,
label = {
Text(text = "Username")
@@ -264,26 +180,40 @@ fun AccountView(
contentDescription = "user",
)
},
- isError = showError && usernameError,
- supportingText = if (showError && usernameError) {
- { Text(text = "Please enter a valid username") }
- } else {
- null
- },
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Text,
- ),
- keyboardActions = KeyboardActions(onDone = { submit() }),
+ enabled = false,
)
Spacer(modifier = Modifier.height(16.dp))
- RoundedRectButton(
- modifier = Modifier.padding(16.dp),
- loading = updatePersonalInfoLoading,
- onClick = { submit() },
- ) {
- Text(text = "Update Personal Info")
- }
SettingTitle(modifier = Modifier.padding(top = 24.dp), text = "Actions")
+ // sign out
+ ListItem(
+ modifier = Modifier.clickable {
+ scope.launch {
+ onSignOut().onSuccess {
+ Toast.makeText(context, "Signed out", Toast.LENGTH_SHORT).show()
+ onNavigateIntro()
+ }.onFailure {
+ Toast.makeText(
+ context,
+ "Failed to sign out: " + it.message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ },
+ leadingContent = {
+ Icon(
+ painter = painterResource(id = R.drawable.sign_out),
+ contentDescription = "Sign Out",
+ )
+ },
+ headlineContent = { Text(text = "Sign Out") },
+ trailingContent = {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = "Navigate",
+ )
+ },
+ )
ListItem(
modifier = Modifier.clickable {
onNavigateChangePassword()
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ChangePasswordView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ChangePasswordView.kt
index 4bed078..54bc5a0 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ChangePasswordView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ChangePasswordView.kt
@@ -33,17 +33,27 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.readability.LocalSnackbarHost
import com.example.readability.ui.animation.animateImeDp
import com.example.readability.ui.components.PasswordTextField
import com.example.readability.ui.components.RoundedRectButton
+import com.example.readability.ui.theme.ReadabilityTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val passwordRegex = Regex("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}\$")
+@Composable
+@Preview(showBackground = true, device = "id:pixel_5")
+fun ChangePasswordViewPreview() {
+ ReadabilityTheme {
+ ChangePasswordView()
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangePasswordView(
@@ -78,7 +88,10 @@ fun ChangePasswordView(
scope.launch {
onPasswordSubmitted(newPassword).onSuccess {
snackbarHost.showSnackbar("Password is successfully changed.")
- withContext(Dispatchers.Main) { onBack() }
+ withContext(Dispatchers.Main) {
+ focusManager.clearFocus()
+ onBack()
+ }
}.onFailure {
loading = false
showError = true
@@ -94,7 +107,10 @@ fun ChangePasswordView(
.navigationBarsPadding(),
topBar = {
TopAppBar(title = { Text("Change Password") }, navigationIcon = {
- IconButton(onClick = { onBack() }) {
+ IconButton(onClick = {
+ focusManager.clearFocus()
+ onBack()
+ }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Arrow Back",
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/PasswordCheckView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/PasswordCheckView.kt
deleted file mode 100644
index 4c5dc10..0000000
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/PasswordCheckView.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-package com.example.readability.ui.screens.settings
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.example.readability.R
-import com.example.readability.ui.animation.animateImeDp
-import com.example.readability.ui.components.PasswordTextField
-import com.example.readability.ui.components.RoundedRectButton
-import com.example.readability.ui.theme.ReadabilityTheme
-import kotlinx.coroutines.launch
-
-@Composable
-@Preview(showBackground = true, device = "id:pixel_5")
-fun PasswordCheckViewPreview() {
- ReadabilityTheme {
- PasswordCheckView()
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun PasswordCheckView(
- onBack: () -> Unit = {},
- onPasswordSubmitted: suspend () -> Result = { Result.success(Unit) },
- onNavigateAccount: () -> Unit = {},
-) {
- var password by remember { mutableStateOf("") }
- var passwordError by remember { mutableStateOf("") }
- var loading by remember { mutableStateOf(false) }
- val passwordFocusRequester = remember { FocusRequester() }
- val focusManager = LocalFocusManager.current
- val scope = rememberCoroutineScope()
-
- val submit = {
- focusManager.clearFocus()
- scope.launch {
- loading = true
- onPasswordSubmitted().onSuccess {
- onNavigateAccount()
- }.onFailure {
- loading = false
- passwordError = it.message ?: "Unknown error occurred. Please try again."
- }
- }
- }
-
- Scaffold(
- modifier = Modifier
- .imePadding()
- .navigationBarsPadding()
- .statusBarsPadding(),
- topBar = {
- TopAppBar(title = { Text(text = "Account") }, navigationIcon = {
- IconButton(onClick = { onBack() }) {
- Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
- }
- })
- },
- ) { innerPadding ->
- LaunchedEffect(Unit) {
- passwordFocusRequester.requestFocus()
- }
- Column(modifier = Modifier.padding(innerPadding)) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
- .verticalScroll(rememberScrollState()),
- ) {
- Spacer(modifier = Modifier.height(64.dp))
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Icon(
- modifier = Modifier.size(96.dp),
- painter = painterResource(id = R.drawable.lock_simple_thin),
- contentDescription = "Lock",
- tint = MaterialTheme.colorScheme.secondary,
- )
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = "Verification Needed",
- style = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.secondary),
- textAlign = TextAlign.Center,
- )
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = "Because you're accessing sensitive info,\nyou need to verify your password.",
- style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.secondary),
- textAlign = TextAlign.Center,
- )
- }
- PasswordTextField(
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(passwordFocusRequester)
- .padding(top = 32.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
- password = password,
- label = "Password",
- isError = passwordError.isNotEmpty(),
- onPasswordChanged = {
- password = it
- passwordError = ""
- },
- supportingText = passwordError,
- keyboardActions = KeyboardActions(onDone = { submit() }),
- )
- }
- RoundedRectButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = { submit() },
- imeAnimation = animateImeDp(label = "SettingsScreen.PasswordCheckView.NextButton"),
- loading = loading,
- ) {
- Text(text = "Next")
- }
- }
- }
-}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsScreen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsScreen.kt
index 0aa463d..203b292 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsScreen.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsScreen.kt
@@ -17,7 +17,6 @@ import kotlinx.coroutines.withContext
sealed class SettingsScreens(val route: String) {
object Settings : SettingsScreens("settings")
- object PasswordCheck : SettingsScreens("password_check")
object Account : SettingsScreens("account")
object ChangePassword : SettingsScreens("change_password")
object Viewer : SettingsScreens("viewer")
@@ -36,52 +35,28 @@ fun SettingsScreen(
NavHost(navController = navController, startDestination = startDestination) {
composableSharedAxis(SettingsScreens.Settings.route, axis = SharedAxis.X) {
val userViewModel: UserViewModel = hiltViewModel()
- // TODO: put correct user info to SettingsView
+ val user by userViewModel.user.collectAsState()
+
SettingsView(
- onSignOut = {
- withContext(Dispatchers.IO) {
- userViewModel.signOut()
- }
- Result.success(Unit)
- },
+ username = user?.userName ?: "",
onBack = { onBack() },
- onNavigatePasswordCheck = { navController.navigate(SettingsScreens.PasswordCheck.route) },
+ onNavigateAccountSetting = { navController.navigate(SettingsScreens.Account.route) },
onNavigateViewer = { navController.navigate(SettingsScreens.Viewer.route) },
- onNavigateAbout = { navController.navigate(SettingsScreens.About.createRoute(it)) },
- onNavigateIntro = { onNavigateAuth() },
)
}
- composableSharedAxis(SettingsScreens.PasswordCheck.route, axis = SharedAxis.X) {
- val userViewModel: UserViewModel = hiltViewModel()
- PasswordCheckView(onBack = { navController.popBackStack() }, onPasswordSubmitted = {
- withContext(Dispatchers.IO) {
- // TODO: check password again using userViewModel
- delay(1000L)
- Result.success(Unit)
- }
- }, onNavigateAccount = {
- navController.navigate(SettingsScreens.Account.route)
- })
- }
composableSharedAxis(SettingsScreens.Account.route, axis = SharedAxis.X) {
val userViewModel: UserViewModel = hiltViewModel()
- // TODO: put correct user info to AccountView
+ val user by userViewModel.user.collectAsState()
AccountView(
+ email = user?.userEmail ?: "",
+ username = user?.userName ?: "",
onBack = { navController.popBackStack() },
onNavigateChangePassword = { navController.navigate(SettingsScreens.ChangePassword.route) },
- onUpdatePhoto = {
- withContext(Dispatchers.IO) {
- // TODO: update photo using userViewModel
- delay(1000L)
- Result.success(Unit)
- }
- },
- onUpdatePersonalInfo = {
+ onSignOut = {
withContext(Dispatchers.IO) {
- // TODO: update personal info using userViewModel
- delay(1000L)
- Result.success(Unit)
+ userViewModel.signOut()
}
+ Result.success(Unit)
},
onDeleteAccount = {
withContext(Dispatchers.IO) {
@@ -96,13 +71,12 @@ fun SettingsScreen(
)
}
composableSharedAxis(SettingsScreens.ChangePassword.route, axis = SharedAxis.X) {
+ val userViewModel: UserViewModel = hiltViewModel()
ChangePasswordView(
onBack = { navController.popBackStack() },
onPasswordSubmitted = { newPassword ->
withContext(Dispatchers.IO) {
- // TODO: change password using userViewModel
- delay(1000L)
- Result.success(Unit)
+ userViewModel.changePassword(newPassword)
}
},
)
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsView.kt
index 58efdfc..6e941b3 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsView.kt
@@ -1,10 +1,10 @@
package com.example.readability.ui.screens.settings
-import android.widget.Toast
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -18,42 +18,32 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import com.example.readability.R
import com.example.readability.ui.components.SettingTitle
import com.example.readability.ui.theme.ReadabilityTheme
-import kotlinx.coroutines.launch
@Composable
@Preview(showBackground = true, device = "id:pixel_5")
fun SettingsViewPreview() {
ReadabilityTheme {
- SettingsView()
+ SettingsView("John Doe")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsView(
- onSignOut: suspend () -> Result = { Result.success(Unit) },
+ username: String,
onBack: () -> Unit = {},
- onNavigatePasswordCheck: () -> Unit = {},
+ onNavigateAccountSetting: () -> Unit = {},
onNavigateViewer: () -> Unit = {},
- onNavigateAbout: (type: String) -> Unit = {},
- onNavigateIntro: () -> Unit = {},
) {
- val context = LocalContext.current
- val logoutScope = rememberCoroutineScope()
-
Scaffold(topBar = {
TopAppBar(title = { Text(text = "Settings") }, navigationIcon = {
IconButton(onClick = { onBack() }) {
@@ -66,20 +56,29 @@ fun SettingsView(
) {
SettingTitle(text = "General")
ListItem(
- modifier = Modifier.clickable {
- onNavigatePasswordCheck()
+ modifier = Modifier.clickable(
+ onClickLabel = "Account Settings",
+ ) {
+ onNavigateAccountSetting()
},
leadingContent = {
- AsyncImage(
+ Box(
modifier = Modifier
.size(40.dp)
- .clip(RoundedCornerShape(20.dp)),
- model = "https://picsum.photos/200/200",
- contentDescription = "Profile Picture",
- )
+ .background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(20.dp)),
+ ) {
+ Icon(
+ modifier = Modifier
+ .padding(5.dp, 10.dp, 5.dp, 4.dp)
+ .fillMaxSize(),
+ painter = painterResource(R.drawable.avatar),
+ contentDescription = "Avatar",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
},
headlineContent = {
- Text(text = "John Doe", style = MaterialTheme.typography.bodyLarge)
+ Text(text = username, style = MaterialTheme.typography.bodyLarge)
},
supportingContent = {
Text(
@@ -97,7 +96,9 @@ fun SettingsView(
},
)
ListItem(
- modifier = Modifier.clickable {
+ modifier = Modifier.clickable(
+ onClickLabel = "Viewer Settings",
+ ) {
onNavigateViewer()
},
headlineContent = {
@@ -110,76 +111,6 @@ fun SettingsView(
)
},
)
- SettingTitle(modifier = Modifier.padding(top = 24.dp), text = "About")
- ListItem(
- modifier = Modifier.clickable {
- onNavigateAbout("privacy_policy")
- },
- headlineContent = {
- Text(text = "Privacy Policy", style = MaterialTheme.typography.bodyLarge)
- },
- trailingContent = {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = "Navigate",
- )
- },
- )
- ListItem(
- modifier = Modifier.clickable {
- onNavigateAbout("terms_of_use")
- },
- headlineContent = {
- Text(text = "Terms of Use", style = MaterialTheme.typography.bodyLarge)
- },
- trailingContent = {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = "Navigate",
- )
- },
- )
- ListItem(
- modifier = Modifier.clickable {
- onNavigateAbout("open_source_licenses")
- },
- headlineContent = {
- Text(text = "Open Source Licenses", style = MaterialTheme.typography.bodyLarge)
- },
- trailingContent = {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = "Navigate",
- )
- },
- )
- Box(
- modifier = Modifier
- .padding(16.dp, 40.dp, 16.dp, 16.dp)
- .fillMaxWidth(),
- contentAlignment = Alignment.BottomCenter,
- ) {
- TextButton(onClick = {
- logoutScope.launch {
- onSignOut().onSuccess {
- Toast.makeText(
- context,
- "Logout Success",
- Toast.LENGTH_SHORT,
- ).show()
- onNavigateIntro()
- }.onFailure {
- Toast.makeText(
- context,
- "Logout Failed",
- Toast.LENGTH_SHORT,
- ).show()
- }
- }
- }) {
- Text(text = "Logout")
- }
- }
}
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ViewerView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ViewerView.kt
index afe6959..9ce78df 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ViewerView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/ViewerView.kt
@@ -16,7 +16,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.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -45,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.NativeCanvas
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
@@ -61,8 +60,6 @@ import com.example.readability.data.viewer.FontDataSource
import com.example.readability.data.viewer.ViewerStyle
import com.example.readability.ui.theme.Gabarito
import com.example.readability.ui.theme.ReadabilityTheme
-import com.example.readability.ui.theme.md_theme_dark_outline
-import com.example.readability.ui.theme.md_theme_light_outline
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@@ -109,13 +106,17 @@ fun ViewerView(
var maxHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
- Scaffold(topBar = {
- TopAppBar(title = { Text(text = "Viewer Settings") }, navigationIcon = {
- IconButton(onClick = { onBack() }) {
- Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
- }
- })
- }) { innerPadding ->
+ Scaffold(
+ modifier = Modifier
+ .safeDrawingPadding(),
+ topBar = {
+ TopAppBar(title = { Text(text = "Viewer Settings") }, navigationIcon = {
+ IconButton(onClick = { onBack() }) {
+ Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
+ }
+ })
+ },
+ ) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
@@ -364,58 +365,6 @@ fun ViewerOptions(
)
},
)
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(text = "Background Color", style = MaterialTheme.typography.bodyLarge)
- Spacer(modifier = Modifier.weight(1f))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Icon(painter = painterResource(id = R.drawable.sun), contentDescription = "Sun")
- Box(
- modifier = Modifier
- .size(40.dp)
- .border(1.dp, md_theme_light_outline, RoundedCornerShape(4.dp))
- .background(Color(viewerStyle.brightBackgroundColor), RoundedCornerShape(4.dp)),
- )
- Icon(painter = painterResource(id = R.drawable.moon), contentDescription = "Moon")
- Box(
- modifier = Modifier
- .size(40.dp)
- .border(1.dp, md_theme_dark_outline, RoundedCornerShape(4.dp))
- .background(Color(viewerStyle.darkBackgroundColor), RoundedCornerShape(4.dp)),
- )
- }
- }
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(text = "Text Color", style = MaterialTheme.typography.bodyLarge)
- Spacer(modifier = Modifier.weight(1f))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Icon(painter = painterResource(id = R.drawable.sun), contentDescription = "Sun")
- Box(
- modifier = Modifier
- .size(40.dp)
- .border(1.dp, md_theme_light_outline, RoundedCornerShape(4.dp))
- .background(Color(viewerStyle.brightTextColor), RoundedCornerShape(4.dp)),
- )
- Icon(painter = painterResource(id = R.drawable.moon), contentDescription = "Moon")
- Box(
- modifier = Modifier
- .size(40.dp)
- .border(1.dp, md_theme_dark_outline, RoundedCornerShape(4.dp))
- .background(Color(viewerStyle.darkTextColor), RoundedCornerShape(4.dp)),
- )
- }
- }
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizReportView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizReportView.kt
index 3c1e958..5de5dac 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizReportView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizReportView.kt
@@ -1,5 +1,6 @@
package com.example.readability.ui.screens.viewer
+import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,10 +31,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.example.readability.LocalSnackbarHost
import com.example.readability.ui.components.CircularProgressIndicatorInButton
import com.example.readability.ui.components.RoundedRectButton
import com.example.readability.ui.theme.ReadabilityTheme
@@ -67,7 +68,7 @@ fun QuizReportView(
var loading by remember { mutableStateOf(false) }
var reasonIdx by remember { mutableIntStateOf(0) }
- val snackbarHost = LocalSnackbarHost.current
+ val context = LocalContext.current
ReadabilityTheme {
Scaffold(topBar = {
@@ -96,16 +97,19 @@ fun QuizReportView(
loading = true
scope.launch {
onReport(REPORT_REASONS[reasonIdx]).onSuccess {
- snackbarHost.showSnackbar(
+ Toast.makeText(
+ context,
"Thank you for the feedback! " +
"We’ll continue to improve our service based on your opinion.",
- )
+ Toast.LENGTH_SHORT,
+ ).show()
onBack()
}.onFailure {
- snackbarHost.showSnackbar(
- it.message
- ?: "Unknown error occurred while sending feedback. Please try again.",
- )
+ Toast.makeText(
+ context,
+ "Unknown error occurred while sending feedback. Please try again.",
+ Toast.LENGTH_SHORT,
+ ).show()
loading = false
}
}
@@ -175,12 +179,12 @@ fun ReportSelection(modifier: Modifier = Modifier, value: Int, onChange: (Int) -
.fillMaxWidth()
.selectable(selected = (index == value), onClick = {
onChange(index)
- }),
+ })
+ .padding(horizontal = 16.dp),
) {
Text(
text = text,
modifier = Modifier
- .padding(start = 16.dp)
.align(Alignment.CenterVertically)
.weight(1f),
)
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizView.kt
index f1d3b47..bd38ad2 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/QuizView.kt
@@ -1,5 +1,6 @@
package com.example.readability.ui.screens.viewer
+import android.widget.Toast
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
@@ -35,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -48,6 +50,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -65,7 +68,9 @@ import com.example.readability.ui.components.RoundedRectButton
import com.example.readability.ui.components.RoundedRectFilledTonalButton
import com.example.readability.ui.theme.Gabarito
import com.example.readability.ui.theme.ReadabilityTheme
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@Composable
@@ -95,9 +100,27 @@ fun QuizView(
quizLoadState: QuizLoadState,
onBack: () -> Unit = {},
onNavigateReport: (Int) -> Unit = {},
+ onLoadQuiz: suspend () -> Result = { Result.success(Unit) },
) {
val pagerScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { quizList.size }
+ val context = LocalContext.current
+
+ LaunchedEffect(
+ Unit,
+ ) {
+ withContext(Dispatchers.IO) {
+ onLoadQuiz()
+ }.onFailure {
+ Toast.makeText(
+ context,
+ "Failed to generate quiz\n:${it.message}",
+ Toast.LENGTH_SHORT,
+ ).show()
+ onBack()
+ }
+ }
+
Scaffold(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
@@ -126,7 +149,7 @@ fun QuizView(
) {
if (it < quizList.size) {
QuizCard(
- modifier = Modifier.padding(32.dp),
+ modifier = Modifier.padding(24.dp),
index = it,
quiz = quizList[it],
quizLoaded = quizLoadState == QuizLoadState.LOADED || it < quizList.size - 1,
@@ -208,7 +231,9 @@ fun QuizProgress(
)
}
LinearProgressIndicator(
- modifier = Modifier.clip(RoundedCornerShape(2.dp)),
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(2.dp)),
progress = { animatedProgress.value },
)
IconButton(onClick = { onRegenerate() }) {
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/SummaryView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/SummaryView.kt
index 98a51dc..4cb5c7a 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/SummaryView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/SummaryView.kt
@@ -1,7 +1,7 @@
package com.example.readability.ui.screens.viewer
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
+import android.graphics.Typeface
+import android.widget.Toast
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -10,21 +10,57 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
-import com.example.readability.ui.theme.Lora
+import androidx.compose.ui.unit.em
+import com.example.readability.data.viewer.ViewerStyle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@Composable
+fun dpToSp(dp: Dp): TextUnit = with(LocalDensity.current) { dp.toSp() }
+
+@Composable
+fun pxToSp(px: Float): TextUnit = with(LocalDensity.current) { px.toSp() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SummaryView(onBack: () -> Unit = {}, summary: String) {
+fun SummaryView(
+ summary: String,
+ viewerStyle: ViewerStyle,
+ typeface: Typeface?,
+ referenceLineHeight: Float,
+ onBack: () -> Unit = {},
+ onLoadSummary: suspend () -> Result,
+) {
+ val context = LocalContext.current
+
+ LaunchedEffect(
+ Unit,
+ ) {
+ withContext(Dispatchers.IO) {
+ onLoadSummary()
+ }.onFailure {
+ Toast.makeText(
+ context,
+ "Failed to generate summary\n:${it.message}",
+ Toast.LENGTH_SHORT,
+ ).show()
+ onBack()
+ }
+ }
+
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "Previous Story") }, navigationIcon = {
@@ -34,21 +70,20 @@ fun SummaryView(onBack: () -> Unit = {}, summary: String) {
})
},
) { innerPadding ->
- Column(
+ Text(
modifier = Modifier
.padding(innerPadding)
- .padding(16.dp)
.verticalScroll(
rememberScrollState(),
- ),
- horizontalAlignment = Alignment.Start,
- verticalArrangement = Arrangement.Top,
- ) {
- Text(
- text = summary,
- textAlign = TextAlign.Start,
- style = MaterialTheme.typography.titleLarge.copy(fontFamily = Lora, fontWeight = FontWeight.Normal),
- )
- }
+ )
+ .padding(16.dp),
+ text = summary,
+ style = TextStyle(
+ fontSize = dpToSp(dp = viewerStyle.textSize.dp),
+ lineHeight = pxToSp(px = (viewerStyle.lineHeight * referenceLineHeight * viewerStyle.textSize / 16f)),
+ fontFamily = typeface?.let { FontFamily(it) },
+ letterSpacing = viewerStyle.letterSpacing.em,
+ ),
+ )
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerScreen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerScreen.kt
index 9dea3e8..d405158 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerScreen.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerScreen.kt
@@ -1,14 +1,26 @@
package com.example.readability.ui.screens.viewer
+import android.os.Build
+import android.view.View
+import android.view.WindowInsets
+import android.view.WindowInsetsController
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.readability.ui.animation.SharedAxis
import com.example.readability.ui.animation.composableSharedAxis
+import com.example.readability.ui.viewmodels.NetworkStatusViewModel
import com.example.readability.ui.viewmodels.QuizViewModel
import com.example.readability.ui.viewmodels.SummaryViewModel
import com.example.readability.ui.viewmodels.ViewerViewModel
@@ -19,16 +31,60 @@ import kotlinx.coroutines.withContext
sealed class ViewerScreens(val route: String) {
object Viewer : ViewerScreens("viewer")
object Quiz : ViewerScreens("quiz")
- object QuizReport : ViewerScreens("quiz/report/{question}/{answer}") {
- fun createRoute(question: String, answer: String) = "quiz/report/$question/$answer"
+ object QuizReport : ViewerScreens("quiz/report?question={question}&answer={answer}") {
+ fun createRoute(question: String, answer: String) = "quiz/report?question=$question&answer=$answer"
}
object Summary : ViewerScreens("summary")
}
@Composable
-fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
- val navController = rememberNavController()
+fun ViewerScreen(
+ id: Int,
+ navController: NavHostController = rememberNavController(),
+ onNavigateSettings: () -> Unit,
+ onBack: () -> Unit,
+) {
+ val networkStatusViewModel: NetworkStatusViewModel = hiltViewModel()
+ val context = LocalContext.current
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val immersiveModeEnabled = navBackStackEntry?.destination?.route == ViewerScreens.Viewer.route
+ var firstImmersiveMode by remember { mutableStateOf(true) }
+
+ LaunchedEffect(immersiveModeEnabled) {
+ if (firstImmersiveMode) {
+ firstImmersiveMode = false
+ return@LaunchedEffect
+ }
+ val activity = context.findActivity() ?: return@LaunchedEffect
+ if (immersiveModeEnabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ activity.window.insetsController?.let {
+ it.hide(WindowInsets.Type.systemBars())
+ it.systemBarsBehavior =
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ activity.window.decorView.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ activity.window.insetsController?.show(WindowInsets.Type.systemBars())
+ } else {
+ @Suppress("DEPRECATION")
+ activity.window.decorView.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_VISIBLE
+ }
+ }
+ }
+ LaunchedEffect(navBackStackEntry?.destination?.route) {
+ networkStatusViewModel.isConnected
+ }
+
NavHost(navController = navController, startDestination = ViewerScreens.Viewer.route) {
composableSharedAxis(ViewerScreens.Viewer.route, axis = SharedAxis.X) {
val viewerViewModel: ViewerViewModel = hiltViewModel()
@@ -37,13 +93,14 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
val bookData by viewerViewModel.getBookData(id).collectAsState(initial = null)
val pageSplitData by viewerViewModel.pageSplitData.collectAsState(initial = null)
val isDarkTheme = isSystemInDarkTheme()
+ val isNetworkConnected by networkStatusViewModel.connectedState.collectAsState()
ViewerView(
bookData = bookData,
pageSplitData = pageSplitData,
onBack = onBack,
+ isNetworkConnected = isNetworkConnected,
onNavigateQuiz = {
if (bookData != null) {
- quizViewModel.loadQuiz(id, bookData!!.progress)
navController.navigate(ViewerScreens.Quiz.route)
}
},
@@ -53,7 +110,6 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
},
onNavigateSummary = {
if (bookData != null) {
- summaryViewModel.loadSummary(id, bookData!!.progress)
navController.navigate(ViewerScreens.Summary.route)
}
},
@@ -63,6 +119,9 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
onPageDraw = { canvas, pageIndex ->
viewerViewModel.drawPage(id, canvas, pageIndex, isDarkTheme)
},
+ onUpdateSummaryProgress = {
+ viewerViewModel.updateSummaryProgress(id)
+ },
)
}
composableSharedAxis(ViewerScreens.Quiz.route, axis = SharedAxis.X) {
@@ -83,6 +142,7 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
),
)
},
+ onLoadQuiz = { quizViewModel.loadQuiz(id) },
)
}
composableSharedAxis(ViewerScreens.QuizReport.route, axis = SharedAxis.X) {
@@ -102,9 +162,19 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) {
composableSharedAxis(ViewerScreens.Summary.route, axis = SharedAxis.X) {
val summaryViewModel: SummaryViewModel = hiltViewModel()
val summary by summaryViewModel.summary.collectAsState()
- SummaryView(summary = summary, onBack = {
- navController.popBackStack()
- })
+ val viewerStyle by summaryViewModel.viewerStyle.collectAsState()
+ val typeface by summaryViewModel.typeface.collectAsState()
+ val referenceLineHeight by summaryViewModel.referenceLineHeight.collectAsState()
+ SummaryView(
+ summary = summary,
+ viewerStyle = viewerStyle,
+ typeface = typeface,
+ referenceLineHeight = referenceLineHeight,
+ onBack = {
+ navController.popBackStack()
+ },
+ onLoadSummary = { summaryViewModel.loadSummary(id) },
+ )
}
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt
index 58483d5..01f89f7 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt
@@ -1,14 +1,17 @@
package com.example.readability.ui.screens.viewer
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.pm.ActivityInfo
import android.content.res.Configuration
+import android.graphics.Bitmap
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -25,15 +28,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -42,10 +46,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
@@ -59,6 +63,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.NativeCanvas
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@@ -69,11 +75,14 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
@@ -81,20 +90,29 @@ import coil.compose.AsyncImage
import com.example.readability.R
import com.example.readability.data.book.Book
import com.example.readability.data.viewer.PageSplitData
+import com.example.readability.data.viewer.getPageIndex
+import com.example.readability.data.viewer.getPageProgress
import com.example.readability.ui.animation.DURATION_EMPHASIZED
+import com.example.readability.ui.animation.DURATION_STANDARD
import com.example.readability.ui.animation.EASING_EMPHASIZED
import com.example.readability.ui.animation.EASING_LEGACY
+import com.example.readability.ui.animation.EASING_STANDARD
import com.example.readability.ui.components.RoundedRectFilledTonalButton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlin.math.abs
+import kotlin.math.roundToInt
@Composable
fun ViewerView(
bookData: Book?,
pageSplitData: PageSplitData?,
+ isNetworkConnected: Boolean,
onPageDraw: (canvas: NativeCanvas, pageIndex: Int) -> Unit = { _, _ -> },
onBack: () -> Unit = {},
onProgressChange: (Double) -> Unit = {},
@@ -102,8 +120,14 @@ fun ViewerView(
onNavigateSettings: () -> Unit = {},
onNavigateQuiz: () -> Unit = {},
onNavigateSummary: () -> Unit = {},
+ onUpdateSummaryProgress: suspend () -> Result = { Result.success(Unit) },
) {
var overlayVisible by remember { mutableStateOf(false) }
+ val shrinkAnimation by animateFloatAsState(
+ targetValue = if (overlayVisible) 1f else 0f,
+ label = "ViewerScreen.ViewerView.PageShrinkAnimation",
+ animationSpec = tween(durationMillis = DURATION_EMPHASIZED, 0, EASING_EMPHASIZED),
+ )
var closeLoading by remember { mutableStateOf(true) }
var transitionDuration by remember { mutableStateOf(0) }
var lastBookReady by remember { mutableStateOf(true) }
@@ -111,6 +135,8 @@ fun ViewerView(
newValue = bookData != null && pageSplitData != null && pageSplitData.pageSplits.isNotEmpty() && closeLoading,
)
+ LockScreenOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
+
// if book is ready within 150ms, don't show loading screen
// otherwise, show loading screen for at least 700ms
LaunchedEffect(bookReady, lastBookReady) {
@@ -133,26 +159,24 @@ fun ViewerView(
}
val pageSize = pageSplitData?.pageSplits?.size ?: 0
- val pageIndex = maxOf(minOf((pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1), 0)
+ val pageIndex = pageSplitData?.getPageIndex(bookData?.progress ?: 0.0) ?: 0
+ var pageChangedByAnimation by remember { mutableStateOf(true) }
- Scaffold(
+ Box(
modifier = Modifier
.fillMaxSize()
- .navigationBarsPadding()
- .systemBarsPadding(),
- ) { innerPadding ->
+ .displayCutoutPadding(),
+ ) {
ViewerSizeMeasurer(
modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
+ .fillMaxSize(),
onPageSizeChanged = { width, height ->
onPageSizeChanged(width, height)
},
)
AnimatedContent(
modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
+ .fillMaxSize(),
targetState = bookReady,
label = "ViewerScreen.ViewerView.Content",
transitionSpec = {
@@ -168,16 +192,20 @@ fun ViewerView(
when (it) {
true -> ViewerOverlay(
modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- visible = overlayVisible,
+ .fillMaxSize(),
+ shrinkAnimation = shrinkAnimation,
bookData = bookData,
- pageSize = pageSize,
- onProgressChange = { onProgressChange(it.toDouble()) },
+ pageSplitData = pageSplitData,
+ isNetworkConnected = isNetworkConnected,
+ onPageChanged = { pageIndex, changedByAnimation ->
+ pageChangedByAnimation = changedByAnimation
+ onProgressChange(pageSplitData?.getPageProgress(pageIndex) ?: 0.0)
+ },
onBack = { onBack() },
onNavigateSettings = { onNavigateSettings() },
onNavigateSummary = { onNavigateSummary() },
onNavigateQuiz = { onNavigateQuiz() },
+ onUpdateSummaryProgress = onUpdateSummaryProgress,
) {
if (bookData != null && pageSize > 0) {
BookPager(
@@ -189,9 +217,12 @@ fun ViewerView(
onPageDraw(canvas, pageIndex)
},
pageIndex = pageIndex,
+ pageChangedByAnimation = pageChangedByAnimation,
overlayVisible = overlayVisible,
- onPageChanged = { pageIndex ->
- onProgressChange((pageIndex + 0.5) / pageSize)
+ shrinkAnimation = shrinkAnimation,
+ onPageChanged = { pageIndex, changedByAnimation ->
+ pageChangedByAnimation = changedByAnimation
+ onProgressChange(pageSplitData?.getPageProgress(pageIndex) ?: 0.0)
},
onOverlayVisibleChanged = { overlayVisible = it },
)
@@ -211,6 +242,26 @@ fun ViewerView(
}
}
+@Composable
+fun LockScreenOrientation(orientation: Int) {
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
+ val originalOrientation = activity.requestedOrientation
+ activity.requestedOrientation = orientation
+ onDispose {
+ // restore original orientation when view disappears
+ activity.requestedOrientation = originalOrientation
+ }
+ }
+}
+
+fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
@Composable
fun LoadingScreen(modifier: Modifier = Modifier, bookData: Book?) {
val configuration = LocalConfiguration.current
@@ -311,9 +362,11 @@ fun BookPager(
pageSplitData: PageSplitData?,
pageSize: Int,
pageIndex: Int,
+ pageChangedByAnimation: Boolean,
overlayVisible: Boolean,
+ shrinkAnimation: Float,
onPageDraw: (canvas: NativeCanvas, pageIndex: Int) -> Unit = { _, _ -> },
- onPageChanged: (Int) -> Unit = {},
+ onPageChanged: (Int, Boolean) -> Unit = { _, _ -> },
onOverlayVisibleChanged: (Boolean) -> Unit = {},
) {
val pagerState = rememberPagerState(
@@ -327,29 +380,23 @@ fun BookPager(
val overlayChangeScope = rememberCoroutineScope()
val animationScope = rememberCoroutineScope()
- val shrinkAnimation by animateFloatAsState(
- targetValue = if (overlayVisible) 1f else 0f,
- label = "ViewerScreen.ViewerView.PageShrinkAnimation",
- animationSpec = tween(durationMillis = DURATION_EMPHASIZED, 0, EASING_EMPHASIZED),
- )
-
LaunchedEffect(pagerState.currentPage) {
if (System.currentTimeMillis() - animationFinishedTime < 100) return@LaunchedEffect
if (animationCount == 0) {
if (pageIndex != pagerState.currentPage) {
- onPageChanged(pagerState.currentPage)
+ onPageChanged(pagerState.currentPage, false)
}
}
}
- LaunchedEffect(bookData.progress) {
- if (pageIndex != pagerState.currentPage) {
+ LaunchedEffect(pageIndex) {
+ if (pageIndex != pagerState.currentPage && pageChangedByAnimation) {
println("isMovingByAnimation = true")
mutex.withLock { animationCount++ }
try {
pagerState.animateScrollToPage(
pageIndex,
- animationSpec = tween(300, 0, EASING_LEGACY),
+ animationSpec = tween(DURATION_STANDARD, 0, EASING_STANDARD),
)
} finally {
println("isMovingByAnimation = false")
@@ -396,27 +443,29 @@ fun BookPager(
.pointerInput(pageIndex, pageSize, overlayVisible) {
awaitEachGesture {
val downEvent = awaitFirstDown(requireUnconsumed = false, PointerEventPass.Main)
- var upEventOrCancellation: PointerInputChange? = null
- while (upEventOrCancellation == null) {
- val event = awaitPointerEvent(pass = PointerEventPass.Main)
+ val upEvent: PointerInputChange
+ while (true) {
+ val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.fastAll { it.changedToUp() }) {
// All pointers are up
- upEventOrCancellation = event.changes[0]
+ upEvent = event.changes[0]
+ break
}
}
- val diff = upEventOrCancellation.position - downEvent.position
- if (diff.getDistanceSquared() < 10000) {
+ val diff = abs(upEvent.position.x - downEvent.position.x)
+ val timeDiff = upEvent.uptimeMillis - downEvent.uptimeMillis
+ if (diff < 25 && timeDiff < 150) {
if (downEvent.position.x < 0.25 * width) {
- onPageChanged(maxOf(pageIndex - 1, 0))
+ onPageChanged(maxOf(pageIndex - 1, 0), true)
} else if (downEvent.position.x > 0.75 * width) {
- onPageChanged(minOf(pageIndex + 1, pageSize - 1))
+ onPageChanged(minOf(pageIndex + 1, pageSize - 1), true)
} else {
// if the page size is changed with offset, the page stops at the middle of the page
// to prevent that, force remove the offset and close the overlay
val targetValue = !overlayVisible
overlayChangeScope.launch {
animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage) }
- while (pagerState.currentPageOffsetFraction != 0f && isActive) {
+ while (abs(pagerState.currentPageOffsetFraction) > 1e-3 && isActive) {
delay(16)
}
if (isActive) onOverlayVisibleChanged(targetValue)
@@ -441,11 +490,11 @@ fun BookPager(
),
pageSpacing = 32.dp * shrinkAnimation,
userScrollEnabled = animationCount == 0,
- ) { pageIndex ->
+ ) { index ->
BookPage(
pageSplitData = pageSplitData,
pageSize = pageSize,
- pageIndex = pageIndex,
+ pageIndex = index,
onPageDraw = onPageDraw,
)
}
@@ -461,17 +510,21 @@ fun BookPage(
) {
val padding = with(LocalDensity.current) { 16.dp.toPx() }
- val ratio =
+ val aspectRatio =
((pageSplitData?.width ?: 0) + padding * 2) / ((pageSplitData?.height ?: 0) + padding * 2)
+ var drawCache by remember(pageIndex, pageSplitData?.width, pageSplitData?.height, pageSplitData?.viewerStyle) {
+ mutableStateOf(null)
+ }
+
Column(
- modifier = Modifier.fillMaxSize(),
+ modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
Box(
- modifier = modifier
+ modifier = Modifier
.fillMaxWidth()
- .aspectRatio(ratio)
+ .aspectRatio(aspectRatio)
.background(MaterialTheme.colorScheme.background),
) {
AnimatedContent(
@@ -484,15 +537,24 @@ fun BookPage(
modifier = Modifier.fillMaxSize(),
) {
drawIntoCanvas { canvas ->
- // red background
- val ratio = size.width / (pageSplitData!!.width + 32.dp.toPx())
- // scale with pivot left top
- scale(
- scale = ratio,
- pivot = Offset(0f, 0f),
- ) {
- translate(left = 16.dp.toPx(), top = 16.dp.toPx()) {
- onPageDraw(canvas.nativeCanvas, pageIndex)
+ if (pageSplitData != null) {
+ if (drawCache == null) {
+ drawCache = Bitmap.createBitmap(
+ pageSplitData.width,
+ pageSplitData.height,
+ Bitmap.Config.ARGB_8888,
+ )
+ val tempCanvas = NativeCanvas(drawCache!!)
+ onPageDraw(tempCanvas, pageIndex)
+ }
+ val sizeRatio = size.width / (pageSplitData.width + 32.dp.toPx())
+ scale(
+ scale = sizeRatio,
+ pivot = Offset(0f, 0f),
+ ) {
+ translate(left = 16.dp.toPx(), top = 16.dp.toPx()) {
+ canvas.nativeCanvas.drawBitmap(drawCache!!, 0f, 0f, null)
+ }
}
}
}
@@ -551,119 +613,289 @@ fun ViewerSizeMeasurer(modifier: Modifier = Modifier, onPageSizeChanged: (Int, I
@Composable
fun ViewerOverlay(
modifier: Modifier = Modifier,
- visible: Boolean,
+ shrinkAnimation: Float,
bookData: Book?,
- pageSize: Int,
- onProgressChange: (Float) -> Unit,
+ pageSplitData: PageSplitData?,
+ isNetworkConnected: Boolean,
+ onPageChanged: (Int, Boolean) -> Unit = { _, _ -> },
onBack: () -> Unit,
onNavigateSettings: () -> Unit,
onNavigateSummary: () -> Unit,
onNavigateQuiz: () -> Unit,
+ onUpdateSummaryProgress: suspend () -> Result,
content: @Composable () -> Unit = {},
) {
- val pageIndex = minOf(
- (pageSize * (bookData?.progress ?: 0.0)).toInt(),
- pageSize - 1,
- )
+ val pageIndex = pageSplitData?.getPageIndex(bookData?.progress ?: 0.0) ?: 0
- Column(
+ Layout(
modifier = modifier,
- ) {
- AnimatedVisibility(
- visible = visible,
- label = "EbookView.ViewerOverlay.TopContent",
- enter = fadeIn(tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED)) + expandVertically(
- tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED),
- ),
- exit = fadeOut(tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED)) + shrinkVertically(
- tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED),
- ),
- ) {
- CenterAlignedTopAppBar(windowInsets = WindowInsets(0, 0, 0, 0), title = {
- Text(
- text = bookData?.title ?: "",
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
- )
- }, navigationIcon = {
- IconButton(onClick = {
- onBack()
- }) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back",
+ content = {
+ CenterAlignedTopAppBar(
+ modifier = Modifier.alpha(shrinkAnimation),
+ windowInsets = WindowInsets(0, 0, 0, 0),
+ title = {
+ Text(
+ text = bookData?.title ?: "",
+ style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph),
+ textAlign = TextAlign.Center,
)
- }
- }, actions = {
- IconButton(onClick = {
- onNavigateSettings()
- }) {
- Icon(
- painter = painterResource(id = R.drawable.settings),
- contentDescription = "Settings",
- )
- }
- })
- }
+ },
+ navigationIcon = {
+ IconButton(onClick = {
+ onBack()
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = {
+ onNavigateSettings()
+ }) {
+ Icon(
+ painter = painterResource(id = R.drawable.settings),
+ contentDescription = "Settings",
+ )
+ }
+ },
+ )
- Box(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- ) {
- content()
- }
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ content()
+ }
- AnimatedVisibility(
- visible = visible,
- label = "EbookView.ViewerOverlay.BottomContent",
- enter = fadeIn(tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED)) + expandVertically(
- tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), expandFrom = Alignment.Top,
- ),
- exit = fadeOut(tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED)) + shrinkVertically(
- tween(DURATION_EMPHASIZED, 0, EASING_EMPHASIZED), shrinkTowards = Alignment.Top,
- ),
- ) {
- Box(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(shrinkAnimation),
+ ) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface),
) {
- Row(
+ SummaryActions(
modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 0.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- RoundedRectFilledTonalButton(
- modifier = Modifier.weight(1f),
- onClick = { onNavigateSummary() },
- ) {
- Text("Generate Summary")
- }
- RoundedRectFilledTonalButton(
- modifier = Modifier.weight(1f),
- onClick = { onNavigateQuiz() },
- ) {
- Text("Generate Quiz")
- }
- }
+ isNetworkConnected = isNetworkConnected,
+ summaryProgress = bookData?.summaryProgress ?: 0.0,
+ pageIndex = pageIndex,
+ onNavigateSummary = onNavigateSummary,
+ onNavigateQuiz = onNavigateQuiz,
+ onUpdateSummaryProgress = onUpdateSummaryProgress,
+ )
Slider(
modifier = Modifier.padding(horizontal = 16.dp),
value = bookData?.progress?.toFloat() ?: 0f,
- onValueChange = onProgressChange,
+ onValueChange = {
+ val newIndex = pageSplitData?.getPageIndex(it.toDouble()) ?: 0
+ if (newIndex != pageIndex) {
+ onPageChanged(newIndex, true)
+ }
+ },
)
}
}
- }
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- text = "${pageIndex + 1} / $pageSize",
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.bodyMedium,
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = MaterialTheme.colorScheme.background)
+ .padding(vertical = 8.dp),
+ text = "${pageIndex + 1} / ${pageSplitData?.pageSplits?.size ?: 0}",
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ ) { measureables, constraints ->
+ val width = constraints.maxWidth
+ val height = constraints.maxHeight
+ val columnConstraints = constraints.copy(
+ minWidth = 0,
+ maxWidth = width,
+ minHeight = 0,
+ maxHeight = height,
)
+ val topBar = measureables[0].measure(columnConstraints)
+ val bottomBar = measureables[2].measure(columnConstraints)
+ val bottomProgress = measureables[3].measure(columnConstraints)
+
+ val topBarTop = (topBar.height * (-1 + shrinkAnimation)).roundToInt()
+ val contentTop = (topBar.height * shrinkAnimation).roundToInt()
+ val bottomBarTop = (height - bottomProgress.height - bottomBar.height * shrinkAnimation).roundToInt()
+ val bottomProgressTop = height - bottomProgress.height
+ val contentHeight = maxOf(0, bottomBarTop - contentTop)
+ val content = measureables[1].measure(
+ constraints.copy(
+ minHeight = contentHeight,
+ maxHeight = contentHeight,
+ ),
+ )
+
+ layout(width, height) {
+ topBar.placeRelative(0, topBarTop)
+ content.placeRelative(0, contentTop)
+ bottomBar.placeRelative(0, bottomBarTop)
+ bottomProgress.placeRelative(0, bottomProgressTop)
+ }
+ }
+}
+
+@Composable
+fun SummaryActions(
+ modifier: Modifier = Modifier,
+ isNetworkConnected: Boolean,
+ summaryProgress: Double,
+ pageIndex: Int,
+ onNavigateSummary: () -> Unit = {},
+ onNavigateQuiz: () -> Unit = {},
+ onUpdateSummaryProgress: suspend () -> Result = { Result.success(Unit) },
+) {
+ // update summary progress on every 2 seconds, if the progress is not 1
+ val summaryUpdateScope = rememberCoroutineScope()
+ val summaryUpdateJob = remember { mutableStateOf(null) }
+ LaunchedEffect(summaryProgress) {
+ if (summaryProgress < 1 && summaryUpdateJob.value == null) {
+ summaryUpdateJob.value = summaryUpdateScope.launch(Dispatchers.IO) {
+ while (isActive) {
+ onUpdateSummaryProgress().onFailure {
+ println("update summary progress failed: $it")
+ }
+ delay(2000)
+ }
+ }
+ } else if (summaryProgress >= 1) {
+ summaryUpdateJob.value?.cancel()
+ summaryUpdateJob.value = null
+ }
+ }
+
+ val animatedSummaryProgress by animateFloatAsState(
+ targetValue = summaryProgress.toFloat(),
+ label = "ViewerScreen.SummaryActions.SummaryProgressAnimation",
+ animationSpec = tween(DURATION_STANDARD, 0, EASING_STANDARD),
+ )
+
+ val infiniteTransition =
+ rememberInfiniteTransition(label = "ViewerScreen.SummaryActions.ThreeDotsAnimation")
+ val dotCount by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 3f,
+ animationSpec = infiniteRepeatable(tween(durationMillis = 2000, easing = { it })),
+ label = "ViewerScreen.SummaryActions.ThreeDotsAnimation",
+ )
+
+ if (!isNetworkConnected) {
+ Box(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ shape = RoundedCornerShape(12.dp),
+ )
+ .height(48.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ "No Internet Connection",
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ }
+ } else if (summaryProgress < 1) {
+ Box(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ shape = RoundedCornerShape(12.dp),
+ )
+ .height(48.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Preparing AI${
+ ".".repeat(
+ dotCount.toInt() + 1,
+ )
+ } (${(summaryProgress * 100).toInt()}%)",
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ }
+ Layout(
+ modifier = Modifier
+ .clipToBounds()
+ .background(color = MaterialTheme.colorScheme.secondaryContainer)
+ .align(Alignment.CenterStart),
+ content = {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Preparing AI${
+ ".".repeat(
+ dotCount.toInt() + 1,
+ )
+ } (${(summaryProgress * 100).toInt()}%)",
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
+ )
+ }
+ },
+ ) { measureables, constraints ->
+ val foregroundText = measureables[0].measure(constraints)
+ layout((constraints.maxWidth * animatedSummaryProgress).roundToInt(), constraints.maxHeight) {
+ foregroundText.placeRelative(0, 0)
+ }
+ }
+ }
+ } else if (pageIndex < 4) {
+ Box(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ shape = RoundedCornerShape(12.dp),
+ )
+ .height(48.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ "4 pages required for Summary and Quiz",
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ }
+ } else {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ RoundedRectFilledTonalButton(
+ modifier = Modifier.weight(1f),
+ onClick = { onNavigateSummary() },
+ ) {
+ Text("Generate Summary")
+ }
+ RoundedRectFilledTonalButton(
+ modifier = Modifier.weight(1f),
+ onClick = { onNavigateQuiz() },
+ ) {
+ Text("Generate Quiz")
+ }
+ }
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/theme/Type.kt b/frontend/app/src/main/java/com/example/readability/ui/theme/Type.kt
index b80f67b..be926f4 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/theme/Type.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/theme/Type.kt
@@ -4,6 +4,7 @@ import androidx.compose.material3.Typography
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import com.example.readability.R
@@ -11,28 +12,44 @@ import com.example.readability.R
val Gabarito = FontFamily(
Font(
R.font.gabarito,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Normal.weight),
+ ),
),
)
@OptIn(ExperimentalTextApi::class)
-val Lora = FontFamily(
+val GabaritoMedium = FontFamily(
+ Font(
+ R.font.gabarito,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Medium.weight),
+ ),
+ ),
+)
+
+@OptIn(ExperimentalTextApi::class)
+val LoraSemiBold = FontFamily(
Font(
R.font.lora,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.SemiBold.weight),
+ ),
),
)
// Set of Material typography styles to start with
private val defaultTypoGraphy = Typography()
val Typography = Typography(
- displayLarge = defaultTypoGraphy.displayLarge.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- displayMedium = defaultTypoGraphy.displayMedium.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- displaySmall = defaultTypoGraphy.displaySmall.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- headlineLarge = defaultTypoGraphy.headlineLarge.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- headlineMedium = defaultTypoGraphy.headlineMedium.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- headlineSmall = defaultTypoGraphy.headlineSmall.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- titleLarge = defaultTypoGraphy.titleLarge.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- titleMedium = defaultTypoGraphy.titleMedium.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
- titleSmall = defaultTypoGraphy.titleSmall.copy(fontFamily = Lora, fontWeight = FontWeight.SemiBold),
+ displayLarge = defaultTypoGraphy.displayLarge.copy(fontFamily = LoraSemiBold),
+ displayMedium = defaultTypoGraphy.displayMedium.copy(fontFamily = LoraSemiBold),
+ displaySmall = defaultTypoGraphy.displaySmall.copy(fontFamily = LoraSemiBold),
+ headlineLarge = defaultTypoGraphy.headlineLarge.copy(fontFamily = LoraSemiBold),
+ headlineMedium = defaultTypoGraphy.headlineMedium.copy(fontFamily = LoraSemiBold),
+ headlineSmall = defaultTypoGraphy.headlineSmall.copy(fontFamily = LoraSemiBold),
+ titleLarge = defaultTypoGraphy.titleLarge.copy(fontFamily = LoraSemiBold),
+ titleMedium = defaultTypoGraphy.titleMedium.copy(fontFamily = LoraSemiBold),
+ titleSmall = defaultTypoGraphy.titleSmall.copy(fontFamily = LoraSemiBold),
bodyLarge = defaultTypoGraphy.bodyLarge.copy(fontFamily = Gabarito),
bodyMedium = defaultTypoGraphy.bodyMedium.copy(fontFamily = Gabarito),
bodySmall = defaultTypoGraphy.bodySmall.copy(fontFamily = Gabarito),
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/BookListViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/BookListViewModel.kt
index f15ec21..5322f50 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/BookListViewModel.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/BookListViewModel.kt
@@ -2,14 +2,17 @@ package com.example.readability.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.example.readability.data.book.Book
import com.example.readability.data.book.BookCardData
import com.example.readability.data.book.BookRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -17,24 +20,31 @@ import javax.inject.Inject
class BookListViewModel @Inject constructor(
private val bookRepository: BookRepository,
) : ViewModel() {
+ private fun bookToBookCardData(book: Book) = BookCardData(
+ id = book.bookId,
+ title = book.title,
+ author = book.author,
+ progress = book.progress,
+ coverImage = book.coverImage,
+ coverImageData = book.coverImageData,
+ content = book.content,
+ summaryProgress = book.summaryProgress,
+ )
+
val bookCardDataList = bookRepository.bookList.map {
- it.map { book ->
- BookCardData(
- id = book.bookId,
- title = book.title,
- author = book.author,
- progress = book.progress,
- coverImage = book.coverImage,
- coverImageData = book.coverImageData,
- content = book.content,
- )
- }
- }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+ it.map { bookToBookCardData(it) }
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.Lazily,
+ runBlocking {
+ bookRepository.bookList.first().map { bookToBookCardData(it) }
+ },
+ )
init {
viewModelScope.launch(Dispatchers.IO) {
bookRepository.refreshBookList().onFailure {
- println("BookListViewModel: refreshBookList failed: $it")
+ println("BookListViewModel: refreshBookList failed: ${it.message}")
}
}
}
@@ -52,4 +62,20 @@ class BookListViewModel @Inject constructor(
bookRepository.updateProgress(bookId, progress)
}
}
+
+ suspend fun deleteBook(bookId: Int) {
+ viewModelScope.launch(Dispatchers.IO) {
+ bookRepository.deleteBook(bookId)
+ }
+ }
+
+ suspend fun updateBookList(): Result {
+ return withContext(Dispatchers.IO) {
+ bookRepository.refreshBookList()
+ }
+ }
+
+ suspend fun clearBookList() {
+ bookRepository.clearBooks()
+ }
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/NetworkStatusViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/NetworkStatusViewModel.kt
new file mode 100644
index 0000000..f0499b3
--- /dev/null
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/NetworkStatusViewModel.kt
@@ -0,0 +1,15 @@
+package com.example.readability.ui.viewmodels
+
+import androidx.lifecycle.ViewModel
+import com.example.readability.data.NetworkStatusRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class NetworkStatusViewModel @Inject constructor(
+ private val networkStatusRepository: NetworkStatusRepository,
+) : ViewModel() {
+ val isConnected: Boolean
+ get() = networkStatusRepository.isConnected
+ val connectedState = networkStatusRepository.connectedState
+}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/QuizViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/QuizViewModel.kt
index fbb532c..f44f833 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/QuizViewModel.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/QuizViewModel.kt
@@ -1,25 +1,23 @@
package com.example.readability.ui.viewmodels
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
import com.example.readability.data.ai.QuizRepository
+import com.example.readability.data.book.BookRepository
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.first
import javax.inject.Inject
@HiltViewModel
class QuizViewModel @Inject constructor(
private val quizRepository: QuizRepository,
+ private val bookRepository: BookRepository,
) : ViewModel() {
val quizList = quizRepository.quizList
val quizSize = quizRepository.quizCount
val quizLoadState = quizRepository.quizLoadState
- fun loadQuiz(bookId: Int, progress: Double) {
- viewModelScope.launch(Dispatchers.IO) {
- quizRepository.getQuiz(bookId, progress)
- }
+ suspend fun loadQuiz(bookId: Int): Result {
+ return quizRepository.getQuiz(bookId, bookRepository.getBook(bookId).first()!!.progress)
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/SummaryViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/SummaryViewModel.kt
index 0a53800..39650eb 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/SummaryViewModel.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/SummaryViewModel.kt
@@ -1,24 +1,27 @@
package com.example.readability.ui.viewmodels
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
import com.example.readability.data.ai.SummaryRepository
+import com.example.readability.data.book.BookRepository
+import com.example.readability.data.viewer.FontDataSource
+import com.example.readability.data.viewer.SettingRepository
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.first
import javax.inject.Inject
@HiltViewModel
class SummaryViewModel @Inject constructor(
private val summaryRepository: SummaryRepository,
+ private val settingRepository: SettingRepository,
+ private val fontDataSource: FontDataSource,
+ private val bookRepository: BookRepository,
) : ViewModel() {
val summary = summaryRepository.summary
+ val viewerStyle = settingRepository.viewerStyle
+ val typeface = fontDataSource.customTypeface
+ val referenceLineHeight = fontDataSource.referenceLineHeight
- fun loadSummary(bookId: Int, progress: Double) {
- viewModelScope.launch(Dispatchers.IO) {
- summaryRepository.getSummary(bookId, progress).onFailure {
- it.printStackTrace()
- }
- }
+ suspend fun loadSummary(bookId: Int): Result {
+ return summaryRepository.getSummary(bookId, bookRepository.getBook(bookId).first()!!.progress)
}
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/UserViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/UserViewModel.kt
index 18ffb91..109b8d9 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/UserViewModel.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/UserViewModel.kt
@@ -1,14 +1,49 @@
package com.example.readability.ui.viewmodels
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.readability.data.user.User
+import com.example.readability.data.user.UserData
import com.example.readability.data.user.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository,
) : ViewModel() {
+ val user = userRepository.user.stateIn(
+ viewModelScope,
+ SharingStarted.Lazily,
+ runBlocking {
+ userRepository.user.first()
+ },
+ )
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ userRepository.getUserInfo().onFailure {
+ println("UserViewModel: getUserInfo failed: ${it.message}")
+ }
+ }
+ }
+
+ private fun userToUserData(user: User) = UserData(
+ userName = user.userName,
+ userEmail = user.userEmail,
+ refreshToken = user.refreshToken,
+ refreshTokenLife = user.refreshTokenLife,
+ accessToken = user.accessToken,
+ accessTokenLife = user.accessTokenLife,
+ createdAt = user.createdAt,
+ verified = user.verified,
+ )
suspend fun isSignedIn(): Boolean {
return userRepository.getAccessToken() != null
}
@@ -16,4 +51,7 @@ class UserViewModel @Inject constructor(
suspend fun signUp(email: String, username: String, password: String) =
userRepository.signUp(email, username, password)
suspend fun signOut() = userRepository.signOut()
+ suspend fun getUserInfo() = userRepository.getUserInfo()
+
+ suspend fun changePassword(newPassword: String) = userRepository.changePassword(newPassword)
}
diff --git a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/ViewerViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/ViewerViewModel.kt
index 8e3bed7..817d77e 100644
--- a/frontend/app/src/main/java/com/example/readability/ui/viewmodels/ViewerViewModel.kt
+++ b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/ViewerViewModel.kt
@@ -9,6 +9,7 @@ import com.example.readability.data.viewer.PageSplitRepository
import com.example.readability.data.viewer.SettingRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -23,6 +24,7 @@ class ViewerViewModel @Inject constructor(
val pageSplitData = MutableStateFlow(null)
val viewerStyle = settingRepository.viewerStyle
+ private var job: Job? = null
fun setPageSize(bookId: Int, width: Int, height: Int) {
if (pageSplitData.value?.width == width &&
@@ -44,6 +46,10 @@ class ViewerViewModel @Inject constructor(
}
}
+ suspend fun updateSummaryProgress(bookId: Int): Result {
+ return bookRepository.updateSummaryProgress(bookId)
+ }
+
fun getBookData(id: Int) = bookRepository.getBook(id)
fun drawPage(bookId: Int, canvas: NativeCanvas, page: Int, isDarkMode: Boolean) {
diff --git a/frontend/app/src/main/res/drawable/avatar.xml b/frontend/app/src/main/res/drawable/avatar.xml
new file mode 100644
index 0000000..7abc53a
--- /dev/null
+++ b/frontend/app/src/main/res/drawable/avatar.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/frontend/app/src/main/res/drawable/default_book_cover_image.png b/frontend/app/src/main/res/drawable/default_book_cover_image.png
new file mode 100644
index 0000000..8f4bfa3
Binary files /dev/null and b/frontend/app/src/main/res/drawable/default_book_cover_image.png differ
diff --git a/frontend/app/src/main/res/drawable/sign_out.xml b/frontend/app/src/main/res/drawable/sign_out.xml
new file mode 100644
index 0000000..3458003
--- /dev/null
+++ b/frontend/app/src/main/res/drawable/sign_out.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/frontend/app/src/main/res/drawable/user_image.xml b/frontend/app/src/main/res/drawable/user_image.xml
new file mode 100644
index 0000000..947c087
--- /dev/null
+++ b/frontend/app/src/main/res/drawable/user_image.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/frontend/app/src/test/java/com/example/readability/ui/models/BookRepositoryTest.kt b/frontend/app/src/test/java/com/example/readability/ui/models/BookRepositoryTest.kt
index 936540c..de01bf3 100644
--- a/frontend/app/src/test/java/com/example/readability/ui/models/BookRepositoryTest.kt
+++ b/frontend/app/src/test/java/com/example/readability/ui/models/BookRepositoryTest.kt
@@ -1,11 +1,16 @@
package com.example.readability.ui.models
+import com.example.readability.data.NetworkStatusRepository
+import com.example.readability.data.book.AddBookRequest
import com.example.readability.data.book.Book
import com.example.readability.data.book.BookDao
+import com.example.readability.data.book.BookEntity
+import com.example.readability.data.book.BookFileDataSource
import com.example.readability.data.book.BookRemoteDataSource
import com.example.readability.data.book.BookRepository
import com.example.readability.data.user.UserRepository
import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
@@ -33,27 +38,80 @@ class BookRepositoryTest {
@Mock
private lateinit var userRepository: UserRepository
+ @Mock
+ private lateinit var networkStatusRepository: NetworkStatusRepository
+
+ @Mock
+ private lateinit var bookFileDataSource: BookFileDataSource
+
// class under test
private lateinit var bookRepository: BookRepository
@Before
- fun init() {
+ fun init() = runBlocking {
`when`(bookDao.getAll()).thenReturn(
listOf(
- Book(
+ BookEntity(
bookId = 1,
title = "test",
author = "test",
progress = 0.0,
+ coverImage = null,
+ content = "test",
+ summaryProgress = 0.5,
+ ),
+ BookEntity(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ BookEntity(
+ bookId = 3,
+ title = "test",
+ author = "test",
+ progress = 0.7,
coverImage = "test",
content = "test",
+ summaryProgress = 1.0,
),
),
)
+ `when`(bookRemoteDataSource.getBookList("test")).thenReturn(
+ Result.success(
+ listOf(
+ Book(
+ bookId = 1,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 0.5,
+ ),
+ Book(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ ),
+ ),
+ )
+ `when`(networkStatusRepository.isConnected).thenReturn(true)
+ `when`(userRepository.getAccessToken()).thenReturn("test")
bookRepository = BookRepository(
bookDao = bookDao,
bookRemoteDataSource = bookRemoteDataSource,
userRepository = userRepository,
+ networkStatusRepository = networkStatusRepository,
+ bookFileDataSource = bookFileDataSource,
)
}
@@ -73,4 +131,467 @@ class BookRepositoryTest {
// bookMap should be updated
assert(bookRepository.getBook(bookId).firstOrNull()?.progress == progress)
}
+
+ @Test
+ fun updateSummaryProgress_succeed() = runTest {
+ // Arrange
+ val bookId = 1
+ val summaryProgress = 0.5
+ `when`(
+ bookRemoteDataSource.getSummaryProgress(
+ "test",
+ bookId,
+ ),
+ ).thenReturn(Result.success(summaryProgress.toString()))
+ doNothing().`when`(bookDao).updateSummaryProgress(bookId, summaryProgress)
+
+ // Act
+ bookRepository.updateSummaryProgress(bookId)
+
+ // Assert
+ // bookMap should be updated
+ assert(bookRepository.getBook(bookId).firstOrNull()?.summaryProgress == summaryProgress)
+ }
+
+ @Test
+ fun updateSummaryProgress_remote_fail() = runTest {
+ // Arrange
+ val bookId = 1
+ `when`(bookRemoteDataSource.getSummaryProgress("test", bookId)).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.updateSummaryProgress(bookId)
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun getBook_succeed() = runTest {
+ // Arrange
+ val bookId = 1
+ `when`(bookDao.getBook(bookId)).thenReturn(
+ BookEntity(
+ bookId = bookId,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 0.5,
+ ),
+ )
+
+ // Act
+ val book = bookRepository.getBook(bookId).firstOrNull()
+
+ // Assert
+ // book should be returned
+ assert(book != null)
+ }
+
+ @Test
+ fun getBook_fail() = runTest {
+ // Arrange
+ val bookId = 10
+ `when`(bookDao.getBook(bookId)).thenReturn(null)
+
+ // Act
+ val book = bookRepository.getBook(bookId).firstOrNull()
+
+ // Assert
+ // book should be null
+ assert(book == null)
+ }
+
+ @Test
+ fun refreshBookList_succeed() = runTest {
+ // Arrange
+ val deletedBookId = 1
+ val updatedProgressBookId = 2
+ val insertedBookId = 4
+ val updatedProgress = 0.6
+ val insertedBook =
+ BookEntity(
+ bookId = insertedBookId,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ )
+
+ doNothing().`when`(bookDao).insert(insertedBook)
+ doNothing().`when`(bookDao).updateProgress(updatedProgressBookId, updatedProgress)
+ doNothing().`when`(bookDao).updateProgress(3, 0.7)
+
+ `when`(bookRemoteDataSource.getBookList("test")).thenReturn(
+ Result.success(
+ listOf(
+ Book(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = updatedProgress,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ Book(
+ bookId = 3,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ Book(
+ bookId = insertedBookId,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ ),
+ ),
+ )
+ `when`(bookDao.getBook(1)).thenReturn(
+ BookEntity(
+ bookId = insertedBookId,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 0.5,
+ ),
+ )
+ `when`(bookDao.getBook(2)).thenReturn(
+ BookEntity(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ )
+ `when`(bookDao.getBook(3)).thenReturn(
+ BookEntity(
+ bookId = 3,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ )
+ `when`(bookDao.getBook(4)).thenReturn(
+ null,
+ )
+
+ // Act
+ bookRepository.refreshBookList()
+
+ // Assert
+ verify(bookDao, times(1)).insert(insertedBook)
+ verify(bookDao, times(1)).updateProgress(updatedProgressBookId, updatedProgress)
+ assert(bookRepository.getBook(deletedBookId).firstOrNull() == null)
+ assert(bookRepository.getBook(updatedProgressBookId).firstOrNull()?.progress == updatedProgress)
+ assert(bookRepository.getBook(insertedBookId).firstOrNull() != null)
+ assert(bookRepository.bookList.firstOrNull()?.size == 3)
+ }
+
+ @Test
+ fun refreshBookList_network_fail() = runTest {
+ // Arrange
+ `when`(networkStatusRepository.isConnected).thenReturn(false)
+
+ // Act
+ val result = bookRepository.refreshBookList()
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun refreshBookList_remote_fail() = runTest {
+ // Arrange
+ `when`(bookRemoteDataSource.getBookList("test")).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.refreshBookList()
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun getCoverImageData_succeed() = runTest {
+ // Arrange
+ val bookId = 2
+ val imageBitmap = null
+
+ `when`(bookFileDataSource.coverImageExists(bookId)).thenReturn(true)
+ `when`(bookFileDataSource.readCoverImageFile(bookId)).thenReturn(imageBitmap)
+
+ // Act
+ val result = bookRepository.getCoverImageData(bookId)
+
+ // Assert
+ assert(result.isSuccess)
+ }
+
+ @Test
+ fun getCoverImageData_fail() = runTest {
+ // Arrange
+ val bookId = 2
+ val coverImage = "test"
+
+ `when`(bookFileDataSource.coverImageExists(bookId)).thenReturn(false)
+ `when`(bookRemoteDataSource.getCoverImageData("test", coverImage)).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.getCoverImageData(bookId)
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun getCoverImageDataIsNull_fail() = runTest {
+ // Arrange
+ val bookId = 1
+ `when`(bookFileDataSource.coverImageExists(bookId)).thenReturn(false)
+
+ // Act
+ val result = bookRepository.getCoverImageData(bookId)
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun getContentData_succeed() = runTest {
+ // Arrange
+ val bookId = 1
+ val contentString = "test_string"
+
+ `when`(bookFileDataSource.contentExists(bookId)).thenReturn(true)
+ `when`(bookFileDataSource.readContentFile(bookId)).thenReturn(contentString)
+
+ // Act
+ val result = bookRepository.getContentData(bookId)
+
+ // Assert
+ assert(result.isSuccess)
+ }
+
+ @Test
+ fun getContentData_fail() = runTest {
+ // Arrange
+ val bookId = 1
+ val content = "test"
+
+ `when`(bookFileDataSource.contentExists(bookId)).thenReturn(false)
+ `when`(bookRemoteDataSource.getContentData("test", content)).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.getContentData(bookId)
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun getContentData_remote_succeed() = runTest {
+ // Arrange
+ val bookId = 1
+ val content = "test"
+ val contentString = "test_string"
+
+ `when`(bookFileDataSource.contentExists(bookId)).thenReturn(false)
+ `when`(bookRemoteDataSource.getContentData("test", content)).thenReturn(Result.success(contentString))
+ doNothing().`when`(bookFileDataSource).writeContentFile(bookId, contentString)
+
+ // Act
+ val result = bookRepository.getContentData(bookId)
+
+ // Assert
+ assert(result.isSuccess)
+ }
+
+ @Test
+ fun addBook_succeed() = runTest {
+ // Arrange
+ val addBookRequest = AddBookRequest(
+ title = "test",
+ content = "test",
+ author = "test",
+ coverImage = "",
+ )
+ val insertedBookEntity =
+ BookEntity(
+ bookId = 4,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ )
+
+ `when`(bookDao.getAll()).thenReturn(
+ listOf(
+ BookEntity(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ BookEntity(
+ bookId = 3,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ ),
+ )
+ `when`(bookRemoteDataSource.getBookList("test")).thenReturn(
+ Result.success(
+ listOf(
+ Book(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ Book(
+ bookId = 3,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ Book(
+ bookId = 4,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ ),
+ ),
+ )
+
+ `when`(bookRemoteDataSource.addBook("test", addBookRequest)).thenReturn(Result.success(Unit))
+ doNothing().`when`(bookDao).insert(insertedBookEntity)
+ doNothing().`when`(bookDao).updateProgress(2, 0.7)
+ doNothing().`when`(bookDao).updateProgress(3, 0.7)
+
+ // Act
+ val result = bookRepository.addBook(addBookRequest)
+
+ // Assert
+ verify(bookDao, times(1)).insert(insertedBookEntity)
+ assert(result.isSuccess)
+ }
+
+ @Test
+ fun addBook_remote_fail() = runTest {
+ // Arrange
+ val addBookRequest = AddBookRequest(
+ title = "test",
+ content = "test",
+ author = "test",
+ coverImage = "",
+ )
+
+ `when`(bookRemoteDataSource.addBook("test", addBookRequest)).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.addBook(addBookRequest)
+
+ // Assert
+ assert(result.isFailure)
+ }
+
+ @Test
+ fun deleteBook_succeed() = runTest {
+ // Arrange
+ val bookId = 3
+
+ `when`(bookRemoteDataSource.deleteBook(bookId, "test")).thenReturn(Result.success("test"))
+ doNothing().`when`(bookFileDataSource).deleteContentFile(bookId)
+ doNothing().`when`(bookFileDataSource).deleteCoverImageFile(bookId)
+ doNothing().`when`(bookDao).delete(bookId)
+ `when`(bookDao.getBook(1)).thenReturn(
+ BookEntity(
+ bookId = 1,
+ title = "test",
+ author = "test",
+ progress = 0.0,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 0.5,
+ ),
+ )
+ `when`(bookDao.getBook(2)).thenReturn(
+ BookEntity(
+ bookId = 2,
+ title = "test",
+ author = "test",
+ progress = 0.7,
+ coverImage = "test",
+ content = "test",
+ summaryProgress = 1.0,
+ ),
+ )
+ doNothing().`when`(bookDao).updateProgress(1, 0.0)
+ doNothing().`when`(bookDao).updateProgress(2, 0.7)
+
+ // Act
+ val result = bookRepository.deleteBook(bookId)
+
+ // Assert
+ verify(bookDao, times(2)).delete(bookId)
+ verify(bookFileDataSource, times(2)).deleteContentFile(bookId)
+ verify(bookFileDataSource, times(2)).deleteCoverImageFile(bookId)
+ assert(result.isSuccess)
+ assert(bookRepository.getBook(bookId).firstOrNull() == null)
+ assert(bookRepository.bookList.firstOrNull()?.size == 2)
+ }
+
+ @Test
+ fun deleteBook_remote_fail() = runTest {
+ // Arrange
+ val bookId = 3
+
+ `when`(bookRemoteDataSource.deleteBook(bookId, "test")).thenReturn(Result.failure(Throwable("test")))
+
+ // Act
+ val result = bookRepository.deleteBook(bookId)
+
+ // Assert
+ assert(result.isFailure)
+ }
}
diff --git a/frontend/app/src/test/java/com/example/readability/ui/models/UserRepositoryTest.kt b/frontend/app/src/test/java/com/example/readability/ui/models/UserRepositoryTest.kt
index 7cbce71..1dbabba 100644
--- a/frontend/app/src/test/java/com/example/readability/ui/models/UserRepositoryTest.kt
+++ b/frontend/app/src/test/java/com/example/readability/ui/models/UserRepositoryTest.kt
@@ -1,5 +1,5 @@
package com.example.readability.ui.models
-import com.example.readability.data.book.BookDao
+import com.example.readability.data.NetworkStatusRepository
import com.example.readability.data.user.TokenResponse
import com.example.readability.data.user.UserDao
import com.example.readability.data.user.UserRemoteDataSource
@@ -24,7 +24,7 @@ class UserRepositoryTest {
// classes to be mocked
private lateinit var userRemoteDataSource: UserRemoteDataSource
private lateinit var userDao: UserDao
- private lateinit var bookDao: BookDao
+ private lateinit var networkStatusRepository: NetworkStatusRepository
// class under test
private lateinit var userRepository: UserRepository
@@ -34,11 +34,12 @@ class UserRepositoryTest {
Dispatchers.setMain(Dispatchers.Unconfined)
userRemoteDataSource = mock(UserRemoteDataSource::class.java)
userDao = mock(UserDao::class.java)
- bookDao = mock(BookDao::class.java)
+ networkStatusRepository = mock(NetworkStatusRepository::class.java)
+ `when`(networkStatusRepository.isConnected).thenReturn(true)
userRepository = UserRepository(
userRemoteDataSource = userRemoteDataSource,
userDao = userDao,
- bookDao = bookDao,
+ networkStatusRepository = networkStatusRepository,
)
}