From 23f09485b7d17d0ce2ad19751e97bc182898c423 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Thu, 23 Nov 2023 15:08:59 +0900 Subject: [PATCH 01/35] fix: quiz parsing error --- .../data/ai/QuizRemoteDataSource.kt | 47 +++++++++++++------ .../readability/data/ai/QuizRepository.kt | 29 +++++++----- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt index a7cfd9a..ef017ea 100644 --- a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt +++ b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt @@ -14,10 +14,12 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET import retrofit2.http.Query +import retrofit2.http.Streaming import javax.inject.Inject import javax.inject.Singleton interface QuizAPI { + @Streaming @GET("/quiz") fun getQuiz( @Query("book_id") bookId: Int, @@ -39,8 +41,9 @@ class QuizAPIProviderModule { enum class QuizResponseType { COUNT, - QUESTION, - ANSWER, + QUESTION_END, + ANSWER_END, + STRING, } data class QuizResponse( @@ -57,12 +60,14 @@ class QuizRemoteDataSource @Inject constructor( val response = quizAPI.getQuiz(bookId, progress, accessToken).execute() if (response.isSuccessful) { val responseBody = response.body() ?: throw Throwable("No body") + println("QuizRemoteDataSource: getQuiz: response.isSuccessful") responseBody.byteStream().bufferedReader().use { try { var content = "" var quizCount = 0 var receivingQuizCount = false - var receivingQuiz = true + var receivingQuiz = false + var receivingAnswer = false var quizCountContent = "" val updateContent = { token: String -> if (receivingQuizCount) { @@ -73,37 +78,51 @@ class QuizRemoteDataSource @Inject constructor( } while (currentCoroutineContext().isActive) { val line = it.readLine() ?: continue +// 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(":"))) + println( + "content: $content, quizCount: $quizCount, receivingQuizCount: $receivingQuizCount, receivingQuiz: $receivingQuiz", + ) 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..0ec0978 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 @@ -41,33 +41,38 @@ class QuizRepository @Inject constructor( _quizList.update { listOf() } _quizCount.update { 0 } try { + var receivingQuiz = true 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 { 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(response.data, "")) - } - } - } else { - _quizList.update { - it.toMutableList().apply { - set(response.intData - 1, Quiz(response.data, "")) + add(Quiz("", "")) } } } - } else if (response.type == QuizResponseType.ANSWER) { + } else { + val lastIndex = _quizList.value.lastIndex _quizList.update { it.toMutableList().apply { - set(response.intData - 1, Quiz(this[response.intData - 1].question, response.data)) + 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)) + } } } } } - } catch (e: Exception) { + } catch (e: Throwable) { + e.printStackTrace() return@withContext Result.failure(e) } if (isActive) { From 93b99f69e03c7c5c2c18ecd7a47bca99e94676e7 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Thu, 23 Nov 2023 15:14:51 +0900 Subject: [PATCH 02/35] feat: orientation fix, disable AI buttons to prevent unexpected summary and quiz. --- .../ui/screens/viewer/ViewerView.kt | 70 ++++++++++++++++--- 1 file changed, 59 insertions(+), 11 deletions(-) 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..cc63061 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,5 +1,9 @@ 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 androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -46,6 +50,7 @@ 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 @@ -72,6 +77,7 @@ import androidx.compose.ui.input.pointer.pointerInput 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.TextAlign @@ -111,6 +117,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) { @@ -211,6 +219,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 @@ -635,17 +663,37 @@ fun ViewerOverlay( 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") + // TODO: add ai status + if (false) { + RoundedRectFilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { onNavigateSummary() }, + enabled = false, + ) { + Text("Waiting for Summary and Quiz...") + } + } else if (pageIndex < 4) { + RoundedRectFilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { onNavigateSummary() }, + enabled = false, + ) { + Text("4 pages required for Summary and Quiz") + } + } else { + RoundedRectFilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { onNavigateSummary() }, + ) { + Text("Generate Summary") + } + RoundedRectFilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { onNavigateQuiz() }, + enabled = pageIndex > 3, + ) { + Text("Generate Quiz") + } } } Slider( From 7beee523a68dbca8f00c4500b9db10648f617f4f Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Thu, 23 Nov 2023 15:28:31 +0900 Subject: [PATCH 03/35] fix: empty message error, book id error, streaming annotation, prevent summary and quiz crash --- .../data/ai/SummaryRemoteDataSource.kt | 2 + .../readability/data/ai/SummaryRepository.kt | 2 +- .../data/book/BookRemoteDataSource.kt | 4 +- .../readability/data/book/BookRepository.kt | 5 ++- .../readability/data/user/UserException.kt | 7 +++- .../ui/screens/viewer/ViewerView.kt | 1 - .../ui/viewmodels/BookListViewModel.kt | 42 +++++++++++++------ 7 files changed, 42 insertions(+), 21 deletions(-) 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..da791af 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, 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..acd4512 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 @@ -26,7 +26,7 @@ class SummaryRepository @Inject constructor( if (!isActive) return@collect summary.value += response } - } catch (e: Exception) { + } catch (e: Throwable) { return@withContext Result.failure(e) } Result.success(Unit) 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 224fc41..54a7ecb 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 @@ -33,7 +33,7 @@ data class BookCardData( ) data class BookResponse( - val bookId: Int, + val book_id: Int, val title: String, val author: String, val content: String, @@ -99,7 +99,7 @@ class BookRemoteDataSource @Inject constructor( return Result.success( responseBody.books.map { BookCardData( - id = it.bookId, + id = it.book_id, title = it.title, author = it.author, progress = it.progress, 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..2c2afc6 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 @@ -45,8 +45,9 @@ class BookRepository @Inject constructor( val accessToken = userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException()) 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) @@ -66,7 +67,7 @@ class BookRepository @Inject constructor( } // delete books that are not in the list bookDao.getAll().forEach { book -> - if (it.find { book.bookId == it.id } == null) { + if (newBookList.find { book.bookId == it.id } == null) { bookDao.delete(book) newMap.remove(book.bookId) } 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/ui/screens/viewer/ViewerView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt index cc63061..04883cb 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 @@ -690,7 +690,6 @@ fun ViewerOverlay( RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), onClick = { onNavigateQuiz() }, - enabled = pageIndex > 3, ) { Text("Generate Quiz") } 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..1a2e803 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,30 @@ 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, + ) + 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 +61,11 @@ class BookListViewModel @Inject constructor( bookRepository.updateProgress(bookId, progress) } } + suspend fun updateBookList() { + viewModelScope.launch(Dispatchers.IO) { + bookRepository.refreshBookList().onFailure { + println("BookListViewModel: refreshBookList failed: ${it.message}") + } + } + } } From 1abb87ce8f62c1eebb98b8d2030fe005797f916d Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Thu, 23 Nov 2023 15:30:16 +0900 Subject: [PATCH 04/35] feat: pull to refresh book list --- .idea/swpp-2023-project-team-7.iml | 9 + frontend/.idea/deploymentTargetDropDown.xml | 17 ++ frontend/app/build.gradle.kts | 2 + .../ui/screens/book/BookListView.kt | 162 +++++++++++------- .../readability/ui/screens/book/BookScreen.kt | 3 + 5 files changed, 130 insertions(+), 63 deletions(-) create mode 100644 .idea/swpp-2023-project-team-7.iml create mode 100644 frontend/.idea/deploymentTargetDropDown.xml 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/frontend/.idea/deploymentTargetDropDown.xml b/frontend/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..9103fee --- /dev/null +++ b/frontend/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/build.gradle.kts b/frontend/app/build.gradle.kts index c284636..15cf7c2 100644 --- a/frontend/app/build.gradle.kts +++ b/frontend/app/build.gradle.kts @@ -105,6 +105,8 @@ dependencies { 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/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..0c342ab 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,14 @@ 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.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 @@ -54,9 +60,10 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun BookListView( bookCardDataList: List, @@ -66,10 +73,23 @@ fun BookListView( onNavigateSettings: () -> Unit = {}, onNavigateAddBook: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}, + onRefresh: suspend () -> Unit = {}, ) { val contentLoadScope = rememberCoroutineScope() val context = LocalContext.current + var refreshing by remember { mutableStateOf(false) } + val refreshScope = rememberCoroutineScope() + + fun refresh() = refreshScope.launch { + refreshing = true + onRefresh() + 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 +110,92 @@ 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, - ) - } - - 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() - } - } - }, - onLoadImage = { - onLoadImage(bookCardDataList[index].id) - }, - onProgressChanged = { id, progress -> - onProgressChanged(id, progress) - }, + 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, ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), + 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 -> + 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) + }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } } } + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = refreshing, + state = state + ) } } } 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 5e5b07e..844575b 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 @@ -43,6 +43,9 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onProgressChanged = { id, progress -> bookListViewModel.updateProgress(id, progress) }, + onRefresh = { + bookListViewModel.updateBookList() + }, ) } composableSharedAxis(BookScreens.AddBook.route, axis = SharedAxis.X) { From b14c39cfb64793ba2f82c8d85298dd2bace9d9ad Mon Sep 17 00:00:00 2001 From: Min Su Yoon Date: Sat, 25 Nov 2023 12:15:14 +0900 Subject: [PATCH 05/35] feat: add intermediate summary progress tracking --- backend/llama/preprocess_summary.py | 75 ++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/backend/llama/preprocess_summary.py b/backend/llama/preprocess_summary.py index 8172490..1c5c214 100644 --- a/backend/llama/preprocess_summary.py +++ b/backend/llama/preprocess_summary.py @@ -17,13 +17,6 @@ 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 @@ -60,6 +53,35 @@ def completion_with_backoff(**kwargs): 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 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 + +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() + +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() + +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) @@ -122,12 +144,11 @@ def split_list(input_list): return output_list -def reduce_multiple_summaries_to_one(summary_list, is_intermediate): +def reduce_multiple_summaries_to_one(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) if is_intermediate: @@ -147,6 +168,7 @@ def reduce_multiple_summaries_to_one(summary_list, is_intermediate): sys.stdout.write(delta_content) sys.stdout.flush() if finished: + update_num_current_inference(books_db, book_id) break except Exception as e: print(e) @@ -169,6 +191,7 @@ def reduce_multiple_summaries_to_one(summary_list, is_intermediate): sys.stdout.write(delta_content) sys.stdout.flush() if finished: + update_num_current_inference(books_db, book_id) break except Exception as e: print(e) @@ -183,17 +206,27 @@ def reduce_multiple_summaries_to_one(summary_list, is_intermediate): return reduced_summary -def reduce_summaries_list(summaries_list): +def reduce_summaries_list(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(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)) + update_num_total_inference(books_db, book_id, num_total_inferences) + for prompt in sliced_text_dict_list: response = "" for attempt in range(10): @@ -211,8 +244,10 @@ async def generate_summary_tree(book_id, story): sys.stdout.write(delta_content) sys.stdout.flush() if finished: + update_num_current_inference(books_db, book_id) break + first_level_summary = Summary(summary_content=response, start_idx=prompt["start_idx"], end_idx=prompt["end_idx"]) @@ -221,19 +256,11 @@ async def generate_summary_tree(book_id, story): print(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(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) @@ -281,4 +308,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 7228ca3090d406703376302e79e8ac3ac5c5a88c Mon Sep 17 00:00:00 2001 From: chaemin2001 Date: Sat, 25 Nov 2023 12:18:52 +0900 Subject: [PATCH 06/35] feat: add book delete request; fix: create new connection to database per function call --- backend/routers/book.py | 84 ++++++++++++++++++++++++++++------------ backend/routers/user.py | 85 ++++++++++++++++++++++++++--------------- 2 files changed, 115 insertions(+), 54 deletions(-) diff --git a/backend/routers/book.py b/backend/routers/book.py index e09a6f0..44c005f 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() @@ -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() @@ -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,21 @@ 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 {} \ No newline at end of file diff --git a/backend/routers/user.py b/backend/routers/user.py index d166eb0..4d00ec2 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 @@ -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() From f31f65b2fba3ebae9d2d56e9a39c99efe53a9735 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Sat, 25 Nov 2023 21:12:09 +0900 Subject: [PATCH 07/35] refactor: add empty space on booklist bottom --- .../example/readability/ui/screens/book/BookListView.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 0c342ab..6c7edc0 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 @@ -186,8 +186,17 @@ fun BookListView( HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), ) + if (index == bookCardDataList.size - 1){ + Column ( + modifier = Modifier.height(80.dp) + ){ + + } + } + } } + } } } From 8918e343e80eb02b86352abd0513a4ab80c5b961 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Sun, 26 Nov 2023 00:59:04 +0900 Subject: [PATCH 08/35] feat: add delete book in UI --- frontend/app/build.gradle.kts | 2 +- .../data/book/BookRemoteDataSource.kt | 21 +++++++++++++++++++ .../readability/data/book/BookRepository.kt | 20 ++++++++++++++++++ .../ui/components/BottomSheetView.kt | 7 ++++++- .../ui/screens/book/BookListView.kt | 10 +++++++-- .../readability/ui/screens/book/BookScreen.kt | 4 ++++ .../ui/viewmodels/BookListViewModel.kt | 5 +++++ 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/app/build.gradle.kts b/frontend/app/build.gradle.kts index 15cf7c2..655f9c1 100644 --- a/frontend/app/build.gradle.kts +++ b/frontend/app/build.gradle.kts @@ -99,7 +99,7 @@ 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") 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..55c61ec 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,6 +14,7 @@ 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 @@ -73,6 +74,12 @@ interface BookAPI { @POST("/book/add") fun addBook(@Query("access_token") accessToken: String, @Body book: AddBookRequest): Call + + @DELETE("/book/delete") + fun deleteBook( + @Query("book_id") bookId: Int, + @Query("access_token") accessToken: String, + ): Call } @InstallIn(SingletonComponent::class) @@ -161,4 +168,18 @@ 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) + } + } } 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 2c2afc6..996a97b 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 @@ -147,6 +147,26 @@ class BookRepository @Inject constructor( } } + suspend fun deleteBook(bookId: Int) : Result { + val accessToken = + userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException()) + return bookRemoteDataSource.deleteBook(bookId, accessToken).fold(onSuccess = { + val book = bookMap.value[bookId] ?: return Result.failure(UserNotSignedInException()) + + bookDao.delete(book) + bookMap.update { + val newMap = it.toMutableMap() + newMap.remove(bookId) + newMap + } + + refreshBookList() + + }, onFailure = { + Result.failure(it) + }) + } + suspend fun clearBooks() { bookDao.deleteAll() bookMap.value = mutableMapOf() 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..47823aa 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 @@ -37,7 +37,7 @@ 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: (Int) -> Unit = {}) { val modalBottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, ) @@ -59,6 +59,7 @@ fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressCha } }, onProgressChanged = onProgressChanged, + onBookDeleted = onBookDeleted, ) } } @@ -94,6 +95,7 @@ fun BottomSheetContent( bookCardData: BookCardData, onDismiss: () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, + onBookDeleted: (Int) -> Unit = {}, ) { Column( modifier = modifier, @@ -128,6 +130,7 @@ fun BottomSheetContent( BookAction.DeleteFromMyLibrary -> { // TODO + onBookDeleted(bookCardData.id) onDismiss() } } @@ -209,6 +212,8 @@ enum class BookAction { ClearProgress, MarkAsCompleted, DeleteFromMyLibrary, + + } @Composable 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 6c7edc0..59e6a37 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 @@ -70,6 +70,7 @@ fun BookListView( onLoadImage: suspend (id: Int) -> Result = { Result.success(Unit) }, onLoadContent: suspend (id: Int) -> Result = { Result.success(Unit) }, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, + onBookDeleted: (Int) -> Unit = {}, onNavigateSettings: () -> Unit = {}, onNavigateAddBook: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}, @@ -182,12 +183,15 @@ fun BookListView( onProgressChanged = { id, progress -> onProgressChanged(id, progress) }, + onBookDeleted ={ id -> + onBookDeleted(id) + }, ) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), ) if (index == bookCardDataList.size - 1){ - Column ( + LazyColumn ( modifier = Modifier.height(80.dp) ){ @@ -236,6 +240,7 @@ fun BookCard( onClick: () -> Unit = {}, onLoadImage: suspend () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, + onBookDeleted: (Int) -> Unit = {} ) { var showSheet by remember { mutableStateOf(false) } var loadingImage by remember { mutableStateOf(false) } @@ -244,7 +249,8 @@ fun BookCard( if (showSheet) { BottomSheet(bookCardData = bookCardData, onDismiss = { showSheet = false - }, onProgressChanged = onProgressChanged) + }, onProgressChanged = onProgressChanged, + onBookDeleted = onBookDeleted) } LaunchedEffect(bookCardData.coverImage) { 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 844575b..9acb911 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 @@ -43,6 +43,10 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onProgressChanged = { id, progress -> bookListViewModel.updateProgress(id, progress) }, + onBookDeleted = {id -> + bookListViewModel.deleteBook(id) + + }, onRefresh = { bookListViewModel.updateBookList() }, 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 1a2e803..ac5045a 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 @@ -61,6 +61,11 @@ class BookListViewModel @Inject constructor( bookRepository.updateProgress(bookId, progress) } } + fun deleteBook(bookId: Int) { + viewModelScope.launch(Dispatchers.IO) { + bookRepository.deleteBook(bookId) + } + } suspend fun updateBookList() { viewModelScope.launch(Dispatchers.IO) { bookRepository.refreshBookList().onFailure { From 5146fd7f997a260f2b901ab86f8067d160fa0e4b Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Sun, 26 Nov 2023 01:42:20 +0900 Subject: [PATCH 09/35] fix: file type error, summary concatenated error, button unclickable due to message --- .../readability/data/ai/SummaryRepository.kt | 1 + .../ui/screens/book/AddBookView.kt | 59 ++++++++++++------- 2 files changed, 40 insertions(+), 20 deletions(-) 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 acd4512..bf5f7ff 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 @@ -17,6 +17,7 @@ class SummaryRepository @Inject constructor( val summary = MutableStateFlow("") suspend fun getSummary(bookId: Int, progress: Double): Result { + summary.value = "" return withContext(Dispatchers.IO) { val accessToken = userRepository.getAccessToken() ?: return@withContext Result.failure( UserNotSignedInException(), 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..1d179b5 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 @@ -103,7 +104,6 @@ fun AddBookView( val scope = rememberCoroutineScope() - val snackbarHost = LocalSnackbarHost.current val imageSelectLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), @@ -322,27 +322,46 @@ 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, + " Please upload txt file ", + Toast.LENGTH_SHORT, + ).show() + }else if(fileName.substring(fileName.length - 4, fileName.length - 1) != ".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 = imageString, + ), + ).onSuccess { + onBookUploaded() + Toast.makeText( + context, + "Book is successfully uploaded", + Toast.LENGTH_SHORT, + ).show() + }.onFailure { + loading = false + Toast.makeText( + context, + "Unknown error happened while uploading book", + Toast.LENGTH_SHORT, + ).show() + } } } + }, ) { Text(text = "Add Book") From 7eca4941bd292039478b62f8ec186eb8cacb3a67 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Mon, 27 Nov 2023 20:14:24 +0900 Subject: [PATCH 10/35] fix: bookList not deleted when sign out --- .../java/com/example/readability/data/user/UserRepository.kt | 4 ---- .../example/readability/ui/screens/settings/SettingsScreen.kt | 3 +++ .../example/readability/ui/viewmodels/BookListViewModel.kt | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) 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..e7243f9 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,5 @@ package com.example.readability.data.user -import com.example.readability.data.book.BookDao import kotlinx.coroutines.flow.first import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -10,7 +9,6 @@ import javax.inject.Singleton class UserRepository @Inject constructor( private val userRemoteDataSource: UserRemoteDataSource, private val userDao: UserDao, - private val bookDao: BookDao, ) { val user = userDao.get() suspend fun signIn(email: String, password: String): Result { @@ -87,8 +85,6 @@ class UserRepository @Inject constructor( } 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/ui/screens/settings/SettingsScreen.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsScreen.kt index 0aa463d..1b7acfa 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 @@ -9,6 +9,7 @@ import androidx.navigation.compose.NavHost 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.BookListViewModel import com.example.readability.ui.viewmodels.SettingViewModel import com.example.readability.ui.viewmodels.UserViewModel import kotlinx.coroutines.Dispatchers @@ -36,11 +37,13 @@ fun SettingsScreen( NavHost(navController = navController, startDestination = startDestination) { composableSharedAxis(SettingsScreens.Settings.route, axis = SharedAxis.X) { val userViewModel: UserViewModel = hiltViewModel() + val bookListViewModel: BookListViewModel = hiltViewModel() // TODO: put correct user info to SettingsView SettingsView( onSignOut = { withContext(Dispatchers.IO) { userViewModel.signOut() + bookListViewModel.clearBookList() } Result.success(Unit) }, 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 ac5045a..7392e82 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 @@ -73,4 +73,8 @@ class BookListViewModel @Inject constructor( } } } + + suspend fun clearBookList() { + bookRepository.clearBooks() + } } From 88af8846e292bcc8d5611d7d5376f14d490843d9 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Mon, 27 Nov 2023 21:12:27 +0900 Subject: [PATCH 11/35] feat: add delete book alert --- .../ui/components/BottomSheetView.kt | 84 +++++++++++++++++-- .../ui/screens/book/AddBookView.kt | 3 +- .../ui/screens/book/BookListView.kt | 14 +++- .../readability/ui/screens/book/BookScreen.kt | 5 +- .../ui/viewmodels/BookListViewModel.kt | 2 +- 5 files changed, 94 insertions(+), 14 deletions(-) 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 47823aa..84130e9 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 @@ -12,19 +13,30 @@ 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.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,13 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressChanged: (Int, Double) -> Unit, onBookDeleted: (Int) -> Unit = {}) { +fun BottomSheet( + bookCardData: BookCardData, + onDismiss: () -> Unit, + onProgressChanged: (Int, Double) -> Unit, + onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, + onNavigateBookList: () -> Unit = {}, +) { val modalBottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, ) @@ -47,8 +65,7 @@ fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressCha onDismissRequest = { onDismiss() }, sheetState = modalBottomSheetState, dragHandle = { BottomSheetDefaults.DragHandle() }, - - ) { + ) { BottomSheetContent( modifier = Modifier.fillMaxWidth(), bookCardData = bookCardData, @@ -60,6 +77,7 @@ fun BottomSheet(bookCardData: BookCardData, onDismiss: () -> Unit, onProgressCha }, onProgressChanged = onProgressChanged, onBookDeleted = onBookDeleted, + onNavigateBookList = onNavigateBookList, ) } } @@ -95,8 +113,63 @@ fun BottomSheetContent( bookCardData: BookCardData, onDismiss: () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, - onBookDeleted: (Int) -> Unit = {}, + onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, + onNavigateBookList: () -> 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() + 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, @@ -130,8 +203,7 @@ fun BottomSheetContent( BookAction.DeleteFromMyLibrary -> { // TODO - onBookDeleted(bookCardData.id) - onDismiss() + showDeleteBookDialog = true } } }) 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 1d179b5..39cadd2 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 @@ -49,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 @@ -328,7 +327,7 @@ fun AddBookView( " Please upload txt file ", Toast.LENGTH_SHORT, ).show() - }else if(fileName.substring(fileName.length - 4, fileName.length - 1) != ".txt"){ + }else if(fileName.substring(fileName.length - 4, fileName.length) != ".txt"){ Toast.makeText( context, "Invalid file format.\nOnly txt file supported", 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 59e6a37..4872eea 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 @@ -70,10 +70,11 @@ fun BookListView( onLoadImage: suspend (id: Int) -> Result = { Result.success(Unit) }, onLoadContent: suspend (id: Int) -> Result = { Result.success(Unit) }, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, - onBookDeleted: (Int) -> Unit = {}, + onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, onNavigateSettings: () -> Unit = {}, onNavigateAddBook: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}, + onNavigateBookList: () -> Unit = {}, onRefresh: suspend () -> Unit = {}, ) { val contentLoadScope = rememberCoroutineScope() @@ -183,9 +184,12 @@ fun BookListView( onProgressChanged = { id, progress -> onProgressChanged(id, progress) }, - onBookDeleted ={ id -> + onBookDeleted = { id -> onBookDeleted(id) }, + onNavigateBookList = { + onNavigateBookList() + }, ) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), @@ -240,7 +244,8 @@ fun BookCard( onClick: () -> Unit = {}, onLoadImage: suspend () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, - onBookDeleted: (Int) -> Unit = {} + onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, + onNavigateBookList: () -> Unit = {}, ) { var showSheet by remember { mutableStateOf(false) } var loadingImage by remember { mutableStateOf(false) } @@ -250,7 +255,8 @@ fun BookCard( BottomSheet(bookCardData = bookCardData, onDismiss = { showSheet = false }, onProgressChanged = onProgressChanged, - onBookDeleted = onBookDeleted) + onBookDeleted = onBookDeleted, + onNavigateBookList = onNavigateBookList,) } LaunchedEffect(bookCardData.coverImage) { 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 9acb911..2191ee5 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 @@ -40,12 +40,15 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onNavigateViewer = { id -> onNavigateViewer(id) }, + onNavigateBookList = { + navController.popBackStack() + }, onProgressChanged = { id, progress -> bookListViewModel.updateProgress(id, progress) }, onBookDeleted = {id -> bookListViewModel.deleteBook(id) - + Result.success(Unit) }, onRefresh = { bookListViewModel.updateBookList() 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 7392e82..2c85fce 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 @@ -61,7 +61,7 @@ class BookListViewModel @Inject constructor( bookRepository.updateProgress(bookId, progress) } } - fun deleteBook(bookId: Int) { + suspend fun deleteBook(bookId: Int) { viewModelScope.launch(Dispatchers.IO) { bookRepository.deleteBook(bookId) } From 63c0fc7455860597c6ea81970b31f4d73116c3c8 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Tue, 28 Nov 2023 00:04:27 +0900 Subject: [PATCH 12/35] feat: save progress, basic ai status; refactor: unnecessary UI, change to Toast --- frontend/app/src/main/AndroidManifest.xml | 2 +- .../readability/data/book/BookDatabase.kt | 8 +++ .../data/book/BookRemoteDataSource.kt | 27 +++++++++ .../readability/data/book/BookRepository.kt | 20 ++++++- .../readability/ui/screens/auth/IntroView.kt | 16 +----- .../readability/ui/screens/auth/SignInView.kt | 18 +++--- .../ui/screens/settings/SettingsView.kt | 44 --------------- .../ui/screens/settings/ViewerView.kt | 56 ------------------- .../ui/screens/viewer/ViewerView.kt | 16 +++++- .../ui/viewmodels/ViewerViewModel.kt | 8 +++ 10 files changed, 88 insertions(+), 127 deletions(-) diff --git a/frontend/app/src/main/AndroidManifest.xml b/frontend/app/src/main/AndroidManifest.xml index 2ea5d74..7123626 100644 --- a/frontend/app/src/main/AndroidManifest.xml +++ b/frontend/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ + @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, @@ -182,4 +195,18 @@ class BookRemoteDataSource @Inject constructor( 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 996a97b..4b501e9 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 @@ -60,6 +60,8 @@ class BookRepository @Inject constructor( progress = book.progress, coverImage = book.coverImage, content = book.content, + numCurrentInference = book.numCurrentInference, + numTotalInference = book.numTotalInference, ) bookDao.insert(bookObject) newMap[book.id] = bookObject @@ -138,13 +140,20 @@ 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) } } + return bookRemoteDataSource.updateProgress(bookId, progress, accessToken).fold(onSuccess = { + refreshBookList() + }, onFailure = { + Result.failure(it) + }) } suspend fun deleteBook(bookId: Int) : Result { @@ -171,4 +180,13 @@ class BookRepository @Inject constructor( bookDao.deleteAll() bookMap.value = mutableMapOf() } + + fun updateAIStatus(bookId: Int, aiStatus: Double) { + bookDao.getNumTotalInference(bookId) ?: return + bookMap.update { + it.toMutableMap().apply { +// this[bookId] = this[bookId]!!.copy(aiStatus = aiStatus) + } + } + } } 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..63ceed1 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 @@ -71,11 +71,11 @@ fun SignInView( 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 +86,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 +165,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/settings/SettingsView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/SettingsView.kt index 58efdfc..5cef0b1 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 @@ -48,7 +48,6 @@ fun SettingsView( onBack: () -> Unit = {}, onNavigatePasswordCheck: () -> Unit = {}, onNavigateViewer: () -> Unit = {}, - onNavigateAbout: (type: String) -> Unit = {}, onNavigateIntro: () -> Unit = {}, ) { val context = LocalContext.current @@ -110,49 +109,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) 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..7def7b9 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,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -45,7 +44,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 +59,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 @@ -364,58 +360,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/ViewerView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/ViewerView.kt index 04883cb..1efa350 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 @@ -593,6 +593,11 @@ fun ViewerOverlay( (pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1, ) +// var aiStatus = 0 +// Handler(Looper.getMainLooper()).postDelayed({ +// println("aiStatus = ${bookData?.numCurrentInference} / ${bookData?.numTotalInference}") +// aiStatus = if (bookData?.numTotalInference == 0) 0 else bookData?.numCurrentInference!! / bookData.numTotalInference +// }, 10000) Column( modifier = modifier, @@ -664,13 +669,14 @@ fun ViewerOverlay( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { // TODO: add ai status +// if (bookData?.numTotalInference == 0) { if (false) { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), onClick = { onNavigateSummary() }, enabled = false, ) { - Text("Waiting for Summary and Quiz...") + Text("Too short to generate Summary and Quiz") } } else if (pageIndex < 4) { RoundedRectFilledTonalButton( @@ -680,6 +686,14 @@ fun ViewerOverlay( ) { Text("4 pages required for Summary and Quiz") } +// } else if (aiStatus < 1) { +// RoundedRectFilledTonalButton( +// modifier = Modifier.weight(1f), +// onClick = { onNavigateSummary() }, +// enabled = false, +// ) { +// Text("Waiting for Summary and Quiz...(${aiStatus * 100 / 1}%)") +// } } else { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), 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..c97db1e 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 @@ -44,6 +44,14 @@ class ViewerViewModel @Inject constructor( } } + fun setAIStatus(bookId: Int, aiStatus: Double) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + bookRepository.updateAIStatus(bookId, aiStatus) + } + } + } + fun getBookData(id: Int) = bookRepository.getBook(id) fun drawPage(bookId: Int, canvas: NativeCanvas, page: Int, isDarkMode: Boolean) { From a4678dd2adc7039fbef5fa96337905ab317070e5 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Tue, 28 Nov 2023 02:05:46 +0900 Subject: [PATCH 13/35] feat: handle no internet connection, refactor: delete unnecessary UI --- frontend/app/src/main/AndroidManifest.xml | 1 + .../readability/ui/screens/auth/AuthScreen.kt | 8 ----- .../readability/ui/screens/auth/EmailView.kt | 8 ----- .../readability/ui/screens/auth/SignInView.kt | 1 - .../ui/screens/book/AddBookView.kt | 4 +-- .../ui/screens/book/BookListView.kt | 18 +++++++++-- .../readability/ui/screens/book/BookScreen.kt | 16 ++++++++-- .../ui/screens/settings/AccountView.kt | 30 +++++-------------- .../ui/screens/settings/SettingsScreen.kt | 3 +- .../ui/screens/viewer/ViewerView.kt | 16 +++++++++- .../ui/viewmodels/UserViewModel.kt | 1 + 11 files changed, 56 insertions(+), 50 deletions(-) diff --git a/frontend/app/src/main/AndroidManifest.xml b/frontend/app/src/main/AndroidManifest.xml index 7123626..4e2e502 100644 --- a/frontend/app/src/main/AndroidManifest.xml +++ b/frontend/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + Unit = {}, onNavigateSignIn: (String) -> Unit = {}, onNavigateSignUp: () -> Unit = {}, - onNavigateForgotPassword: () -> Unit = {}, ) { var showError by remember { mutableStateOf(false) } var emailError by remember { mutableStateOf(false) } @@ -147,13 +146,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/SignInView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/auth/SignInView.kt index 63ceed1..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 @@ -64,7 +64,6 @@ 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) } 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 39cadd2..277465e 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 @@ -352,15 +352,15 @@ fun AddBookView( ).show() }.onFailure { loading = false + val message = if (it.message?.isEmpty() == false) it.message!! else "Unknown error happened while uploading book" Toast.makeText( context, - "Unknown error happened while uploading book", + message, Toast.LENGTH_SHORT, ).show() } } } - }, ) { Text(text = "Add Book") 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 4872eea..9da1054 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 @@ -1,6 +1,9 @@ package com.example.readability.ui.screens.book import BottomSheet +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkInfo import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image @@ -79,15 +82,24 @@ fun BookListView( ) { val contentLoadScope = rememberCoroutineScope() val context = LocalContext.current + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork: NetworkInfo? = cm.activeNetworkInfo var refreshing by remember { mutableStateOf(false) } val refreshScope = rememberCoroutineScope() fun refresh() = refreshScope.launch { refreshing = true - onRefresh() - delay(1000) // TODO - refreshing = false + if (activeNetwork?.isConnectedOrConnecting == null || !activeNetwork.isConnectedOrConnecting) { + delay(700) + refreshing = false + delay(200) + Toast.makeText(context, "No Internet Connection", Toast.LENGTH_SHORT).show() + } else { + onRefresh() + delay(1000) // TODO + refreshing = false + } } val state = rememberPullRefreshState(refreshing, ::refresh) 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 2191ee5..a952546 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 @@ -1,8 +1,12 @@ package com.example.readability.ui.screens.book +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @@ -21,6 +25,9 @@ sealed class BookScreens(val route: String) { @Composable fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}) { val navController = rememberNavController() + val context = LocalContext.current + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + NavHost(navController = navController, startDestination = BookScreens.BookList.route) { composableSharedAxis(BookScreens.BookList.route, axis = SharedAxis.X) { val bookListViewModel: BookListViewModel = hiltViewModel() @@ -61,8 +68,13 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onBack = { navController.popBackStack() }, onBookUploaded = { navController.popBackStack() }, onAddBookClicked = { - withContext(Dispatchers.IO) { - addBookViewModel.addBook(it) + val activeNetwork: NetworkInfo? = cm.activeNetworkInfo + if (activeNetwork != null && activeNetwork.isConnectedOrConnecting) { + withContext(Dispatchers.IO) { + addBookViewModel.addBook(it) + } + } else { + Result.failure(Exception("No internet connection")) } }, ) 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..d31d207 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 @@ -193,22 +193,6 @@ fun AccountView( 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") Spacer(modifier = Modifier.height(16.dp)) @@ -276,13 +260,13 @@ fun AccountView( keyboardActions = KeyboardActions(onDone = { submit() }), ) Spacer(modifier = Modifier.height(16.dp)) - RoundedRectButton( - modifier = Modifier.padding(16.dp), - loading = updatePersonalInfoLoading, - onClick = { submit() }, - ) { - Text(text = "Update Personal Info") - } +// RoundedRectButton( +// modifier = Modifier.padding(16.dp), +// loading = updatePersonalInfoLoading, +// onClick = { submit() }, +// ) { +// Text(text = "Update Personal Info") +// } SettingTitle(modifier = Modifier.padding(top = 24.dp), text = "Actions") ListItem( modifier = Modifier.clickable { 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 1b7acfa..c194e5d 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 @@ -48,9 +48,8 @@ fun SettingsScreen( Result.success(Unit) }, onBack = { onBack() }, - onNavigatePasswordCheck = { navController.navigate(SettingsScreens.PasswordCheck.route) }, + onNavigatePasswordCheck = { navController.navigate(SettingsScreens.Account.route) }, onNavigateViewer = { navController.navigate(SettingsScreens.Viewer.route) }, - onNavigateAbout = { navController.navigate(SettingsScreens.About.createRoute(it)) }, onNavigateIntro = { onNavigateAuth() }, ) } 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 1efa350..274f67d 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 @@ -5,6 +5,8 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.NetworkInfo import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState @@ -593,6 +595,9 @@ fun ViewerOverlay( (pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1, ) + val context = LocalContext.current + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork: NetworkInfo? = cm.activeNetworkInfo // var aiStatus = 0 // Handler(Looper.getMainLooper()).postDelayed({ // println("aiStatus = ${bookData?.numCurrentInference} / ${bookData?.numTotalInference}") @@ -668,9 +673,18 @@ fun ViewerOverlay( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { + if (activeNetwork?.isConnectedOrConnecting == null || !activeNetwork.isConnectedOrConnecting) { + RoundedRectFilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { onNavigateSummary() }, + enabled = false, + ) { + Text("No Internet Connection") + } + } // TODO: add ai status // if (bookData?.numTotalInference == 0) { - if (false) { + else if (false) { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), onClick = { onNavigateSummary() }, 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..763e60c 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 @@ -16,4 +16,5 @@ 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() } From 9da73bd430d1e2e42e93967b907b96a91d7c8ae2 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Tue, 28 Nov 2023 15:34:38 +0900 Subject: [PATCH 14/35] fix: delayed progress upload, fix summary / quiz not stopping when new request came --- frontend/.idea/deploymentTargetDropDown.xml | 17 ------ .../data/ai/QuizRemoteDataSource.kt | 5 +- .../readability/data/ai/QuizRepository.kt | 61 ++++++++++++------- .../data/ai/SummaryRemoteDataSource.kt | 2 +- .../readability/data/ai/SummaryRepository.kt | 27 ++++++-- .../data/book/BookRemoteDataSource.kt | 5 +- .../readability/data/book/BookRepository.kt | 25 +++++--- .../ui/components/BottomSheetView.kt | 4 +- .../readability/ui/screens/auth/EmailView.kt | 6 +- .../ui/screens/book/AddBookView.kt | 13 ++-- .../ui/screens/book/BookListView.kt | 25 ++++---- .../readability/ui/screens/book/BookScreen.kt | 2 +- .../ui/screens/settings/AccountView.kt | 1 - .../readability/ui/screens/viewer/QuizView.kt | 2 +- .../ui/screens/viewer/ViewerView.kt | 4 +- 15 files changed, 108 insertions(+), 91 deletions(-) delete mode 100644 frontend/.idea/deploymentTargetDropDown.xml diff --git a/frontend/.idea/deploymentTargetDropDown.xml b/frontend/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index 9103fee..0000000 --- a/frontend/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt index ef017ea..7edd43a 100644 --- a/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt +++ b/frontend/app/src/main/java/com/example/readability/data/ai/QuizRemoteDataSource.kt @@ -77,16 +77,13 @@ class QuizRemoteDataSource @Inject constructor( } } while (currentCoroutineContext().isActive) { - val line = it.readLine() ?: continue + val line = it.readLine() ?: break // println(line) if (line.startsWith("data:")) { var token = line.substring(6) if (token.isEmpty()) token = "\n" if (token.contains(":")) { updateContent(token.substring(0, token.indexOf(":"))) - println( - "content: $content, quizCount: $quizCount, receivingQuizCount: $receivingQuizCount, receivingQuiz: $receivingQuiz", - ) if (quizCount == 0) { receivingQuizCount = true } else { 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 0ec0978..749cc17 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 @@ -2,11 +2,14 @@ package com.example.readability.data.ai 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 @@ -29,6 +32,8 @@ class QuizRepository @Inject constructor( 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() @@ -41,36 +46,46 @@ class QuizRepository @Inject constructor( _quizList.update { listOf() } _quizCount.update { 0 } 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("", "")) + lastQuizLoadJob?.cancel() + lastQuizLoadJob = quizLoadScope.launch { + 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 { - 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 { + 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), + ) + } } } } } } + lastQuizLoadJob?.join() } catch (e: Throwable) { e.printStackTrace() return@withContext Result.failure(e) 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 da791af..bd02505 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 @@ -50,7 +50,7 @@ class SummaryRemoteDataSource @Inject constructor( responseBody.byteStream().bufferedReader().use { try { while (currentCoroutineContext().isActive) { - val line = it.readLine() ?: continue + val line = it.readLine() ?: break if (line.startsWith("data:")) { emit(line.substring(6)) } 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 bf5f7ff..c385263 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 @@ -2,9 +2,13 @@ package com.example.readability.data.ai 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 @@ -14,23 +18,36 @@ class SummaryRepository @Inject constructor( private val summaryRemoteDataSource: SummaryRemoteDataSource, private val userRepository: UserRepository, ) { - 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 { - summary.value = "" 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() + lastSummaryLoadJob = summaryLoadScope.launch { + _summary.value = "" + summaryRemoteDataSource.getSummary(bookId, progress, accessToken).collect { response -> + if (!isActive) return@collect + _summary.value += response + } } + lastSummaryLoadJob?.join() } 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/BookRemoteDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/book/BookRemoteDataSource.kt index 5a4f5a5..ee1f44f 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 @@ -89,10 +89,7 @@ interface BookAPI { ): Call @DELETE("/book/delete") - fun deleteBook( - @Query("book_id") bookId: Int, - @Query("access_token") accessToken: String, - ): Call + fun deleteBook(@Query("book_id") bookId: Int, @Query("access_token") accessToken: String): Call } @InstallIn(SingletonComponent::class) 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 4b501e9..7fa3513 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 @@ -2,10 +2,15 @@ package com.example.readability.data.book 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 javax.inject.Inject @@ -18,6 +23,8 @@ class BookRepository @Inject constructor( private val userRepository: UserRepository, ) { private val bookMap = MutableStateFlow(mutableMapOf()) + private val progressUpdateScope = CoroutineScope(Dispatchers.IO) + private var progressUpdateJob: Job? = null val bookList = bookMap.map { it.values.toList() @@ -149,14 +156,19 @@ class BookRepository @Inject constructor( this[bookId] = this[bookId]!!.copy(progress = progress) } } - return bookRemoteDataSource.updateProgress(bookId, progress, accessToken).fold(onSuccess = { - refreshBookList() - }, onFailure = { - Result.failure(it) - }) + + progressUpdateJob?.cancel() + progressUpdateJob = progressUpdateScope.launch { + delay(100L) + if (!isActive) { + return@launch + } + bookRemoteDataSource.updateProgress(bookId, progress, accessToken) + } + return Result.success(Unit) } - suspend fun deleteBook(bookId: Int) : Result { + suspend fun deleteBook(bookId: Int): Result { val accessToken = userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException()) return bookRemoteDataSource.deleteBook(bookId, accessToken).fold(onSuccess = { @@ -170,7 +182,6 @@ class BookRepository @Inject constructor( } refreshBookList() - }, onFailure = { Result.failure(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 84130e9..b0ec616 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 @@ -65,7 +65,7 @@ fun BottomSheet( onDismissRequest = { onDismiss() }, sheetState = modalBottomSheetState, dragHandle = { BottomSheetDefaults.DragHandle() }, - ) { + ) { BottomSheetContent( modifier = Modifier.fillMaxWidth(), bookCardData = bookCardData, @@ -284,8 +284,6 @@ enum class BookAction { ClearProgress, MarkAsCompleted, DeleteFromMyLibrary, - - } @Composable 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 0413072..50f1fa5 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 @@ -52,11 +52,7 @@ private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@(.+)\$") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmailView( - onBack: () -> Unit = {}, - onNavigateSignIn: (String) -> Unit = {}, - onNavigateSignUp: () -> Unit = {}, -) { +fun EmailView(onBack: () -> Unit = {}, onNavigateSignIn: (String) -> Unit = {}, onNavigateSignUp: () -> Unit = {}) { var showError by remember { mutableStateOf(false) } var emailError by remember { mutableStateOf(false) } var email by remember { mutableStateOf("") } 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 277465e..6d0ed3d 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 @@ -103,7 +103,6 @@ fun AddBookView( val scope = rememberCoroutineScope() - val imageSelectLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), ) { uri: Uri? -> @@ -321,19 +320,19 @@ fun AddBookView( modifier = Modifier.fillMaxWidth(), loading = loading, onClick = { - if(content.isEmpty()){ + if (content.isEmpty()) { Toast.makeText( context, " Please upload txt file ", Toast.LENGTH_SHORT, ).show() - }else if(fileName.substring(fileName.length - 4, fileName.length) != ".txt"){ + } 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{ + } else { loading = true scope.launch { onAddBookClicked( @@ -352,7 +351,11 @@ fun AddBookView( ).show() }.onFailure { loading = false - val message = if (it.message?.isEmpty() == false) it.message!! else "Unknown error happened while uploading book" + val message = if (it.message?.isEmpty() == false) { + it.message!! + } else { + "Unknown error happened while uploading book" + } Toast.makeText( context, message, 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 9da1054..570dfeb 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 @@ -206,24 +206,21 @@ fun BookListView( HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), ) - if (index == bookCardDataList.size - 1){ - LazyColumn ( - modifier = Modifier.height(80.dp) - ){ - + if (index == bookCardDataList.size - 1) { + LazyColumn( + modifier = Modifier.height(80.dp), + ) { } } - } } - } } } PullRefreshIndicator( modifier = Modifier.align(Alignment.TopCenter), refreshing = refreshing, - state = state + state = state, ) } } @@ -264,11 +261,15 @@ fun BookCard( val imageLoadScope = rememberCoroutineScope() if (showSheet) { - BottomSheet(bookCardData = bookCardData, onDismiss = { - showSheet = false - }, onProgressChanged = onProgressChanged, + BottomSheet( + bookCardData = bookCardData, + onDismiss = { + showSheet = false + }, + onProgressChanged = onProgressChanged, onBookDeleted = onBookDeleted, - onNavigateBookList = onNavigateBookList,) + onNavigateBookList = onNavigateBookList, + ) } LaunchedEffect(bookCardData.coverImage) { 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 a952546..0877829 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 @@ -53,7 +53,7 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onProgressChanged = { id, progress -> bookListViewModel.updateProgress(id, progress) }, - onBookDeleted = {id -> + onBookDeleted = { id -> bookListViewModel.deleteBook(id) Result.success(Unit) }, 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 d31d207..e495114 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 @@ -53,7 +53,6 @@ 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 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..90b59f6 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 @@ -126,7 +126,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, 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 274f67d..62c1ed5 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 @@ -495,11 +495,11 @@ fun BookPage( ((pageSplitData?.width ?: 0) + padding * 2) / ((pageSplitData?.height ?: 0) + padding * 2) Column( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, ) { Box( - modifier = modifier + modifier = Modifier .fillMaxWidth() .aspectRatio(ratio) .background(MaterialTheme.colorScheme.background), From b5c4b9af86877dfec38fbcf87f524cf186512298 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Tue, 28 Nov 2023 16:38:14 +0900 Subject: [PATCH 15/35] refactor: move network check codes to NetworkStatusRepository, add check to all network functions --- .../data/NetworkStatusDataSource.kt | 41 +++++++++++++++++++ .../data/NetworkStatusRepository.kt | 13 ++++++ .../readability/data/ai/QuizRepository.kt | 5 +++ .../readability/data/ai/SummaryRepository.kt | 5 +++ .../readability/data/book/BookRepository.kt | 19 ++++++++- .../readability/data/user/UserRepository.kt | 14 +++++++ .../ui/screens/book/BookListView.kt | 20 +++------ .../readability/ui/screens/book/BookScreen.kt | 10 +---- .../ui/screens/viewer/ViewerScreen.kt | 4 ++ .../ui/screens/viewer/ViewerView.kt | 10 ++--- .../ui/viewmodels/BookListViewModel.kt | 10 ++--- .../ui/viewmodels/NetworkStatusViewModel.kt | 15 +++++++ 12 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 frontend/app/src/main/java/com/example/readability/data/NetworkStatusDataSource.kt create mode 100644 frontend/app/src/main/java/com/example/readability/data/NetworkStatusRepository.kt create mode 100644 frontend/app/src/main/java/com/example/readability/ui/viewmodels/NetworkStatusViewModel.kt diff --git a/frontend/app/src/main/java/com/example/readability/data/NetworkStatusDataSource.kt b/frontend/app/src/main/java/com/example/readability/data/NetworkStatusDataSource.kt new file mode 100644 index 0000000..e85a435 --- /dev/null +++ b/frontend/app/src/main/java/com/example/readability/data/NetworkStatusDataSource.kt @@ -0,0 +1,41 @@ +package com.example.readability.data + +import android.content.Context +import android.net.ConnectivityManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkStatusDataSource @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // a MutableStateFlow which represents the current network status + // It is automatically updated when the network becomes available. + // However, it is not automatically updated when the network becomes unavailable. + val connectedState = MutableStateFlow(false) + val isConnected: Boolean + get() { + val result = connectivityManager.activeNetworkInfo?.isConnected ?: false + connectedState.value = result + return result + } + + init { + connectedState.value = isConnected + connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + connectedState.value = true + } + + // This is not actually called? + override fun onUnavailable() { + super.onUnavailable() + connectedState.value = false + } + }) + } +} diff --git a/frontend/app/src/main/java/com/example/readability/data/NetworkStatusRepository.kt b/frontend/app/src/main/java/com/example/readability/data/NetworkStatusRepository.kt new file mode 100644 index 0000000..98123f1 --- /dev/null +++ b/frontend/app/src/main/java/com/example/readability/data/NetworkStatusRepository.kt @@ -0,0 +1,13 @@ +package com.example.readability.data + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkStatusRepository @Inject constructor( + private val networkStatusDataSource: NetworkStatusDataSource, +) { + val isConnected: Boolean + get() = networkStatusDataSource.isConnected + val connectedState = networkStatusDataSource.connectedState +} 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 749cc17..35bde32 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,5 +1,6 @@ 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 @@ -28,6 +29,7 @@ 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) @@ -39,6 +41,9 @@ class QuizRepository @Inject constructor( 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() 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 c385263..bc49940 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,5 +1,6 @@ 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 @@ -17,6 +18,7 @@ import javax.inject.Singleton class SummaryRepository @Inject constructor( private val summaryRemoteDataSource: SummaryRemoteDataSource, private val userRepository: UserRepository, + private val networkStatusRepository: NetworkStatusRepository, ) { private val summaryLoadScope = CoroutineScope(Dispatchers.IO) private var lastSummaryLoadJob: Job? = null @@ -26,6 +28,9 @@ class SummaryRepository @Inject constructor( 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(), 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 7fa3513..6a71366 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,5 +1,6 @@ package com.example.readability.data.book +import com.example.readability.data.NetworkStatusRepository import com.example.readability.data.user.UserNotSignedInException import com.example.readability.data.user.UserRepository import kotlinx.coroutines.CoroutineScope @@ -21,6 +22,7 @@ class BookRepository @Inject constructor( private val bookDao: BookDao, private val bookRemoteDataSource: BookRemoteDataSource, private val userRepository: UserRepository, + private val networkStatusRepository: NetworkStatusRepository, ) { private val bookMap = MutableStateFlow(mutableMapOf()) private val progressUpdateScope = CoroutineScope(Dispatchers.IO) @@ -51,6 +53,9 @@ 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() @@ -97,6 +102,9 @@ class BookRepository @Inject constructor( 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")) } @@ -123,6 +131,9 @@ class BookRepository @Inject constructor( 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) @@ -140,6 +151,9 @@ class BookRepository @Inject constructor( 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 = { @@ -160,7 +174,7 @@ class BookRepository @Inject constructor( progressUpdateJob?.cancel() progressUpdateJob = progressUpdateScope.launch { delay(100L) - if (!isActive) { + if (!isActive || !networkStatusRepository.isConnected) { return@launch } bookRemoteDataSource.updateProgress(bookId, progress, accessToken) @@ -171,6 +185,9 @@ class BookRepository @Inject constructor( 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()) 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 e7243f9..9b25768 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,5 +1,6 @@ package com.example.readability.data.user +import com.example.readability.data.NetworkStatusRepository import kotlinx.coroutines.flow.first import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -9,9 +10,13 @@ import javax.inject.Singleton class UserRepository @Inject constructor( private val userRemoteDataSource: UserRemoteDataSource, private val userDao: UserDao, + 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( @@ -32,6 +37,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 = { @@ -58,6 +66,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, @@ -71,6 +82,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, 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 570dfeb..fc72b28 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 @@ -1,9 +1,6 @@ package com.example.readability.ui.screens.book import BottomSheet -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkInfo import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image @@ -78,28 +75,21 @@ fun BookListView( onNavigateAddBook: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}, onNavigateBookList: () -> Unit = {}, - onRefresh: suspend () -> Unit = {}, + onRefresh: suspend () -> Result = { Result.success(Unit) }, ) { val contentLoadScope = rememberCoroutineScope() val context = LocalContext.current - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork: NetworkInfo? = cm.activeNetworkInfo var refreshing by remember { mutableStateOf(false) } val refreshScope = rememberCoroutineScope() fun refresh() = refreshScope.launch { refreshing = true - if (activeNetwork?.isConnectedOrConnecting == null || !activeNetwork.isConnectedOrConnecting) { - delay(700) - refreshing = false - delay(200) - Toast.makeText(context, "No Internet Connection", Toast.LENGTH_SHORT).show() - } else { - onRefresh() - delay(1000) // TODO - refreshing = false + onRefresh().onFailure { + Toast.makeText(context, "Failed to refresh: ${it.message}", Toast.LENGTH_SHORT).show() } + delay(1000) // TODO + refreshing = false } val state = rememberPullRefreshState(refreshing, ::refresh) 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 0877829..2c97cfa 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 @@ -2,7 +2,6 @@ package com.example.readability.ui.screens.book import android.content.Context import android.net.ConnectivityManager -import android.net.NetworkInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -68,13 +67,8 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onBack = { navController.popBackStack() }, onBookUploaded = { navController.popBackStack() }, onAddBookClicked = { - val activeNetwork: NetworkInfo? = cm.activeNetworkInfo - if (activeNetwork != null && activeNetwork.isConnectedOrConnecting) { - withContext(Dispatchers.IO) { - addBookViewModel.addBook(it) - } - } else { - Result.failure(Exception("No internet connection")) + withContext(Dispatchers.IO) { + addBookViewModel.addBook(it) } }, ) 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..dd62e15 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 @@ -9,6 +9,7 @@ import androidx.navigation.compose.NavHost 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 @@ -34,13 +35,16 @@ fun ViewerScreen(id: Int, onNavigateSettings: () -> Unit, onBack: () -> Unit) { val viewerViewModel: ViewerViewModel = hiltViewModel() val quizViewModel: QuizViewModel = hiltViewModel() val summaryViewModel: SummaryViewModel = hiltViewModel() + val networkStatusViewModel: NetworkStatusViewModel = hiltViewModel() 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) 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 62c1ed5..f5d0084 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 @@ -5,8 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.net.ConnectivityManager -import android.net.NetworkInfo import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState @@ -103,6 +101,7 @@ import kotlinx.coroutines.sync.withLock fun ViewerView( bookData: Book?, pageSplitData: PageSplitData?, + isNetworkConnected: Boolean, onPageDraw: (canvas: NativeCanvas, pageIndex: Int) -> Unit = { _, _ -> }, onBack: () -> Unit = {}, onProgressChange: (Double) -> Unit = {}, @@ -183,6 +182,7 @@ fun ViewerView( visible = overlayVisible, bookData = bookData, pageSize = pageSize, + isNetworkConnected = isNetworkConnected, onProgressChange = { onProgressChange(it.toDouble()) }, onBack = { onBack() }, onNavigateSettings = { onNavigateSettings() }, @@ -584,6 +584,7 @@ fun ViewerOverlay( visible: Boolean, bookData: Book?, pageSize: Int, + isNetworkConnected: Boolean, onProgressChange: (Float) -> Unit, onBack: () -> Unit, onNavigateSettings: () -> Unit, @@ -595,9 +596,6 @@ fun ViewerOverlay( (pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1, ) - val context = LocalContext.current - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork: NetworkInfo? = cm.activeNetworkInfo // var aiStatus = 0 // Handler(Looper.getMainLooper()).postDelayed({ // println("aiStatus = ${bookData?.numCurrentInference} / ${bookData?.numTotalInference}") @@ -673,7 +671,7 @@ fun ViewerOverlay( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - if (activeNetwork?.isConnectedOrConnecting == null || !activeNetwork.isConnectedOrConnecting) { + if (!isNetworkConnected) { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), onClick = { onNavigateSummary() }, 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 2c85fce..70caa38 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 @@ -61,16 +61,16 @@ class BookListViewModel @Inject constructor( bookRepository.updateProgress(bookId, progress) } } + suspend fun deleteBook(bookId: Int) { viewModelScope.launch(Dispatchers.IO) { bookRepository.deleteBook(bookId) } } - suspend fun updateBookList() { - viewModelScope.launch(Dispatchers.IO) { - bookRepository.refreshBookList().onFailure { - println("BookListViewModel: refreshBookList failed: ${it.message}") - } + + suspend fun updateBookList(): Result { + return withContext(Dispatchers.IO) { + bookRepository.refreshBookList() } } 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 +} From 0a12d884a698f5def0cbcfa493d23ca90a56b081 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Tue, 28 Nov 2023 19:40:18 +0900 Subject: [PATCH 16/35] feat: default book cover image and book card, resize cover image, limit maximum length and prevent new line for book title and author --- .../ui/components/BottomSheetView.kt | 12 ++++--- .../ui/screens/book/AddBookView.kt | 34 +++++++++++++++--- .../ui/screens/book/BookListView.kt | 16 ++++----- .../ui/screens/viewer/ViewerView.kt | 3 +- .../res/drawable/defaul_book_cover_image.png | Bin 0 -> 393 bytes 5 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 frontend/app/src/main/res/drawable/defaul_book_cover_image.png 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 b0ec616..3f60243 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 @@ -36,6 +36,7 @@ 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 @@ -54,7 +55,6 @@ fun BottomSheet( onDismiss: () -> Unit, onProgressChanged: (Int, Double) -> Unit, onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, - onNavigateBookList: () -> Unit = {}, ) { val modalBottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, @@ -77,7 +77,6 @@ fun BottomSheet( }, onProgressChanged = onProgressChanged, onBookDeleted = onBookDeleted, - onNavigateBookList = onNavigateBookList, ) } } @@ -114,7 +113,6 @@ fun BottomSheetContent( onDismiss: () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, - onNavigateBookList: () -> Unit = {}, ) { var showDeleteBookDialog by remember { mutableStateOf(false) } @@ -143,7 +141,8 @@ fun BottomSheetContent( "Book deleted", Toast.LENGTH_SHORT, ).show() - onNavigateBookList() + onDismiss() +// onNavigateBookList() }.onFailure { Toast.makeText( context, @@ -224,7 +223,10 @@ 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), + contentScale = ContentScale.Fit, ) } Spacer(modifier = Modifier.height(16.dp)) 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 6d0ed3d..ba972bd 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 @@ -96,12 +96,32 @@ 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 + + var defaultImageString = "" + val defaultUri = Uri.parse("android.resource://"+context.packageName+"/drawable/" + R.drawable.defaul_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(), @@ -137,6 +157,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 { @@ -231,7 +254,8 @@ 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( @@ -239,12 +263,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( @@ -252,6 +277,7 @@ fun AddBookView( contentDescription = "Book Icon", ) }, + singleLine = true, ) Spacer(modifier = Modifier.height(32.dp)) Box( @@ -323,7 +349,7 @@ fun AddBookView( if (content.isEmpty()) { Toast.makeText( context, - " Please upload txt file ", + "File is empty.", Toast.LENGTH_SHORT, ).show() } else if (fileName.substring(fileName.length - 4, fileName.length) != ".txt") { @@ -340,7 +366,7 @@ fun AddBookView( title = title, content = content, author = author, - coverImage = imageString, + coverImage = if ( imageString == "" ){ defaultImageString }else{ imageString }, ), ).onSuccess { onBookUploaded() 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 fc72b28..7b57f56 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 @@ -52,6 +52,7 @@ 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 @@ -74,7 +75,6 @@ fun BookListView( onNavigateSettings: () -> Unit = {}, onNavigateAddBook: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}, - onNavigateBookList: () -> Unit = {}, onRefresh: suspend () -> Result = { Result.success(Unit) }, ) { val contentLoadScope = rememberCoroutineScope() @@ -189,9 +189,6 @@ fun BookListView( onBookDeleted = { id -> onBookDeleted(id) }, - onNavigateBookList = { - onNavigateBookList() - }, ) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), @@ -244,7 +241,6 @@ fun BookCard( onLoadImage: suspend () -> Unit = {}, onProgressChanged: (Int, Double) -> Unit = { _, _ -> }, onBookDeleted: suspend (Int) -> Result = { Result.success(Unit) }, - onNavigateBookList: () -> Unit = {}, ) { var showSheet by remember { mutableStateOf(false) } var loadingImage by remember { mutableStateOf(false) } @@ -258,7 +254,6 @@ fun BookCard( }, onProgressChanged = onProgressChanged, onBookDeleted = onBookDeleted, - onNavigateBookList = onNavigateBookList, ) } @@ -292,12 +287,12 @@ fun BookCard( Image( modifier = Modifier .padding(16.dp, 16.dp, 0.dp, 16.dp) - .fillMaxHeight() + .height(100.dp) .width(64.dp) .testTag(bookCardData.coverImage ?: ""), bitmap = bookCardData.coverImageData, contentDescription = "Book Cover Image", - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Fit, ) } Column( @@ -316,16 +311,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)) 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 f5d0084..467e78d 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 @@ -80,6 +80,7 @@ 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 @@ -618,7 +619,7 @@ fun ViewerOverlay( CenterAlignedTopAppBar(windowInsets = WindowInsets(0, 0, 0, 0), title = { Text( text = bookData?.title ?: "", - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), textAlign = TextAlign.Center, ) }, navigationIcon = { diff --git a/frontend/app/src/main/res/drawable/defaul_book_cover_image.png b/frontend/app/src/main/res/drawable/defaul_book_cover_image.png new file mode 100644 index 0000000000000000000000000000000000000000..e92c5744b5e98e8437ef0e4be0ead4bff0ab4c92 GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^4nUm1!3HGP9xZtRq&N#aB8wRqxP?KOkzv*x37{Zj zage(c!@6@aFM%AEbVpxD28NCO+zFa ze=i?@EW13$&Zh92S7qvqa+~-2j{pDob#w3h4H45`Z~M6__kP&3#Pc85pZ58C<@(mP zYnxW?@cqW1!cfH75W}P}o#BKk{g~I8WdyPrPl?CnPc7Vcu#$_fXaD!D@rL*1w!NPE jneT1-^zWs`xBsyR`D?SpoRPH$h8%;ZtDnm{r-UW|PVbA> literal 0 HcmV?d00001 From bc28a0df35c0b4bddcb39b6abe3b6274be9e3cfb Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Tue, 28 Nov 2023 21:37:39 +0900 Subject: [PATCH 17/35] Refactor: delete unnecessary parameters, Fix: POST to GET --- .../data/user/UserRemoteDataSource.kt | 14 ++++++++- .../readability/data/user/UserRepository.kt | 10 +++++++ .../readability/ui/screens/book/BookScreen.kt | 8 ----- .../ui/screens/settings/AccountView.kt | 2 -- .../ui/screens/settings/SettingsScreen.kt | 11 +++---- .../ui/screens/settings/SettingsView.kt | 3 +- .../ui/viewmodels/UserViewModel.kt | 30 +++++++++++++++++++ 7 files changed, 59 insertions(+), 19 deletions(-) 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..2797c4d 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,7 +79,7 @@ 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 } 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 9b25768..97b848e 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 @@ -13,6 +13,16 @@ class UserRepository @Inject constructor( private val networkStatusRepository: NetworkStatusRepository, ) { val user = userDao.get() + +// init { +// // load book list from database +// runBlocking { +// withContext(Dispatchers.IO) { +// val user = userDao.get() +// } +// } +// } + suspend fun signIn(email: String, password: String): Result { if (!networkStatusRepository.isConnected) { return Result.failure(Exception("Network not connected")) 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 2c97cfa..8688fae 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 @@ -1,11 +1,8 @@ package com.example.readability.ui.screens.book -import android.content.Context -import android.net.ConnectivityManager import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @@ -24,8 +21,6 @@ sealed class BookScreens(val route: String) { @Composable fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) -> Unit = {}) { val navController = rememberNavController() - val context = LocalContext.current - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NavHost(navController = navController, startDestination = BookScreens.BookList.route) { composableSharedAxis(BookScreens.BookList.route, axis = SharedAxis.X) { @@ -46,9 +41,6 @@ fun BookScreen(onNavigateSettings: () -> Unit = {}, onNavigateViewer: (id: Int) onNavigateViewer = { id -> onNavigateViewer(id) }, - onNavigateBookList = { - navController.popBackStack() - }, onProgressChanged = { id, progress -> bookListViewModel.updateProgress(id, progress) }, 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 e495114..93caf7a 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 @@ -70,7 +70,6 @@ fun AccountPreview() { fun AccountView( onBack: () -> Unit = {}, onNavigateChangePassword: () -> Unit = {}, - onUpdatePhoto: suspend () -> Result = { Result.success(Unit) }, onUpdatePersonalInfo: suspend () -> Result = { Result.success(Unit) }, onDeleteAccount: suspend () -> Result = { Result.success(Unit) }, onNavigateIntro: () -> Unit = {}, @@ -81,7 +80,6 @@ fun AccountView( 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() 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 c194e5d..dd5c0d7 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 @@ -38,6 +38,9 @@ fun SettingsScreen( composableSharedAxis(SettingsScreens.Settings.route, axis = SharedAxis.X) { val userViewModel: UserViewModel = hiltViewModel() val bookListViewModel: BookListViewModel = hiltViewModel() +// val userData by userViewModel.userData.collectAsState() + + // TODO: put correct user info to SettingsView SettingsView( onSignOut = { @@ -51,6 +54,7 @@ fun SettingsScreen( onNavigatePasswordCheck = { navController.navigate(SettingsScreens.Account.route) }, onNavigateViewer = { navController.navigate(SettingsScreens.Viewer.route) }, onNavigateIntro = { onNavigateAuth() }, +// userData = userData ) } composableSharedAxis(SettingsScreens.PasswordCheck.route, axis = SharedAxis.X) { @@ -71,13 +75,6 @@ fun SettingsScreen( AccountView( onBack = { navController.popBackStack() }, onNavigateChangePassword = { navController.navigate(SettingsScreens.ChangePassword.route) }, - onUpdatePhoto = { - withContext(Dispatchers.IO) { - // TODO: update photo using userViewModel - delay(1000L) - Result.success(Unit) - } - }, onUpdatePersonalInfo = { withContext(Dispatchers.IO) { // TODO: update personal info using userViewModel 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 5cef0b1..2326cd9 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 @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch @Preview(showBackground = true, device = "id:pixel_5") fun SettingsViewPreview() { ReadabilityTheme { - SettingsView() +// SettingsView() } } @@ -49,6 +49,7 @@ fun SettingsView( onNavigatePasswordCheck: () -> Unit = {}, onNavigateViewer: () -> Unit = {}, onNavigateIntro: () -> Unit = {}, +// userData: UserData ) { val context = LocalContext.current val logoutScope = rememberCoroutineScope() 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 763e60c..a247ab1 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,6 +1,8 @@ package com.example.readability.ui.viewmodels import androidx.lifecycle.ViewModel +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 javax.inject.Inject @@ -9,6 +11,34 @@ import javax.inject.Inject class UserViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { +// init { +// viewModelScope.launch(Dispatchers.IO) { +// userRepository.getUserInfo().onFailure { +// println("UserViewModel: getUserInfo failed: ${it.message}") +// } +// } +// } +// +// val userData = userRepository.user.map{userToUserData(it)}.stateIn( +// viewModelScope, +// SharingStarted.Lazily, +// runBlocking { +//// userRepository.user.first() +// userToUserData(userRepository.user.first()) +// }, +// ) + + + 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 } From d7c9e68ff811b599465fa342f466f805041cc25c Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Thu, 30 Nov 2023 17:19:37 +0900 Subject: [PATCH 18/35] feat: preserve email input when go back --- .../readability/ui/screens/auth/AuthScreen.kt | 33 ++++++++----------- .../readability/ui/screens/auth/EmailView.kt | 13 +++++--- .../ui/screens/auth/ForgotPasswordView.kt | 7 ++-- 3 files changed, 27 insertions(+), 26 deletions(-) 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 68eb4f4..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,13 +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) }, ) } composableSharedAxis(AuthScreens.SignIn.route, axis = SharedAxis.X) { - val email = it.arguments?.getString("email") ?: "" val userViewModel: UserViewModel = hiltViewModel() SignInView( email = email, @@ -79,6 +79,8 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav } composableSharedAxis(AuthScreens.ForgotPassword.route, axis = SharedAxis.X) { ForgotPasswordView( + email = email, + onEmailChanged = { email = it }, onBack = { navController.popBackStack() }, onEmailSubmitted = { withContext(Dispatchers.IO) { @@ -89,7 +91,6 @@ fun AuthScreen(navController: NavHostController = rememberNavController(), onNav onNavigateVerify = { navController.navigate( AuthScreens.VerifyEmail.createRoute( - it, false, ), ) @@ -101,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 50f1fa5..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") } } @@ -52,10 +52,15 @@ private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@(.+)\$") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmailView(onBack: () -> Unit = {}, onNavigateSignIn: (String) -> Unit = {}, onNavigateSignUp: () -> Unit = {}) { +fun EmailView( + email: String, + onEmailChanged: (String) -> Unit = {}, + onBack: () -> Unit = {}, + onNavigateSignIn: (String) -> Unit = {}, + onNavigateSignUp: () -> Unit = {}, +) { var showError by remember { mutableStateOf(false) } var emailError by remember { mutableStateOf(false) } - var email by remember { mutableStateOf("") } val emailFocusRequester = remember { FocusRequester() } @@ -118,7 +123,7 @@ fun EmailView(onBack: () -> Unit = {}, onNavigateSignIn: (String) -> Unit = {}, .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/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, From 1f8075ffde96b0cf3406e43f6257d58487778c7a Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Thu, 30 Nov 2023 17:22:08 +0900 Subject: [PATCH 19/35] feat: add user info to settings; fix: delayed viewer style change for font family --- .../com/example/readability/MainActivity.kt | 85 ++-------- .../data/viewer/PageSplitRepository.kt | 6 +- .../example/readability/ui/screens/Screen.kt | 133 +++++++++++++++ .../example/readability/ui/screens/Screens.kt | 12 -- .../ui/screens/settings/AccountView.kt | 153 ++++++----------- .../ui/screens/settings/ChangePasswordView.kt | 20 ++- .../ui/screens/settings/PasswordCheckView.kt | 159 ------------------ .../ui/screens/settings/SettingsScreen.kt | 47 ++---- .../ui/screens/settings/SettingsView.kt | 72 +++----- .../ui/screens/settings/ViewerView.kt | 19 ++- 10 files changed, 259 insertions(+), 447 deletions(-) create mode 100644 frontend/app/src/main/java/com/example/readability/ui/screens/Screen.kt delete mode 100644 frontend/app/src/main/java/com/example/readability/ui/screens/Screens.kt delete mode 100644 frontend/app/src/main/java/com/example/readability/ui/screens/settings/PasswordCheckView.kt diff --git a/frontend/app/src/main/java/com/example/readability/MainActivity.kt b/frontend/app/src/main/java/com/example/readability/MainActivity.kt index 96fff28..4af7450 100644 --- a/frontend/app/src/main/java/com/example/readability/MainActivity.kt +++ b/frontend/app/src/main/java/com/example/readability/MainActivity.kt @@ -15,17 +15,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.example.readability.ui.animation.composableFadeThrough -import com.example.readability.ui.screens.Screens -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.data.viewer.FontDataSource +import com.example.readability.ui.screens.Screen import com.example.readability.ui.theme.ReadabilityTheme import com.example.readability.ui.viewmodels.UserViewModel import dagger.hilt.android.AndroidEntryPoint @@ -34,6 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import javax.inject.Inject class SnackBarState(val state: SnackbarHostState? = null, val scope: CoroutineScope? = null) { fun showSnackbar(message: String) { @@ -49,6 +41,12 @@ val LocalSnackbarHost = staticCompositionLocalOf { @AndroidEntryPoint class MainActivity : ComponentActivity() { + + // inject FontDataSource into MainActivity + // to initialize on app startup + @Inject + lateinit var fontDataSource: FontDataSource + companion object { init { System.loadLibrary("readability") @@ -79,75 +77,12 @@ class MainActivity : ComponentActivity() { val snackbarScope = rememberCoroutineScope() ReadabilityTheme { Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { - val navController = rememberNavController() CompositionLocalProvider( LocalSnackbarHost provides SnackBarState( state = snackbarHostState, scope = snackbarScope, ), ) { - 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, - ) - } - } + Screen(isSignedIn = isSignedIn) } } } 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..74ced45 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 @@ -53,8 +53,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 +89,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 +113,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/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/settings/AccountView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/settings/AccountView.kt index 93caf7a..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,15 +37,11 @@ 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.SettingTitle import com.example.readability.ui.theme.ReadabilityTheme @@ -61,54 +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 = {}, - 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 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 = { @@ -127,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() } } }, @@ -166,32 +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", - ) - } - SettingTitle(modifier = Modifier.padding(top = 24.dp), text = "Personal Info") + SettingTitle(text = "Personal Info") Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( modifier = Modifier @@ -199,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") @@ -213,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( @@ -231,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") @@ -245,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 dd5c0d7..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 @@ -9,7 +9,6 @@ import androidx.navigation.compose.NavHost 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.BookListViewModel import com.example.readability.ui.viewmodels.SettingViewModel import com.example.readability.ui.viewmodels.UserViewModel import kotlinx.coroutines.Dispatchers @@ -18,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") @@ -37,50 +35,28 @@ fun SettingsScreen( NavHost(navController = navController, startDestination = startDestination) { composableSharedAxis(SettingsScreens.Settings.route, axis = SharedAxis.X) { val userViewModel: UserViewModel = hiltViewModel() - val bookListViewModel: BookListViewModel = hiltViewModel() -// val userData by userViewModel.userData.collectAsState() + val user by userViewModel.user.collectAsState() - - // TODO: put correct user info to SettingsView SettingsView( - onSignOut = { - withContext(Dispatchers.IO) { - userViewModel.signOut() - bookListViewModel.clearBookList() - } - Result.success(Unit) - }, + username = user?.userName ?: "", onBack = { onBack() }, - onNavigatePasswordCheck = { navController.navigate(SettingsScreens.Account.route) }, + onNavigateAccountSetting = { navController.navigate(SettingsScreens.Account.route) }, onNavigateViewer = { navController.navigate(SettingsScreens.Viewer.route) }, - onNavigateIntro = { onNavigateAuth() }, -// userData = userData ) } - 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) }, - 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) { @@ -95,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 2326cd9..bd3d699 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 = {}, - onNavigateIntro: () -> Unit = {}, -// userData: UserData ) { - val context = LocalContext.current - val logoutScope = rememberCoroutineScope() - Scaffold(topBar = { TopAppBar(title = { Text(text = "Settings") }, navigationIcon = { IconButton(onClick = { onBack() }) { @@ -67,19 +57,26 @@ fun SettingsView( SettingTitle(text = "General") ListItem( modifier = Modifier.clickable { - onNavigatePasswordCheck() + 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( @@ -110,33 +107,6 @@ fun SettingsView( ) }, ) - 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 7def7b9..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,6 +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.safeDrawingPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -105,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) From 2951227550056d01d545370e0127bcf7825d11ef Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Thu, 30 Nov 2023 17:23:59 +0900 Subject: [PATCH 20/35] feat: save content and image as file --- .../readability/data/book/BookDatabase.kt | 69 +++------ .../data/book/BookFileDataSource.kt | 132 +++++++++++++++++ .../data/book/BookRemoteDataSource.kt | 11 +- .../readability/data/book/BookRepository.kt | 137 +++++++++++++----- .../ui/components/BottomSheetView.kt | 1 + .../ui/screens/book/BookListView.kt | 5 +- .../ui/viewmodels/BookListViewModel.kt | 1 + .../ui/models/BookRepositoryTest.kt | 20 ++- 8 files changed, 274 insertions(+), 102 deletions(-) create mode 100644 frontend/app/src/main/java/com/example/readability/data/book/BookFileDataSource.kt 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 ffb026b..88b4c06 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,62 +34,48 @@ 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 = "num_total_inference") val numTotalInference: Int, - @ColumnInfo(name = "num_current_inference") val numCurrentInference: Int, + @ColumnInfo(name = "summary_progress") val summaryProgress: Double = 0.0, ) @Dao interface BookDao { - @Query("SELECT * FROM Book") - fun getAll(): List - - @Query("SELECT * FROM Book WHERE book_id = :bookId") - fun getBook(bookId: Int): Book? + @Query("SELECT * FROM BookEntity") + fun getAll(): List - @Query("SELECT num_total_inference FROM Book WHERE book_id = :bookId") - fun getNumTotalInference(bookId: Int): Int? + @Query("SELECT * FROM BookEntity WHERE book_id = :bookId") + fun getBook(bookId: Int): BookEntity? - @Query("SELECT num_current_inference FROM Book WHERE book_id = :bookId") - fun getNumCurrentInference(bookId: Int): Int? + @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?) } -@Database(entities = [Book::class], version = 2) +@Database(entities = [BookEntity::class], version = 3) @TypeConverters(BookTypeConverters::class) abstract class BookDatabase : RoomDatabase() { abstract fun bookDao(): BookDao @@ -129,6 +96,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 ee1f44f..06a8576 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 @@ -33,8 +33,7 @@ data class BookCardData( val coverImage: String? = null, val coverImageData: ImageBitmap? = null, val content: String, - val numTotalInference: Int = 0, - val numCurrentInference: Int = 0, + val summaryProgress: Double, ) data class BookResponse( @@ -44,8 +43,6 @@ data class BookResponse( val content: String, val cover_image: String, val progress: Double, - val num_total_inference: Int, - val num_current_inference: Int, ) data class AddBookRequest( @@ -108,15 +105,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, 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 6a71366..f2150de 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,5 +1,6 @@ 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 @@ -14,12 +15,55 @@ 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, @@ -43,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 } @@ -61,28 +105,20 @@ class BookRepository @Inject constructor( val newMap = bookMap.value.toMutableMap() 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, - numCurrentInference = book.numCurrentInference, - numTotalInference = book.numTotalInference, - ) - 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 (newBookList.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) } } @@ -94,23 +130,33 @@ class BookRepository @Inject constructor( } suspend fun getCoverImageData(bookId: Int): Result { - val accessToken = - userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException()) - val book = bookDao.getBook(bookId) ?: return Result.failure( - Exception("Book not found"), - ) - if (book.coverImageData != null) { + // 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()) 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) @@ -123,20 +169,28 @@ class BookRepository @Inject constructor( } suspend fun getContentData(bookId: Int): Result { - val accessToken = - userRepository.getAccessToken() ?: return Result.failure(UserNotSignedInException()) - val book = bookDao.getBook(bookId) ?: return Result.failure( - Exception("Book not found"), - ) - if (book.contentData != null) { + // 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()) 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) @@ -191,7 +245,9 @@ class BookRepository @Inject constructor( return bookRemoteDataSource.deleteBook(bookId, accessToken).fold(onSuccess = { val book = bookMap.value[bookId] ?: return Result.failure(UserNotSignedInException()) - bookDao.delete(book) + bookFileDataSource.deleteContentFile(bookId) + bookFileDataSource.deleteCoverImageFile(bookId) + bookDao.delete(book.bookId) bookMap.update { val newMap = it.toMutableMap() newMap.remove(bookId) @@ -205,16 +261,17 @@ class BookRepository @Inject constructor( } suspend fun clearBooks() { + bookFileDataSource.deleteAll() bookDao.deleteAll() bookMap.value = mutableMapOf() } fun updateAIStatus(bookId: Int, aiStatus: Double) { - bookDao.getNumTotalInference(bookId) ?: return - bookMap.update { - it.toMutableMap().apply { -// this[bookId] = this[bookId]!!.copy(aiStatus = aiStatus) - } - } +// bookDao.getNumTotalInference(bookId) ?: return +// bookMap.update { +// it.toMutableMap().apply { +// // this[bookId] = this[bookId]!!.copy(aiStatus = aiStatus) +// } +// } } } 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 3f60243..1d21894 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 @@ -100,6 +100,7 @@ fun BottomSheetPreview() { progress = 0.5, coverImageData = null, content = "asd", + summaryProgress = 0.5, ), ) } 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 7b57f56..0d11beb 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 @@ -227,6 +227,7 @@ fun BookCardPreview() { author = "F. Scott Fitzgerald", progress = 0.5, content = "aasdasd", + summaryProgress = 0.5, ), ) } @@ -311,7 +312,7 @@ fun BookCard( style = MaterialTheme.typography.titleMedium.copy( fontFamily = Gabarito, fontWeight = FontWeight.Medium, - lineBreak = LineBreak.Paragraph + lineBreak = LineBreak.Paragraph, ), ) } @@ -323,7 +324,7 @@ fun BookCard( color = MaterialTheme.colorScheme.secondary, fontFamily = Gabarito, fontWeight = FontWeight.Medium, - lineBreak = LineBreak.Paragraph + lineBreak = LineBreak.Paragraph, ), ) Spacer(modifier = Modifier.weight(1f)) 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 70caa38..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 @@ -28,6 +28,7 @@ class BookListViewModel @Inject constructor( coverImage = book.coverImage, coverImageData = book.coverImageData, content = book.content, + summaryProgress = book.summaryProgress, ) val bookCardDataList = bookRepository.bookList.map { 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..17e3863 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,14 @@ package com.example.readability.ui.models -import com.example.readability.data.book.Book +import com.example.readability.data.NetworkStatusRepository 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 +36,38 @@ 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 = "test", content = "test", + summaryProgress = 0.5, ), ), ) + `when`(networkStatusRepository.isConnected).thenReturn(true) + `when`(userRepository.getAccessToken()).thenReturn("test") bookRepository = BookRepository( bookDao = bookDao, bookRemoteDataSource = bookRemoteDataSource, userRepository = userRepository, + networkStatusRepository = networkStatusRepository, + bookFileDataSource = bookFileDataSource, ) } From ccfb0855abf9cedbd6a735f06e8e8bcaee73464f Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Thu, 30 Nov 2023 17:28:10 +0900 Subject: [PATCH 21/35] feat: Viewer immersive mode, improve overall animation --- .../readability/screens/ViewerScreenTest.kt | 25 ++- .../readability/data/ai/SummaryRepository.kt | 16 +- .../readability/data/viewer/FontDataSource.kt | 22 ++- .../{animateIMEDp.kt => AnimateImeDp.kt} | 0 ...Transition.kt => FadeThroughTransition.kt} | 29 +-- ...sTransition.kt => SharedAxisTransition.kt} | 42 ++-- .../ui/animation/{values.kt => Values.kt} | 0 .../ui/screens/book/AddBookView.kt | 15 +- .../ui/screens/viewer/QuizReportView.kt | 24 ++- .../ui/screens/viewer/SummaryView.kt | 52 +++-- .../ui/screens/viewer/ViewerScreen.kt | 74 ++++++- .../ui/screens/viewer/ViewerView.kt | 187 ++++++++++-------- .../ui/viewmodels/SummaryViewModel.kt | 7 + frontend/app/src/main/res/drawable/avatar.xml | 13 ++ .../app/src/main/res/drawable/sign_out.xml | 9 + .../app/src/main/res/drawable/user_image.xml | 16 ++ 16 files changed, 364 insertions(+), 167 deletions(-) rename frontend/app/src/main/java/com/example/readability/ui/animation/{animateIMEDp.kt => AnimateImeDp.kt} (100%) rename frontend/app/src/main/java/com/example/readability/ui/animation/{fadeThroughTransition.kt => FadeThroughTransition.kt} (74%) rename frontend/app/src/main/java/com/example/readability/ui/animation/{sharedAxisTransition.kt => SharedAxisTransition.kt} (74%) rename frontend/app/src/main/java/com/example/readability/ui/animation/{values.kt => Values.kt} (100%) create mode 100644 frontend/app/src/main/res/drawable/avatar.xml create mode 100644 frontend/app/src/main/res/drawable/sign_out.xml create mode 100644 frontend/app/src/main/res/drawable/user_image.xml 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..a64aeb3 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,5 +1,6 @@ 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 @@ -76,6 +77,7 @@ class ViewerScreenTest { openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size)) composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onProgressChange = { @@ -104,6 +106,7 @@ class ViewerScreenTest { openBoatBookData.copy(progress = 0.0) composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onProgressChange = { @@ -132,6 +135,7 @@ class ViewerScreenTest { openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size)) composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onProgressChange = { @@ -157,6 +161,7 @@ class ViewerScreenTest { openBoatBookData = openBoatBookData.copy(progress = 0.0) composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onProgressChange = { @@ -178,6 +183,7 @@ class ViewerScreenTest { openBoatBookData.copy(progress = (1.5 / pageSplitData.pageSplits.size)) composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onProgressChange = { @@ -200,6 +206,7 @@ class ViewerScreenTest { var onBack = false composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onBack = { @@ -229,6 +236,7 @@ class ViewerScreenTest { var onNavigateSettings = false composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onNavigateSettings = { @@ -258,6 +266,7 @@ class ViewerScreenTest { var onNavigateSummary = false composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onNavigateSummary = { @@ -287,6 +296,7 @@ class ViewerScreenTest { var onNavigateQuiz = false composeTestRule.setContent { ViewerView( + isNetworkConnected = true, bookData = openBoatBookData, pageSplitData = pageSplitData, onNavigateQuiz = { @@ -400,7 +410,12 @@ class ViewerScreenTest { @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 + ) } // check if summary is displayed @@ -412,7 +427,13 @@ 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 + ) } // click back button 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 bc49940..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 @@ -37,14 +37,22 @@ class SummaryRepository @Inject constructor( ) try { lastSummaryLoadJob?.cancel() + var error: Throwable? = null lastSummaryLoadJob = summaryLoadScope.launch { - _summary.value = "" - summaryRemoteDataSource.getSummary(bookId, progress, accessToken).collect { response -> - if (!isActive) return@collect - _summary.value += response + try { + _summary.value = "" + summaryRemoteDataSource.getSummary(bookId, progress, accessToken).collect { response -> + if (!isActive) return@collect + _summary.value += response + } + } catch (e: Throwable) { + error = e } } lastSummaryLoadJob?.join() + if (error != null) { + return@withContext Result.failure(error!!) + } } catch (e: Throwable) { return@withContext Result.failure(e) } 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/ui/animation/animateIMEDp.kt b/frontend/app/src/main/java/com/example/readability/ui/animation/AnimateImeDp.kt similarity index 100% rename from frontend/app/src/main/java/com/example/readability/ui/animation/animateIMEDp.kt rename to frontend/app/src/main/java/com/example/readability/ui/animation/AnimateImeDp.kt 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 similarity index 74% rename from frontend/app/src/main/java/com/example/readability/ui/animation/fadeThroughTransition.kt rename to 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 similarity index 74% rename from frontend/app/src/main/java/com/example/readability/ui/animation/sharedAxisTransition.kt rename to frontend/app/src/main/java/com/example/readability/ui/animation/SharedAxisTransition.kt index 4611cfb..00ae2d1 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 @@ -23,6 +23,16 @@ enum class SharedAxis { 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, axis: SharedAxis, @@ -49,10 +59,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 +73,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 +87,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 +101,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/animation/values.kt b/frontend/app/src/main/java/com/example/readability/ui/animation/Values.kt similarity index 100% rename from frontend/app/src/main/java/com/example/readability/ui/animation/values.kt rename to frontend/app/src/main/java/com/example/readability/ui/animation/Values.kt 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 ba972bd..b60e050 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 @@ -106,7 +106,9 @@ fun AddBookView( val maxChar = 80 var defaultImageString = "" - val defaultUri = Uri.parse("android.resource://"+context.packageName+"/drawable/" + R.drawable.defaul_book_cover_image) + val defaultUri = Uri.parse( + "android.resource://" + context.packageName + "/drawable/" + R.drawable.defaul_book_cover_image, + ) defaultbitmap = if (Build.VERSION.SDK_INT < 28) { MediaStore.Images.Media.getBitmap(context.contentResolver, defaultUri) } else { @@ -255,7 +257,8 @@ fun AddBookView( modifier = Modifier.fillMaxWidth(), value = title, onValueChange = { - if (it.length <= maxChar) title = it }, + if (it.length <= maxChar) title = it + }, label = { Text(text = "Book Title") }, leadingIcon = { Icon( @@ -269,7 +272,7 @@ fun AddBookView( OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = author, - onValueChange = {if (it.length <= maxChar) author = it }, + onValueChange = { if (it.length <= maxChar) author = it }, label = { Text(text = "Author (Optional)") }, leadingIcon = { Icon( @@ -366,7 +369,11 @@ fun AddBookView( title = title, content = content, author = author, - coverImage = if ( imageString == "" ){ defaultImageString }else{ imageString }, + coverImage = if (imageString == "") { + defaultImageString + } else { + imageString + }, ), ).onSuccess { onBookUploaded() 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/SummaryView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/viewer/SummaryView.kt index 98a51dc..836acbe 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,6 @@ package com.example.readability.ui.screens.viewer -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import android.graphics.Typeface import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -10,21 +9,35 @@ 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.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +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 + +@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 = {}, +) { Scaffold( topBar = { TopAppBar(title = { Text(text = "Previous Story") }, navigationIcon = { @@ -34,21 +47,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 dd62e15..0dfa62b 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,11 +1,22 @@ 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 @@ -20,16 +31,56 @@ 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 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 + } + } + } + NavHost(navController = navController, startDestination = ViewerScreens.Viewer.route) { composableSharedAxis(ViewerScreens.Viewer.route, axis = SharedAxis.X) { val viewerViewModel: ViewerViewModel = hiltViewModel() @@ -106,9 +157,18 @@ 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() + }, + ) } } } 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 467e78d..8071d06 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 @@ -6,13 +6,8 @@ import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.res.Configuration import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState 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 @@ -29,11 +24,10 @@ 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.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 @@ -46,7 +40,6 @@ 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 @@ -74,6 +67,7 @@ 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 @@ -97,6 +91,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.math.roundToInt @Composable fun ViewerView( @@ -112,6 +107,11 @@ fun ViewerView( onNavigateSummary: () -> 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) } @@ -145,24 +145,21 @@ fun ViewerView( val pageSize = pageSplitData?.pageSplits?.size ?: 0 val pageIndex = maxOf(minOf((pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1), 0) - 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 = { @@ -178,9 +175,9 @@ fun ViewerView( when (it) { true -> ViewerOverlay( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + .fillMaxSize(), visible = overlayVisible, + shrinkAnimation = shrinkAnimation, bookData = bookData, pageSize = pageSize, isNetworkConnected = isNetworkConnected, @@ -201,6 +198,7 @@ fun ViewerView( }, pageIndex = pageIndex, overlayVisible = overlayVisible, + shrinkAnimation = shrinkAnimation, onPageChanged = { pageIndex -> onProgressChange((pageIndex + 0.5) / pageSize) }, @@ -343,6 +341,7 @@ fun BookPager( pageSize: Int, pageIndex: Int, overlayVisible: Boolean, + shrinkAnimation: Float, onPageDraw: (canvas: NativeCanvas, pageIndex: Int) -> Unit = { _, _ -> }, onPageChanged: (Int) -> Unit = {}, onOverlayVisibleChanged: (Boolean) -> Unit = {}, @@ -358,12 +357,6 @@ 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) { @@ -583,6 +576,7 @@ fun ViewerSizeMeasurer(modifier: Modifier = Modifier, onPageSizeChanged: (Int, I fun ViewerOverlay( modifier: Modifier = Modifier, visible: Boolean, + shrinkAnimation: Float, bookData: Book?, pageSize: Int, isNetworkConnected: Boolean, @@ -603,65 +597,52 @@ fun ViewerOverlay( // aiStatus = if (bookData?.numTotalInference == 0) 0 else bookData?.numCurrentInference!! / bookData.numTotalInference // }, 10000) - 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.copy(lineBreak = LineBreak.Paragraph), - textAlign = TextAlign.Center, - ) - }, 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", + 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, ) - } - }) - } + }, + 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() @@ -729,15 +710,47 @@ fun ViewerOverlay( ) } } - } - 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} / $pageSize", + 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) + } } } 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..37531f5 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 @@ -3,6 +3,8 @@ 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.viewer.FontDataSource +import com.example.readability.data.viewer.SettingRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -11,8 +13,13 @@ import javax.inject.Inject @HiltViewModel class SummaryViewModel @Inject constructor( private val summaryRepository: SummaryRepository, + private val settingRepository: SettingRepository, + private val fontDataSource: FontDataSource, ) : 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) { 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/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 @@ + + + + + From 31b51eae291469596f40b04b88f1b319675fd56a Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Thu, 30 Nov 2023 17:29:05 +0900 Subject: [PATCH 22/35] fix: quiz crash on error; feat: add change password to user data layer --- frontend/app/build.gradle.kts | 1 + .../readability/data/ai/QuizRepository.kt | 66 +++++++++++-------- .../data/user/UserRemoteDataSource.kt | 12 ++++ .../readability/data/user/UserRepository.kt | 8 +++ .../ui/viewmodels/UserViewModel.kt | 41 +++++++----- .../ui/models/UserRepositoryTest.kt | 9 +-- 6 files changed, 87 insertions(+), 50 deletions(-) diff --git a/frontend/app/build.gradle.kts b/frontend/app/build.gradle.kts index 655f9c1..613f76d 100644 --- a/frontend/app/build.gradle.kts +++ b/frontend/app/build.gradle.kts @@ -69,6 +69,7 @@ android { unitTests { isIncludeAndroidResources = true } + unitTests.all { it.jvmArgs("-noverify") } } } 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 35bde32..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 @@ -52,45 +52,53 @@ class QuizRepository @Inject constructor( _quizCount.update { 0 } try { lastQuizLoadJob?.cancel() + var error: Throwable? = null lastQuizLoadJob = quizLoadScope.launch { - 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("", "")) + 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 { - 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 { + 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), + ) + } } } } } + } catch (e: Throwable) { + error = e } } lastQuizLoadJob?.join() + if (error != null) { + return@withContext Result.failure(error!!) + } } catch (e: Throwable) { e.printStackTrace() return@withContext Result.failure(e) 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 2797c4d..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 @@ -81,6 +81,9 @@ interface UserAPI { @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) @@ -168,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 97b848e..5bf942e 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 @@ -108,6 +108,14 @@ 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() { userDao.deleteAll() } 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 a247ab1..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,33 +1,38 @@ 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() { -// init { -// viewModelScope.launch(Dispatchers.IO) { -// userRepository.getUserInfo().onFailure { -// println("UserViewModel: getUserInfo failed: ${it.message}") -// } -// } -// } -// -// val userData = userRepository.user.map{userToUserData(it)}.stateIn( -// viewModelScope, -// SharingStarted.Lazily, -// runBlocking { -//// userRepository.user.first() -// userToUserData(userRepository.user.first()) -// }, -// ) + 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, @@ -37,7 +42,7 @@ class UserViewModel @Inject constructor( accessToken = user.accessToken, accessTokenLife = user.accessTokenLife, createdAt = user.createdAt, - verified = user.verified + verified = user.verified, ) suspend fun isSignedIn(): Boolean { return userRepository.getAccessToken() != null @@ -47,4 +52,6 @@ class UserViewModel @Inject constructor( 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/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, ) } From 7b9adcd98f7cc2f4491a22111fa19b4d5c2f2a20 Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Fri, 1 Dec 2023 03:36:21 +0900 Subject: [PATCH 23/35] feat: show summary preprocessing progress when overlay displayed; fix: adding new line to the summary --- .../data/ai/SummaryRemoteDataSource.kt | 4 +- .../readability/data/book/BookDatabase.kt | 3 ++ .../data/book/BookRemoteDataSource.kt | 24 +++++++++ .../readability/data/book/BookRepository.kt | 30 ++++++++---- .../ui/components/RoundedRectButton.kt | 49 +++++++++++++++++++ .../ui/screens/viewer/ViewerScreen.kt | 3 ++ .../ui/screens/viewer/ViewerView.kt | 37 +++++--------- .../ui/viewmodels/ViewerViewModel.kt | 14 ++++-- 8 files changed, 127 insertions(+), 37 deletions(-) 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 bd02505..8169ee8 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 @@ -52,7 +52,9 @@ class SummaryRemoteDataSource @Inject constructor( while (currentCoroutineContext().isActive) { val line = it.readLine() ?: break if (line.startsWith("data:")) { - emit(line.substring(6)) + var token = line.substring(6) + if (token.isEmpty()) token = "\n" + emit(token) } } } catch (e: Exception) { 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 88b4c06..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 @@ -73,6 +73,9 @@ interface BookDao { @Query("UPDATE BookEntity SET progress = :progress WHERE book_id = :bookId") fun updateProgress(bookId: Int, progress: Double) + + @Query("UPDATE BookEntity SET summary_progress = :summaryProgress WHERE book_id = :bookId") + fun updateSummaryProgress(bookId: Int, summaryProgress: Double) } @Database(entities = [BookEntity::class], version = 3) 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 06a8576..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 @@ -56,6 +56,10 @@ data class BooksResponse( val books: List, ) +data class SummaryProgressResponse( + val summary_progress: String, +) + interface BookAPI { @Headers("Accept: application/json") @GET("/books") @@ -75,6 +79,12 @@ 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 @@ -163,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() 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 f2150de..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 @@ -202,6 +202,27 @@ 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()) @@ -265,13 +286,4 @@ class BookRepository @Inject constructor( bookDao.deleteAll() bookMap.value = mutableMapOf() } - - fun updateAIStatus(bookId: Int, aiStatus: Double) { -// bookDao.getNumTotalInference(bookId) ?: return -// bookMap.update { -// it.toMutableMap().apply { -// // this[bookId] = this[bookId]!!.copy(aiStatus = aiStatus) -// } -// } - } } diff --git a/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt b/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt index 0d7df3d..412381e 100644 --- a/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt +++ b/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt @@ -2,9 +2,12 @@ package com.example.readability.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -16,17 +19,22 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times +import com.example.readability.ui.theme.ReadabilityTheme @Composable fun CircularProgressIndicatorInButton( @@ -146,3 +154,44 @@ fun RoundedRectFilledTonalButton( }, ) } + +@Preview +@Composable +fun CustomButtonPreview() { + ReadabilityTheme { + SummaryProgressBar( + progress = 0.73, + ) + } +} + + + +@Composable +fun SummaryProgressBar( + progress: Double = 0.0, + text: String = "Preparing AI... (${(progress * 100).toInt()}%)" +) { + ReadabilityTheme { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + .fillMaxWidth(), + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .height(48.dp) + .background(MaterialTheme.colorScheme.secondaryContainer) + .fillMaxWidth(progress.toFloat()) + ) + Text( + modifier = Modifier + .align(Alignment.Center), + text = text, + ) + } + } +} 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 0dfa62b..1d631a9 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 @@ -118,6 +118,9 @@ fun ViewerScreen( onPageDraw = { canvas, pageIndex -> viewerViewModel.drawPage(id, canvas, pageIndex, isDarkTheme) }, + onOverlayView = { + viewerViewModel.updateSummaryProgress(id) + } ) } composableSharedAxis(ViewerScreens.Quiz.route, axis = SharedAxis.X) { 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 8071d06..785755b 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 @@ -86,6 +86,7 @@ import com.example.readability.ui.animation.DURATION_EMPHASIZED import com.example.readability.ui.animation.EASING_EMPHASIZED import com.example.readability.ui.animation.EASING_LEGACY import com.example.readability.ui.components.RoundedRectFilledTonalButton +import com.example.readability.ui.components.SummaryProgressBar import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -105,6 +106,7 @@ fun ViewerView( onNavigateSettings: () -> Unit = {}, onNavigateQuiz: () -> Unit = {}, onNavigateSummary: () -> Unit = {}, + onOverlayView: () -> Unit = {}, ) { var overlayVisible by remember { mutableStateOf(false) } val shrinkAnimation by animateFloatAsState( @@ -186,6 +188,7 @@ fun ViewerView( onNavigateSettings = { onNavigateSettings() }, onNavigateSummary = { onNavigateSummary() }, onNavigateQuiz = { onNavigateQuiz() }, + onOverlayView = { onOverlayView() }, ) { if (bookData != null && pageSize > 0) { BookPager( @@ -585,17 +588,17 @@ fun ViewerOverlay( onNavigateSettings: () -> Unit, onNavigateSummary: () -> Unit, onNavigateQuiz: () -> Unit, + onOverlayView: () -> Unit, content: @Composable () -> Unit = {}, ) { val pageIndex = minOf( (pageSize * (bookData?.progress ?: 0.0)).toInt(), pageSize - 1, ) -// var aiStatus = 0 -// Handler(Looper.getMainLooper()).postDelayed({ -// println("aiStatus = ${bookData?.numCurrentInference} / ${bookData?.numTotalInference}") -// aiStatus = if (bookData?.numTotalInference == 0) 0 else bookData?.numCurrentInference!! / bookData.numTotalInference -// }, 10000) + + fun updateSummaryProgress() { + onOverlayView() + } Layout( modifier = modifier, @@ -661,17 +664,11 @@ fun ViewerOverlay( ) { Text("No Internet Connection") } - } - // TODO: add ai status -// if (bookData?.numTotalInference == 0) { - else if (false) { - RoundedRectFilledTonalButton( - modifier = Modifier.weight(1f), - onClick = { onNavigateSummary() }, - enabled = false, - ) { - Text("Too short to generate Summary and Quiz") - } + } else if (bookData?.summaryProgress!! < 1) { + updateSummaryProgress() + SummaryProgressBar( + progress = bookData.summaryProgress, + ) } else if (pageIndex < 4) { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), @@ -680,14 +677,6 @@ fun ViewerOverlay( ) { Text("4 pages required for Summary and Quiz") } -// } else if (aiStatus < 1) { -// RoundedRectFilledTonalButton( -// modifier = Modifier.weight(1f), -// onClick = { onNavigateSummary() }, -// enabled = false, -// ) { -// Text("Waiting for Summary and Quiz...(${aiStatus * 100 / 1}%)") -// } } else { RoundedRectFilledTonalButton( modifier = Modifier.weight(1f), 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 c97db1e..584466a 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,7 +9,10 @@ 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.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -23,6 +26,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,10 +48,14 @@ class ViewerViewModel @Inject constructor( } } - fun setAIStatus(bookId: Int, aiStatus: Double) { - viewModelScope.launch { + fun updateSummaryProgress(bookId: Int) { + job?.cancel() + job = viewModelScope.launch { withContext(Dispatchers.IO) { - bookRepository.updateAIStatus(bookId, aiStatus) + while (bookRepository.getBook(bookId).first()?.summaryProgress!! < 1.0) { + bookRepository.updateSummaryProgress(bookId) + delay(1000) + } } } } From 88ece092657c0720e788155902ccd984dbb823d9 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Sat, 2 Dec 2023 22:45:45 +0900 Subject: [PATCH 24/35] fix: remove black corner of default book cover, correctly apply font weight --- .../ui/components/BottomSheetView.kt | 8 ++-- .../readability/ui/components/SettingTitle.kt | 4 +- .../ui/screens/book/AddBookView.kt | 2 +- .../ui/screens/book/BookListView.kt | 8 +++- .../com/example/readability/ui/theme/Type.kt | 37 +++++++++++++----- .../res/drawable/defaul_book_cover_image.png | Bin 393 -> 0 bytes .../res/drawable/default_book_cover_image.png | Bin 0 -> 279 bytes 7 files changed, 40 insertions(+), 19 deletions(-) delete mode 100644 frontend/app/src/main/res/drawable/defaul_book_cover_image.png create mode 100644 frontend/app/src/main/res/drawable/default_book_cover_image.png 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 1d21894..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 @@ -7,7 +7,6 @@ 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 @@ -92,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", @@ -226,8 +225,9 @@ fun BookInfo(modifier: Modifier = Modifier, coverImage: ImageBitmap?, title: Str contentDescription = "Book Image", modifier = Modifier .width(90.dp) - .height(140.dp), - contentScale = ContentScale.Fit, + .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/book/AddBookView.kt b/frontend/app/src/main/java/com/example/readability/ui/screens/book/AddBookView.kt index b60e050..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 @@ -107,7 +107,7 @@ fun AddBookView( var defaultImageString = "" val defaultUri = Uri.parse( - "android.resource://" + context.packageName + "/drawable/" + R.drawable.defaul_book_cover_image, + "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) 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 0d11beb..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 @@ -21,6 +21,7 @@ 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 @@ -46,6 +47,7 @@ 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 @@ -63,6 +65,7 @@ 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, ExperimentalMaterialApi::class) @Composable @@ -290,10 +293,11 @@ fun BookCard( .padding(16.dp, 16.dp, 0.dp, 16.dp) .height(100.dp) .width(64.dp) + .clip(RoundedCornerShape(4.dp)) .testTag(bookCardData.coverImage ?: ""), bitmap = bookCardData.coverImageData, contentDescription = "Book Cover Image", - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, ) } Column( @@ -348,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/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/res/drawable/defaul_book_cover_image.png b/frontend/app/src/main/res/drawable/defaul_book_cover_image.png deleted file mode 100644 index e92c5744b5e98e8437ef0e4be0ead4bff0ab4c92..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^4nUm1!3HGP9xZtRq&N#aB8wRqxP?KOkzv*x37{Zj zage(c!@6@aFM%AEbVpxD28NCO+zFa ze=i?@EW13$&Zh92S7qvqa+~-2j{pDob#w3h4H45`Z~M6__kP&3#Pc85pZ58C<@(mP zYnxW?@cqW1!cfH75W}P}o#BKk{g~I8WdyPrPl?CnPc7Vcu#$_fXaD!D@rL*1w!NPE jneT1-^zWs`xBsyR`D?SpoRPH$h8%;ZtDnm{r-UW|PVbA> 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 0000000000000000000000000000000000000000..8f4bfa3040accf6a73d0c0e26275e5cf55769e6d GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^4nUm1!3HGP9xZtRq&N#aB8wRqxP?KOkzv*x37{Zj zage(c!@6@aFM%AEbVpxD28NCO+~Ab zs+R5tPPx34l*Ik^Z1-MEq;C71sHR=S>#&aTgek*LHihd9Pox@Rm?va2&`Iz&W7h@N VEoN8Rb%E|?@O1TaS?83{1ORvwRlWcK literal 0 HcmV?d00001 From afed96cfaecce44b7e9eb5cb8636516fc0488dba Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Sat, 2 Dec 2023 22:50:55 +0900 Subject: [PATCH 25/35] fix: match AI progress to UI design, unify page index / percent calculation, remove stuck on fast viewer scroll --- .../readability/screens/ViewerScreenTest.kt | 4 +- .../data/viewer/PageSplitRepository.kt | 8 +- .../ui/components/RoundedRectButton.kt | 49 --- .../ui/screens/viewer/ViewerScreen.kt | 4 +- .../ui/screens/viewer/ViewerView.kt | 311 +++++++++++++----- .../ui/viewmodels/ViewerViewModel.kt | 14 +- 6 files changed, 240 insertions(+), 150 deletions(-) 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 a64aeb3..5298f95 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 @@ -414,7 +414,7 @@ class ViewerScreenTest { summary = "this is a summary", viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, - referenceLineHeight = 16f + referenceLineHeight = 16f, ) } @@ -432,7 +432,7 @@ class ViewerScreenTest { onBack = { onBack = true }, viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, - referenceLineHeight = 16f + referenceLineHeight = 16f, ) } 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 74ced45..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 diff --git a/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt b/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt index 412381e..0d7df3d 100644 --- a/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt +++ b/frontend/app/src/main/java/com/example/readability/ui/components/RoundedRectButton.kt @@ -2,12 +2,9 @@ package com.example.readability.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -19,22 +16,17 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times -import com.example.readability.ui.theme.ReadabilityTheme @Composable fun CircularProgressIndicatorInButton( @@ -154,44 +146,3 @@ fun RoundedRectFilledTonalButton( }, ) } - -@Preview -@Composable -fun CustomButtonPreview() { - ReadabilityTheme { - SummaryProgressBar( - progress = 0.73, - ) - } -} - - - -@Composable -fun SummaryProgressBar( - progress: Double = 0.0, - text: String = "Preparing AI... (${(progress * 100).toInt()}%)" -) { - ReadabilityTheme { - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .height(48.dp) - .background(MaterialTheme.colorScheme.surfaceContainer) - .fillMaxWidth(), - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .height(48.dp) - .background(MaterialTheme.colorScheme.secondaryContainer) - .fillMaxWidth(progress.toFloat()) - ) - Text( - modifier = Modifier - .align(Alignment.Center), - text = text, - ) - } - } -} 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 1d631a9..5efebb2 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 @@ -118,9 +118,9 @@ fun ViewerScreen( onPageDraw = { canvas, pageIndex -> viewerViewModel.drawPage(id, canvas, pageIndex, isDarkTheme) }, - onOverlayView = { + onUpdateSummaryProgress = { viewerViewModel.updateSummaryProgress(id) - } + }, ) } composableSharedAxis(ViewerScreens.Quiz.route, axis = SharedAxis.X) { 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 785755b..db75ec7 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 @@ -6,7 +6,10 @@ import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.res.Configuration import androidx.compose.animation.AnimatedContent +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.slideInHorizontally import androidx.compose.animation.slideOutHorizontally @@ -27,11 +30,13 @@ 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.height import androidx.compose.foundation.layout.padding 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 @@ -57,6 +62,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 @@ -82,16 +89,23 @@ 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 com.example.readability.ui.components.SummaryProgressBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel 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 @@ -106,7 +120,7 @@ fun ViewerView( onNavigateSettings: () -> Unit = {}, onNavigateQuiz: () -> Unit = {}, onNavigateSummary: () -> Unit = {}, - onOverlayView: () -> Unit = {}, + onUpdateSummaryProgress: suspend () -> Result = { Result.success(Unit) }, ) { var overlayVisible by remember { mutableStateOf(false) } val shrinkAnimation by animateFloatAsState( @@ -145,7 +159,8 @@ 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) } Box( modifier = Modifier @@ -178,17 +193,19 @@ fun ViewerView( true -> ViewerOverlay( modifier = Modifier .fillMaxSize(), - visible = overlayVisible, shrinkAnimation = shrinkAnimation, bookData = bookData, - pageSize = pageSize, + pageSplitData = pageSplitData, isNetworkConnected = isNetworkConnected, - onProgressChange = { onProgressChange(it.toDouble()) }, + onPageChanged = { pageIndex, changedByAnimation -> + pageChangedByAnimation = changedByAnimation + onProgressChange(pageSplitData?.getPageProgress(pageIndex) ?: 0.0) + }, onBack = { onBack() }, onNavigateSettings = { onNavigateSettings() }, onNavigateSummary = { onNavigateSummary() }, onNavigateQuiz = { onNavigateQuiz() }, - onOverlayView = { onOverlayView() }, + onUpdateSummaryProgress = onUpdateSummaryProgress, ) { if (bookData != null && pageSize > 0) { BookPager( @@ -200,10 +217,12 @@ fun ViewerView( onPageDraw(canvas, pageIndex) }, pageIndex = pageIndex, + pageChangedByAnimation = pageChangedByAnimation, overlayVisible = overlayVisible, shrinkAnimation = shrinkAnimation, - onPageChanged = { pageIndex -> - onProgressChange((pageIndex + 0.5) / pageSize) + onPageChanged = { pageIndex, changedByAnimation -> + pageChangedByAnimation = changedByAnimation + onProgressChange(pageSplitData?.getPageProgress(pageIndex) ?: 0.0) }, onOverlayVisibleChanged = { overlayVisible = it }, ) @@ -343,10 +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( @@ -364,19 +384,19 @@ fun BookPager( 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") @@ -423,27 +443,30 @@ 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 + println("[DEBUG] diff: $diff, timeDiff: $timeDiff") + 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) @@ -468,11 +491,11 @@ fun BookPager( ), pageSpacing = 32.dp * shrinkAnimation, userScrollEnabled = animationCount == 0, - ) { pageIndex -> + ) { index -> BookPage( pageSplitData = pageSplitData, pageSize = pageSize, - pageIndex = pageIndex, + pageIndex = index, onPageDraw = onPageDraw, ) } @@ -488,7 +511,7 @@ fun BookPage( ) { val padding = with(LocalDensity.current) { 16.dp.toPx() } - val ratio = + val aspectRatio = ((pageSplitData?.width ?: 0) + padding * 2) / ((pageSplitData?.height ?: 0) + padding * 2) Column( @@ -498,7 +521,7 @@ fun BookPage( Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(ratio) + .aspectRatio(aspectRatio) .background(MaterialTheme.colorScheme.background), ) { AnimatedContent( @@ -511,11 +534,10 @@ fun BookPage( modifier = Modifier.fillMaxSize(), ) { drawIntoCanvas { canvas -> - // red background - val ratio = size.width / (pageSplitData!!.width + 32.dp.toPx()) + val sizeRatio = size.width / (pageSplitData!!.width + 32.dp.toPx()) // scale with pivot left top scale( - scale = ratio, + scale = sizeRatio, pivot = Offset(0f, 0f), ) { translate(left = 16.dp.toPx(), top = 16.dp.toPx()) { @@ -578,27 +600,19 @@ fun ViewerSizeMeasurer(modifier: Modifier = Modifier, onPageSizeChanged: (Int, I @Composable fun ViewerOverlay( modifier: Modifier = Modifier, - visible: Boolean, shrinkAnimation: Float, bookData: Book?, - pageSize: Int, + pageSplitData: PageSplitData?, isNetworkConnected: Boolean, - onProgressChange: (Float) -> Unit, + onPageChanged: (Int, Boolean) -> Unit = { _, _ -> }, onBack: () -> Unit, onNavigateSettings: () -> Unit, onNavigateSummary: () -> Unit, onNavigateQuiz: () -> Unit, - onOverlayView: () -> Unit, + onUpdateSummaryProgress: suspend () -> Result, content: @Composable () -> Unit = {}, ) { - val pageIndex = minOf( - (pageSize * (bookData?.progress ?: 0.0)).toInt(), - pageSize - 1, - ) - - fun updateSummaryProgress() { - onOverlayView() - } + val pageIndex = pageSplitData?.getPageIndex(bookData?.progress ?: 0.0) ?: 0 Layout( modifier = modifier, @@ -651,51 +665,24 @@ fun ViewerOverlay( .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), - ) { - if (!isNetworkConnected) { - RoundedRectFilledTonalButton( - modifier = Modifier.weight(1f), - onClick = { onNavigateSummary() }, - enabled = false, - ) { - Text("No Internet Connection") - } - } else if (bookData?.summaryProgress!! < 1) { - updateSummaryProgress() - SummaryProgressBar( - progress = bookData.summaryProgress, - ) - } else if (pageIndex < 4) { - RoundedRectFilledTonalButton( - modifier = Modifier.weight(1f), - onClick = { onNavigateSummary() }, - enabled = false, - ) { - Text("4 pages required for Summary and Quiz") - } - } else { - 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) + } + }, ) } } @@ -705,7 +692,7 @@ fun ViewerOverlay( .fillMaxWidth() .background(color = MaterialTheme.colorScheme.background) .padding(vertical = 8.dp), - text = "${pageIndex + 1} / $pageSize", + text = "${pageIndex + 1} / ${pageSplitData?.pageSplits?.size ?: 0}", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) @@ -743,3 +730,159 @@ fun ViewerOverlay( } } } + +@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/viewmodels/ViewerViewModel.kt b/frontend/app/src/main/java/com/example/readability/ui/viewmodels/ViewerViewModel.kt index 584466a..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 @@ -10,9 +10,7 @@ import com.example.readability.data.viewer.SettingRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -48,16 +46,8 @@ class ViewerViewModel @Inject constructor( } } - fun updateSummaryProgress(bookId: Int) { - job?.cancel() - job = viewModelScope.launch { - withContext(Dispatchers.IO) { - while (bookRepository.getBook(bookId).first()?.summaryProgress!! < 1.0) { - bookRepository.updateSummaryProgress(bookId) - delay(1000) - } - } - } + suspend fun updateSummaryProgress(bookId: Int): Result { + return bookRepository.updateSummaryProgress(bookId) } fun getBookData(id: Int) = bookRepository.getBook(id) From 4ed8897dee5a676ae8a46c78c401e879649c6fb7 Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Sat, 2 Dec 2023 23:41:40 +0900 Subject: [PATCH 26/35] fix: unintended new line in summary generating --- .../example/readability/data/ai/SummaryRemoteDataSource.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 8169ee8..652fb2b 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 @@ -49,11 +49,13 @@ 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() ?: break if (line.startsWith("data:")) { var token = line.substring(6) - if (token.isEmpty()) token = "\n" + if (isFirstToken) isFirstToken = false + else if (token.isEmpty()) token = "\n" emit(token) } } From 3889d5178f444f7b05b9930c4c7eb86223af1e1a Mon Sep 17 00:00:00 2001 From: minjoojeon Date: Sun, 3 Dec 2023 01:26:54 +0900 Subject: [PATCH 27/35] feat: handle cases when summary and quiz failed during generation --- .../readability/ui/screens/viewer/QuizView.kt | 25 ++++++++++++++++++- .../ui/screens/viewer/SummaryView.kt | 21 ++++++++++++++++ .../ui/screens/viewer/ViewerScreen.kt | 9 ++++--- .../ui/viewmodels/QuizViewModel.kt | 12 ++++----- .../ui/viewmodels/SummaryViewModel.kt | 14 ++++------- 5 files changed, 61 insertions(+), 20 deletions(-) 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 90b59f6..5705db2 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,25 @@ 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) @@ -208,7 +229,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 836acbe..6cfdf53 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,6 +1,7 @@ package com.example.readability.ui.screens.viewer import android.graphics.Typeface +import android.widget.Toast import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -13,7 +14,9 @@ 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.ui.Modifier +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 @@ -22,6 +25,8 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp 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() } @@ -37,7 +42,23 @@ fun SummaryView( 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 = { 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 5efebb2..0499b8f 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 @@ -45,6 +45,7 @@ fun ViewerScreen( 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 @@ -80,13 +81,15 @@ fun ViewerScreen( } } } + LaunchedEffect(navBackStackEntry?.destination?.route){ + networkStatusViewModel.isConnected + } NavHost(navController = navController, startDestination = ViewerScreens.Viewer.route) { composableSharedAxis(ViewerScreens.Viewer.route, axis = SharedAxis.X) { val viewerViewModel: ViewerViewModel = hiltViewModel() val quizViewModel: QuizViewModel = hiltViewModel() val summaryViewModel: SummaryViewModel = hiltViewModel() - val networkStatusViewModel: NetworkStatusViewModel = hiltViewModel() val bookData by viewerViewModel.getBookData(id).collectAsState(initial = null) val pageSplitData by viewerViewModel.pageSplitData.collectAsState(initial = null) val isDarkTheme = isSystemInDarkTheme() @@ -98,7 +101,6 @@ fun ViewerScreen( isNetworkConnected = isNetworkConnected, onNavigateQuiz = { if (bookData != null) { - quizViewModel.loadQuiz(id, bookData!!.progress) navController.navigate(ViewerScreens.Quiz.route) } }, @@ -108,7 +110,6 @@ fun ViewerScreen( }, onNavigateSummary = { if (bookData != null) { - summaryViewModel.loadSummary(id, bookData!!.progress) navController.navigate(ViewerScreens.Summary.route) } }, @@ -141,6 +142,7 @@ fun ViewerScreen( ), ) }, + onLoadQuiz = {quizViewModel.loadQuiz(id)} ) } composableSharedAxis(ViewerScreens.QuizReport.route, axis = SharedAxis.X) { @@ -171,6 +173,7 @@ fun ViewerScreen( onBack = { navController.popBackStack() }, + onLoadSummary = { summaryViewModel.loadSummary(id) } ) } } 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..ff60fa8 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 37531f5..779e93c 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,13 +1,12 @@ 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 @@ -15,17 +14,14 @@ 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) } } From c4faba45552074beaaf7cf3cf918479b863f326a Mon Sep 17 00:00:00 2001 From: Hyungoo Kwon Date: Sun, 3 Dec 2023 05:21:52 +0900 Subject: [PATCH 28/35] test: BookRepository Unit tests --- .../readability/data/user/UserRepository.kt | 9 - .../ui/screens/viewer/ViewerView.kt | 1 - .../ui/models/BookRepositoryTest.kt | 377 +++++++++++++++++- 3 files changed, 368 insertions(+), 19 deletions(-) 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 5bf942e..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 @@ -14,15 +14,6 @@ class UserRepository @Inject constructor( ) { val user = userDao.get() -// init { -// // load book list from database -// runBlocking { -// withContext(Dispatchers.IO) { -// val user = userDao.get() -// } -// } -// } - suspend fun signIn(email: String, password: String): Result { if (!networkStatusRepository.isConnected) { return Result.failure(Exception("Network not connected")) 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 db75ec7..f2feaee 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 @@ -99,7 +99,6 @@ 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.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch 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 17e3863..fb2499f 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,6 +1,8 @@ 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 @@ -49,17 +51,34 @@ class BookRepositoryTest { fun init() = runBlocking { `when`(bookDao.getAll()).thenReturn( listOf( - BookEntity( - bookId = 1, - title = "test", - author = "test", - progress = 0.0, - coverImage = "test", - content = "test", - summaryProgress = 0.5, - ), + 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( @@ -87,4 +106,344 @@ 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) + } } From 6e641ea78df0594c4135781e22fe3675568d40c7 Mon Sep 17 00:00:00 2001 From: Ikjun Choi <1350adwx@snu.ac.kr> Date: Sun, 3 Dec 2023 20:52:06 +0900 Subject: [PATCH 29/35] Test: add settingsView test, fix current tests to match implementation --- .../readability/screens/AuthScreenTest.kt | 424 ++---------------- .../readability/screens/BookScreenTest.kt | 2 + .../readability/screens/SettingScreenTest.kt | 237 +++++++++- .../readability/screens/ViewerScreenTest.kt | 72 ++- .../data/ai/SummaryRemoteDataSource.kt | 5 +- .../ui/screens/settings/SettingsView.kt | 8 +- .../readability/ui/screens/viewer/QuizView.kt | 8 +- .../ui/screens/viewer/SummaryView.kt | 8 +- .../ui/screens/viewer/ViewerScreen.kt | 6 +- .../ui/viewmodels/QuizViewModel.kt | 2 +- .../ui/viewmodels/SummaryViewModel.kt | 2 +- .../ui/models/BookRepositoryTest.kt | 212 +++++++-- 12 files changed, 541 insertions(+), 445 deletions(-) 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 182d95b..061d1be 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 @@ -47,6 +47,7 @@ class BookScreenTest { content = "", progress = 0.1, coverImage = "asd", + summaryProgress = 0.1, ), BookCardData( id = 2, @@ -55,6 +56,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..c0bb250 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 5298f95..5f57a45 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 @@ -4,7 +4,9 @@ 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 @@ -47,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) { @@ -73,15 +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( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = bookData, pageSplitData = pageSplitData, onProgressChange = { - openBoatBookData = openBoatBookData.copy(progress = it) + bookData = bookData.copy(progress = it) }, ) with(LocalDensity.current) { @@ -95,22 +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( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = bookData, pageSplitData = pageSplitData, onProgressChange = { - openBoatBookData = openBoatBookData.copy(progress = it) + bookData = bookData.copy(progress = it) }, ) with(LocalDensity.current) { @@ -124,22 +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( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = bookData, pageSplitData = pageSplitData, onProgressChange = { - openBoatBookData = openBoatBookData.copy(progress = it) + bookData = bookData.copy(progress = it) }, ) with(LocalDensity.current) { @@ -158,14 +161,15 @@ class ViewerScreenTest { @Test fun viewerView_LeftSwipe() { - openBoatBookData = openBoatBookData.copy(progress = 0.0) + + var bookData = openBoatBookData.copy(progress = 0.0) composeTestRule.setContent { ViewerView( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = bookData, pageSplitData = pageSplitData, onProgressChange = { - openBoatBookData = openBoatBookData.copy(progress = it) + bookData = bookData.copy(progress = it) }, ) } @@ -174,20 +178,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( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = bookData, pageSplitData = pageSplitData, onProgressChange = { - openBoatBookData = openBoatBookData.copy(progress = it) + bookData = bookData.copy(progress = it) }, ) } @@ -196,7 +200,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 @@ -259,6 +263,7 @@ class ViewerScreenTest { assert(onNavigateSettings) } + @OptIn(ExperimentalTestApi::class) @Test fun viewerView_GenerateSummaryClicked() { var width = 0f @@ -267,7 +272,7 @@ class ViewerScreenTest { composeTestRule.setContent { ViewerView( isNetworkConnected = true, - bookData = openBoatBookData, + bookData = openBoatBookData.copy(progress = 0.5), pageSplitData = pageSplitData, onNavigateSummary = { onNavigateSummary = true @@ -283,12 +288,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 @@ -313,6 +320,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 @@ -407,6 +415,26 @@ 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 { @@ -415,6 +443,7 @@ class ViewerScreenTest { viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, referenceLineHeight = 16f, + onLoadSummary = { Result.success(Unit) } ) } @@ -433,6 +462,7 @@ class ViewerScreenTest { viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, referenceLineHeight = 16f, + onLoadSummary = { Result.success(Unit) } ) } 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 652fb2b..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 @@ -54,8 +54,9 @@ class SummaryRemoteDataSource @Inject constructor( val line = it.readLine() ?: break if (line.startsWith("data:")) { var token = line.substring(6) - if (isFirstToken) isFirstToken = false - else if (token.isEmpty()) token = "\n" + if (isFirstToken) { + isFirstToken = false + } else if (token.isEmpty()) token = "\n" emit(token) } } 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 bd3d699..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 @@ -56,7 +56,9 @@ fun SettingsView( ) { SettingTitle(text = "General") ListItem( - modifier = Modifier.clickable { + modifier = Modifier.clickable( + onClickLabel = "Account Settings", + ) { onNavigateAccountSetting() }, leadingContent = { @@ -94,7 +96,9 @@ fun SettingsView( }, ) ListItem( - modifier = Modifier.clickable { + modifier = Modifier.clickable( + onClickLabel = "Viewer Settings", + ) { onNavigateViewer() }, headlineContent = { 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 5705db2..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 @@ -100,20 +100,22 @@ fun QuizView( quizLoadState: QuizLoadState, onBack: () -> Unit = {}, onNavigateReport: (Int) -> Unit = {}, - onLoadQuiz: suspend () -> Result = { Result.success(Unit) } + onLoadQuiz: suspend () -> Result = { Result.success(Unit) }, ) { val pagerScope = rememberCoroutineScope() val pagerState = rememberPagerState(initialPage = 0) { quizList.size } val context = LocalContext.current LaunchedEffect( - Unit + Unit, ) { withContext(Dispatchers.IO) { onLoadQuiz() }.onFailure { Toast.makeText( - context, "Failed to generate quiz\n:${it.message}", Toast.LENGTH_SHORT + context, + "Failed to generate quiz\n:${it.message}", + Toast.LENGTH_SHORT, ).show() onBack() } 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 6cfdf53..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 @@ -42,18 +42,20 @@ fun SummaryView( typeface: Typeface?, referenceLineHeight: Float, onBack: () -> Unit = {}, - onLoadSummary: suspend () -> Result + onLoadSummary: suspend () -> Result, ) { val context = LocalContext.current LaunchedEffect( - Unit + Unit, ) { withContext(Dispatchers.IO) { onLoadSummary() }.onFailure { Toast.makeText( - context, "Failed to generate summary\n:${it.message}", Toast.LENGTH_SHORT + context, + "Failed to generate summary\n:${it.message}", + Toast.LENGTH_SHORT, ).show() onBack() } 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 0499b8f..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 @@ -81,7 +81,7 @@ fun ViewerScreen( } } } - LaunchedEffect(navBackStackEntry?.destination?.route){ + LaunchedEffect(navBackStackEntry?.destination?.route) { networkStatusViewModel.isConnected } @@ -142,7 +142,7 @@ fun ViewerScreen( ), ) }, - onLoadQuiz = {quizViewModel.loadQuiz(id)} + onLoadQuiz = { quizViewModel.loadQuiz(id) }, ) } composableSharedAxis(ViewerScreens.QuizReport.route, axis = SharedAxis.X) { @@ -173,7 +173,7 @@ fun ViewerScreen( onBack = { navController.popBackStack() }, - onLoadSummary = { summaryViewModel.loadSummary(id) } + onLoadSummary = { summaryViewModel.loadSummary(id) }, ) } } 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 ff60fa8..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 @@ -10,7 +10,7 @@ import javax.inject.Inject @HiltViewModel class QuizViewModel @Inject constructor( private val quizRepository: QuizRepository, - private val bookRepository: BookRepository + private val bookRepository: BookRepository, ) : ViewModel() { val quizList = quizRepository.quizList 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 779e93c..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 @@ -14,7 +14,7 @@ class SummaryViewModel @Inject constructor( private val summaryRepository: SummaryRepository, private val settingRepository: SettingRepository, private val fontDataSource: FontDataSource, - private val bookRepository: BookRepository + private val bookRepository: BookRepository, ) : ViewModel() { val summary = summaryRepository.summary val viewerStyle = settingRepository.viewerStyle 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 fb2499f..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 @@ -51,9 +51,33 @@ class BookRepositoryTest { fun init() = runBlocking { `when`(bookDao.getAll()).thenReturn( listOf( - 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,), + 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( @@ -67,7 +91,8 @@ class BookRepositoryTest { coverImage = "test", content = "test", summaryProgress = 0.5, - ), Book( + ), + Book( bookId = 2, title = "test", author = "test", @@ -75,9 +100,9 @@ class BookRepositoryTest { coverImage = "test", content = "test", summaryProgress = 1.0, - ) - ) - ) + ), + ), + ), ) `when`(networkStatusRepository.isConnected).thenReturn(true) `when`(userRepository.getAccessToken()).thenReturn("test") @@ -114,8 +139,9 @@ class BookRepositoryTest { val summaryProgress = 0.5 `when`( bookRemoteDataSource.getSummaryProgress( - "test", bookId - ) + "test", + bookId, + ), ).thenReturn(Result.success(summaryProgress.toString())) doNothing().`when`(bookDao).updateSummaryProgress(bookId, summaryProgress) @@ -153,7 +179,7 @@ class BookRepositoryTest { coverImage = "test", content = "test", summaryProgress = 0.5, - ) + ), ) // Act @@ -185,7 +211,16 @@ class BookRepositoryTest { 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,) + 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) @@ -194,23 +229,71 @@ class BookRepositoryTest { `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,), - ) - ) + 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,), + 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,), + 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,), + BookEntity( + bookId = 3, + title = "test", + author = "test", + progress = 0.7, + coverImage = "test", + content = "test", + summaryProgress = 1.0, + ), ) `when`(bookDao.getBook(4)).thenReturn( - null + null, ) // Act @@ -351,24 +434,73 @@ class BookRepositoryTest { title = "test", content = "test", author = "test", - coverImage = "" + coverImage = "", ) - val insertedBookEntity = BookEntity(bookId = 4, title = "test", author = "test", progress = 0.0, coverImage = "test", content = "test", summaryProgress = 1.0,) + 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,), + 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,), - ) - ) + 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)) @@ -391,7 +523,7 @@ class BookRepositoryTest { title = "test", content = "test", author = "test", - coverImage = "" + coverImage = "", ) `when`(bookRemoteDataSource.addBook("test", addBookRequest)).thenReturn(Result.failure(Throwable("test"))) @@ -413,10 +545,26 @@ class BookRepositoryTest { 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,), + 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,), + 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) From 58b423521e7e7b4e2cb72162d7535077e8fe3f2b Mon Sep 17 00:00:00 2001 From: Min Su Yoon Date: Sun, 3 Dec 2023 23:28:21 +0900 Subject: [PATCH 30/35] refactor: apply proxy pattern to summary and quiz generation logic --- backend/llama/constants.py | 192 ++++++++++ backend/llama/custom_type.py | 527 ++++++++++++++++++++++++++++ backend/llama/preprocess_summary.py | 190 ++++------ backend/llama/run_quiz.py | 56 +-- backend/llama/run_summary.py | 61 ++-- backend/routers/ai.py | 73 ++-- 6 files changed, 915 insertions(+), 184 deletions(-) create mode 100644 backend/llama/constants.py 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 1c5c214..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, @@ -20,16 +21,6 @@ def completion_with_backoff(**kwargs): 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. @@ -39,20 +30,6 @@ 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. - -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. -''' def get_book_content_url(books_db, book_id): cursor = books_db.cursor() cursor.execute(f"SELECT content FROM Books where id = {book_id}") @@ -87,6 +64,10 @@ 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 @@ -119,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 @@ -136,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]]) @@ -144,72 +133,54 @@ def split_list(input_list): return output_list -def reduce_multiple_summaries_to_one(books_db, book_id, 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: - update_num_current_inference(books_db, book_id) - 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: - update_num_current_inference(books_db, book_id) - 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(books_db, book_id, 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(books_db, book_id, 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] @@ -225,39 +196,34 @@ def generate_summary_tree(book_id, story): 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: - update_num_current_inference(books_db, book_id) - 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(books_db, book_id, summaries_list) + 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" update_summary_path_url(books_db, book_id, summary_path_url) @@ -268,44 +234,44 @@ 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() \ No newline at end of file +# 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..f2f1289 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_quizzes_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_quizzes_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()) From 938f3296126ad2550f96c61ee01c495ecd1f0ed4 Mon Sep 17 00:00:00 2001 From: Chaemin2001 Date: Sun, 3 Dec 2023 23:32:51 +0900 Subject: [PATCH 31/35] feat: add delete user, change password endpoints, book current inference endpoint; fix: database result indexing error for get book detail --- backend/routers/book.py | 34 ++++++++++++++++++++++++++----- backend/routers/user.py | 45 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/backend/routers/book.py b/backend/routers/book.py index 44c005f..999c76c 100644 --- a/backend/routers/book.py +++ b/backend/routers/book.py @@ -60,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} @@ -90,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], } @@ -214,4 +214,28 @@ def book_delete(book_id: str, email: str = Depends(get_user_with_access_token)): ) books_db.cursor().execute(f"DELETE FROM Books WHERE id = '{book_id}'") books_db.commit() - return {} \ No newline at end of file + 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 4d00ec2..c192def 100644 --- a/backend/routers/user.py +++ b/backend/routers/user.py @@ -197,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 ): @@ -265,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 update_user_password(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 {} From 043e80580b3cda4ce1b3f325a546fce131dbfbe7 Mon Sep 17 00:00:00 2001 From: Min Su Yoon Date: Sun, 3 Dec 2023 23:34:54 +0900 Subject: [PATCH 32/35] test: add split list, num_inference tests --- backend/tests/test_summary.py | 70 ++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) 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") From 46c5b694b3a4808355be71775c834b1958abb4e4 Mon Sep 17 00:00:00 2001 From: Min Su Yoon Date: Sun, 3 Dec 2023 23:53:50 +0900 Subject: [PATCH 33/35] fix: fix typo in quiz request function call --- backend/routers/ai.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routers/ai.py b/backend/routers/ai.py index f2f1289..80ac2d1 100644 --- a/backend/routers/ai.py +++ b/backend/routers/ai.py @@ -102,7 +102,7 @@ def ai_quiz(request: Request, book_id: str, progress: float, email: str = Depend if result[0][8] == 1: async def event_generator(): - for delta_content, finished in ai_backend.get_quizzes_from_text(progress, book_content_url): + for delta_content, finished in ai_backend.get_quiz_from_text(progress, book_content_url): if await request.is_disconnected(): return yield { @@ -113,7 +113,7 @@ async def event_generator(): summary_tree_url = os.path.join(user_dirname,result[0][7]) async def event_generator(): - for delta_content, finished in ai_backend.get_quizzes_from_intermediate(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 { From 2863762468afa5e8f8f28213a081d999b103957d Mon Sep 17 00:00:00 2001 From: Ikjun Choi <1350adwx@snu.ac.kr> Date: Tue, 5 Dec 2023 15:26:13 +0900 Subject: [PATCH 34/35] fix: run ktlint on codes --- .../readability/screens/SettingScreenTest.kt | 6 ++-- .../readability/screens/ViewerScreenTest.kt | 9 +++--- .../ui/screens/viewer/ViewerView.kt | 32 +++++++++++++------ 3 files changed, 30 insertions(+), 17 deletions(-) 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 c0bb250..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 @@ -136,7 +136,7 @@ class SettingScreenTest { }, onNavigateIntro = { onNavigateIntroCalled = true - } + }, ) } composeTestRule.onNodeWithText("Delete Account").performClick() @@ -182,7 +182,7 @@ class SettingScreenTest { onPasswordSubmitted = { onPasswordSubmitted = true Result.success(Unit) - } + }, ) } composeTestRule.onNodeWithTag("PasswordTextField").performTextInput("testtest") @@ -238,7 +238,7 @@ class SettingScreenTest { fun viewerView_isDisplayed() { composeTestRule.setContent { ViewerView( - viewerStyle = ViewerStyle() + viewerStyle = ViewerStyle(), ) } 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 5f57a45..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 @@ -51,7 +51,7 @@ class ViewerScreenTest { contentData = content, progress = 0.5, coverImage = "", - summaryProgress = 1.0 + summaryProgress = 1.0, ) val pageSplits = mutableListOf() for (i in 0..content.length step 100) { @@ -161,7 +161,6 @@ class ViewerScreenTest { @Test fun viewerView_LeftSwipe() { - var bookData = openBoatBookData.copy(progress = 0.0) composeTestRule.setContent { ViewerView( @@ -428,7 +427,7 @@ class ViewerScreenTest { }, onBack = { onBack = true - } + }, ) } @@ -443,7 +442,7 @@ class ViewerScreenTest { viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, referenceLineHeight = 16f, - onLoadSummary = { Result.success(Unit) } + onLoadSummary = { Result.success(Unit) }, ) } @@ -462,7 +461,7 @@ class ViewerScreenTest { viewerStyle = ViewerStyle(), typeface = Typeface.DEFAULT, referenceLineHeight = 16f, - onLoadSummary = { Result.success(Unit) } + onLoadSummary = { Result.success(Unit) }, ) } 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 f2feaee..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 @@ -5,6 +5,7 @@ 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.core.animateFloat import androidx.compose.animation.core.animateFloatAsState @@ -453,7 +454,6 @@ fun BookPager( } val diff = abs(upEvent.position.x - downEvent.position.x) val timeDiff = upEvent.uptimeMillis - downEvent.uptimeMillis - println("[DEBUG] diff: $diff, timeDiff: $timeDiff") if (diff < 25 && timeDiff < 150) { if (downEvent.position.x < 0.25 * width) { onPageChanged(maxOf(pageIndex - 1, 0), true) @@ -513,6 +513,10 @@ fun BookPage( 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(), verticalArrangement = Arrangement.Center, @@ -533,14 +537,24 @@ fun BookPage( modifier = Modifier.fillMaxSize(), ) { drawIntoCanvas { canvas -> - val sizeRatio = size.width / (pageSplitData!!.width + 32.dp.toPx()) - // scale with pivot left top - scale( - scale = sizeRatio, - 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) + } } } } From d6160f3abbe990bb256514b7349fcea857d9861b Mon Sep 17 00:00:00 2001 From: Min Su Yoon Date: Tue, 5 Dec 2023 15:56:24 +0900 Subject: [PATCH 35/35] fix: delete user function typo --- backend/routers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routers/user.py b/backend/routers/user.py index c192def..4b8fc64 100644 --- a/backend/routers/user.py +++ b/backend/routers/user.py @@ -288,7 +288,7 @@ def update_user_password(password: str, email: str = Depends(get_user_with_acces return {} @user.delete("/user/delete_user") -def update_user_password(email: str = Depends(get_user_with_access_token)): +def delete_user(email: str = Depends(get_user_with_access_token)): if email is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED,