diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..cd7974c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LOCALIZATION_README.md b/LOCALIZATION_README.md new file mode 100644 index 0000000..f330f5f --- /dev/null +++ b/LOCALIZATION_README.md @@ -0,0 +1,175 @@ +# Multi-Language Localization Setup + +This project now supports multiple languages: English, Portuguese, and Spanish. + +## 📁 File Structure + +``` +app/src/main/res/ +├── values/ +│ └── strings.xml # English strings (default) +├── values-pt/ +│ └── strings.xml # Portuguese strings +└── values-es/ + └── strings.xml # Spanish strings +``` + +## 🌐 How It Works + +### Automatic Language Detection +- The app automatically detects the device's language setting +- If the device is set to Portuguese, it will use `values-pt/strings.xml` +- If the device is set to Spanish, it will use `values-es/strings.xml` +- If the device is set to any other language, it will use `values/strings.xml` (English) + +### String Resources +All user-facing text in the app is now localized: +- **UI Labels**: Buttons, titles, descriptions +- **Dialog Messages**: Confirmations, errors, information +- **Content Descriptions**: Accessibility labels for screen readers +- **Settings**: Configuration options and descriptions + +## 🔧 Implementation Details + +### Compose UI +The app uses `stringResource()` calls throughout the Compose UI: +```kotlin +Text(stringResource(R.string.app_name)) +Text(stringResource(R.string.settings)) +``` + +### Android Manifest +The app name is localized: +```xml +android:label="@string/app_name" +``` + +## 📝 Adding New Strings + +### 1. Add to English (values/strings.xml) +```xml +New Feature +``` + +### 2. Add to Portuguese (values-pt/strings.xml) +```xml +Nova Funcionalidade +``` + +### 3. Add to Spanish (values-es/strings.xml) +```xml +Nueva Funcionalidad +``` + +### 4. Use in Code +```kotlin +Text(stringResource(R.string.new_feature_title)) +``` + +## 🌍 Supported Languages + +| Language | Code | File | Status | +|----------|------|------|---------| +| English | `en` | `values/strings.xml` | ✅ Complete | +| Portuguese | `pt` | `values-pt/strings.xml` | ✅ Complete | +| Spanish | `es` | `values-es/strings.xml` | ✅ Complete | + +## 🚀 Adding More Languages + +To add another language (e.g., French): + +1. Create `app/src/main/res/values-fr/strings.xml` +2. Copy all strings from `values/strings.xml` +3. Translate each string to French +4. The app will automatically support French when the device language is set to French + +## 📱 Testing Localization + +### Method 1: Device Settings +1. Go to Device Settings → System → Languages & input → Languages +2. Add your desired language and move it to the top +3. Restart the app +4. All text should now appear in the selected language + +### Method 2: Android Studio +1. Open Android Studio +2. Go to Run → Edit Configurations +3. In "Launch Options", set "Language" to your desired language code (e.g., "es" for Spanish) +4. Run the app - it will launch in the selected language + +### Method 3: ADB Command +```bash +# For Spanish +adb shell setprop persist.sys.language es +adb shell setprop persist.sys.country ES + +# For Portuguese +adb shell setprop persist.sys.language pt +adb shell setprop persist.sys.country BR + +# For English +adb shell setprop persist.sys.language en +adb shell setprop persist.sys.country US +``` + +## 🔍 Common Issues + +### Missing Translations +If a string is missing from a language file: +- The app will fall back to English +- Check the logcat for warnings about missing resources + +### String Format Issues +Some strings might need special handling for different languages: +- Date formats +- Number formats +- Pluralization rules +- Gender-specific language variations + +### RTL Support +All supported languages (English, Portuguese, Spanish) are left-to-right (LTR), so no special RTL handling is needed. + +## 📚 Best Practices + +1. **Never hardcode strings** - Always use `stringResource()` +2. **Keep translations up to date** - Update all language files when adding new features +3. **Test on real devices** - Emulator language switching can be unreliable +4. **Use descriptive names** - String keys should be self-explanatory +5. **Group related strings** - Use comments to organize strings by feature +6. **Consider cultural differences** - Some phrases may need adaptation beyond direct translation + +## 🛠️ Maintenance + +### Regular Tasks +- Review new strings added to English +- Translate new strings to Portuguese and Spanish +- Test all languages regularly +- Update this documentation when adding new languages + +### Quality Assurance +- Have native speakers review translations +- Test on different Android versions +- Verify accessibility with screen readers +- Check for text overflow in different languages +- Consider regional variations (e.g., European vs Latin American Spanish) + +## 🌎 Regional Considerations + +### Spanish +- **Spain (es-ES)**: Uses "vosotros" form and European Spanish vocabulary +- **Latin America (es-419)**: Uses "ustedes" form and Latin American vocabulary +- Current implementation uses neutral Latin American Spanish + +### Portuguese +- **Portugal (pt-PT)**: European Portuguese vocabulary and pronunciation +- **Brazil (pt-BR)**: Brazilian Portuguese vocabulary and pronunciation +- Current implementation uses Brazilian Portuguese + +## 📖 Resources + +- [Android Localization Guide](https://developer.android.com/guide/topics/resources/localization) +- [Android String Resources](https://developer.android.com/guide/topics/resources/string-resource) +- [Compose Localization](https://developer.android.com/jetpack/compose/localization) +- [Material Design Localization](https://material.io/design/usability/bidirectionality.html) +- [Spanish Language Resources](https://www.rae.es/) +- [Portuguese Language Resources](https://www.priberam.pt/) diff --git a/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSource.kt b/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSource.kt index 6b7c770..9c50419 100644 --- a/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSource.kt +++ b/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSource.kt @@ -2,6 +2,7 @@ package com.mobyle.abbay.data.datasource.local.books import com.mobyle.abbay.data.model.BookFileEntity import com.mobyle.abbay.data.model.MultipleBooksEntity +import com.model.Book import kotlinx.coroutines.flow.Flow interface BooksLocalDataSource { @@ -9,13 +10,12 @@ interface BooksLocalDataSource { fun observeBookFilesList(): Flow> - suspend fun getBookFilesList(): List + suspend fun getBooksList(): List - suspend fun addBookFileList(filesList: List) - - suspend fun getMultipleBooksList(): List - - suspend fun addMultipleBooksList(booksList: List) + suspend fun upsertBooksList( + singleFileBooksList: List, + multipleBooksList: List + ) suspend fun deleteBook(id: String) @@ -31,7 +31,7 @@ interface BooksLocalDataSource { fun getBooksFolder(): String? - fun hasShownReloadGuide() : Boolean + fun hasShownReloadGuide(): Boolean fun setReloadGuideAsShown() diff --git a/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSourceImpl.kt b/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSourceImpl.kt index 8c9befe..5e29c03 100644 --- a/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSourceImpl.kt +++ b/app/src/main/java/com/mobyle/abbay/data/datasource/local/books/BooksLocalDataSourceImpl.kt @@ -5,6 +5,7 @@ import com.mobyle.abbay.data.datasource.local.keystore.KeyValueStore import com.mobyle.abbay.data.datasource.local.keystore.KeyValueStoreKeys import com.mobyle.abbay.data.model.BookFileEntity import com.mobyle.abbay.data.model.MultipleBooksEntity +import com.model.Book import javax.inject.Inject class BooksLocalDataSourceImpl @Inject constructor( @@ -16,16 +17,17 @@ class BooksLocalDataSourceImpl @Inject constructor( override fun observeBookFilesList() = booksDao.observeBookFilesList() - override suspend fun getBookFilesList(): List = booksDao.getBookFilesList() - - override suspend fun addBookFileList(filesList: List) = - booksDao.insertBookFilesList(filesList) - - override suspend fun getMultipleBooksList(): List = - booksDao.getMultipleBooksList() + override suspend fun upsertBooksList( + singleFileBooksList: List, + multipleBooksList: List + ) { + booksDao.upsertBooksList( + multipleBooksList = multipleBooksList, + singleFileBooksList = singleFileBooksList + ) + } - override suspend fun addMultipleBooksList(booksList: List) = - booksDao.insertMultipleBooksList(booksList) + override suspend fun getBooksList(): List = booksDao.getBooksList() override suspend fun deleteBook(id: String) { booksDao.deleteBookFile(id) @@ -36,8 +38,7 @@ class BooksLocalDataSourceImpl @Inject constructor( } override suspend fun clearBooks() { - booksDao.deleteAllBookFiles() - booksDao.deleteAllMultipleBooks() + booksDao.deleteAllBooks() keyValueStore.deleteAllBookInformation() } diff --git a/app/src/main/java/com/mobyle/abbay/data/datasource/local/daos/BooksDao.kt b/app/src/main/java/com/mobyle/abbay/data/datasource/local/daos/BooksDao.kt index 6768760..dcffe7a 100644 --- a/app/src/main/java/com/mobyle/abbay/data/datasource/local/daos/BooksDao.kt +++ b/app/src/main/java/com/mobyle/abbay/data/datasource/local/daos/BooksDao.kt @@ -4,8 +4,11 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction +import com.mobyle.abbay.data.mappers.toDomain import com.mobyle.abbay.data.model.BookFileEntity import com.mobyle.abbay.data.model.MultipleBooksEntity +import com.model.Book import kotlinx.coroutines.flow.Flow @Dao @@ -45,4 +48,30 @@ interface BooksDao { @Query("UPDATE MultipleBooksEntity SET progress = :progress, currentBookPosition = :currentPosition WHERE id = :id") suspend fun updateMultipleBookProgress(id: String, currentPosition: Int, progress: Long) + + @Transaction + suspend fun deleteAllBooks( + ) { + deleteAllBookFiles() + deleteAllMultipleBooks() + } + + @Transaction + suspend fun upsertBooksList( + multipleBooksList: List, + singleFileBooksList: List + ) { + deleteAllBooks() + insertMultipleBooksList(multipleBooksList) + insertBookFilesList(singleFileBooksList) + } + + @Transaction + suspend fun getBooksList(): List { + return getBookFilesList().map { + it.toDomain() + } + getMultipleBooksList().map { + it.toDomain() + } + } } diff --git a/app/src/main/java/com/mobyle/abbay/data/mappers/DomainToEntityMappers.kt b/app/src/main/java/com/mobyle/abbay/data/mappers/DomainToEntityMappers.kt index 91989b7..1367ccd 100644 --- a/app/src/main/java/com/mobyle/abbay/data/mappers/DomainToEntityMappers.kt +++ b/app/src/main/java/com/mobyle/abbay/data/mappers/DomainToEntityMappers.kt @@ -1,8 +1,10 @@ package com.mobyle.abbay.data.mappers import com.mobyle.abbay.data.model.BookFileEntity +import com.mobyle.abbay.data.model.BookTypeEntity import com.mobyle.abbay.data.model.MultipleBooksEntity import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -20,7 +22,8 @@ fun MultipleBooks.toEntity(): MultipleBooksEntity = MultipleBooksEntity( duration = duration, currentBookPosition = currentBookPosition, speed = speed, - hasError = hasError + hasError = hasError, + type = type.toEntity() ) fun BookFile.toEntity() = BookFileEntity( @@ -31,7 +34,13 @@ fun BookFile.toEntity() = BookFileEntity( progress = progress, duration = duration, speed = speed, - hasError = hasError + hasError = hasError, + type = type.toEntity() ) +private fun BookType.toEntity(): BookTypeEntity = when (this) { + BookType.FILE -> BookTypeEntity.FILE + else -> BookTypeEntity.FOLDER +} + private fun BookFileEntity.toJson() = Json.encodeToString(this) \ No newline at end of file diff --git a/app/src/main/java/com/mobyle/abbay/data/mappers/EntityToDomainMappers.kt b/app/src/main/java/com/mobyle/abbay/data/mappers/EntityToDomainMappers.kt index f419f22..62bc37f 100644 --- a/app/src/main/java/com/mobyle/abbay/data/mappers/EntityToDomainMappers.kt +++ b/app/src/main/java/com/mobyle/abbay/data/mappers/EntityToDomainMappers.kt @@ -1,8 +1,10 @@ package com.mobyle.abbay.data.mappers import com.mobyle.abbay.data.model.BookFileEntity +import com.mobyle.abbay.data.model.BookTypeEntity import com.mobyle.abbay.data.model.MultipleBooksEntity import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import kotlinx.serialization.json.Json @@ -18,7 +20,8 @@ fun MultipleBooksEntity.toDomain(): MultipleBooks = MultipleBooks( duration = duration, currentBookPosition = currentBookPosition, speed = speed, - hasError = hasError + hasError = hasError, + type = type.toDomain(), ) fun BookFileEntity.toDomain() = BookFile( @@ -29,7 +32,13 @@ fun BookFileEntity.toDomain() = BookFile( progress = progress, duration = duration, speed = speed, - hasError = hasError + hasError = hasError, + type = type.toDomain(), ) +private fun BookTypeEntity.toDomain(): BookType = when(this) { + BookTypeEntity.FILE -> BookType.FILE + else -> BookType.FOLDER +} + private fun String.toEntity() = Json.decodeFromString(this) \ No newline at end of file diff --git a/app/src/main/java/com/mobyle/abbay/data/model/BookFileEntity.kt b/app/src/main/java/com/mobyle/abbay/data/model/BookFileEntity.kt index 1979369..dadebe1 100644 --- a/app/src/main/java/com/mobyle/abbay/data/model/BookFileEntity.kt +++ b/app/src/main/java/com/mobyle/abbay/data/model/BookFileEntity.kt @@ -14,5 +14,6 @@ class BookFileEntity( val progress: Long, val duration: Long, val speed: Float, - val hasError: Boolean + val hasError: Boolean, + val type: BookTypeEntity ) \ No newline at end of file diff --git a/app/src/main/java/com/mobyle/abbay/data/model/BookTypeEntity.kt b/app/src/main/java/com/mobyle/abbay/data/model/BookTypeEntity.kt new file mode 100644 index 0000000..4690c8b --- /dev/null +++ b/app/src/main/java/com/mobyle/abbay/data/model/BookTypeEntity.kt @@ -0,0 +1,6 @@ +package com.mobyle.abbay.data.model + +enum class BookTypeEntity { + FOLDER, + FILE +} \ No newline at end of file diff --git a/app/src/main/java/com/mobyle/abbay/data/model/MultipleBooksEntity.kt b/app/src/main/java/com/mobyle/abbay/data/model/MultipleBooksEntity.kt index c9f5f2d..05e6663 100644 --- a/app/src/main/java/com/mobyle/abbay/data/model/MultipleBooksEntity.kt +++ b/app/src/main/java/com/mobyle/abbay/data/model/MultipleBooksEntity.kt @@ -15,5 +15,6 @@ data class MultipleBooksEntity( val duration: Long, val currentBookPosition: Int = 0, val speed: Float, - val hasError: Boolean + val hasError: Boolean, + val type: BookTypeEntity ) \ No newline at end of file diff --git a/app/src/main/java/com/mobyle/abbay/data/repository/BooksRepositoryImpl.kt b/app/src/main/java/com/mobyle/abbay/data/repository/BooksRepositoryImpl.kt index baccbbe..d14a98a 100644 --- a/app/src/main/java/com/mobyle/abbay/data/repository/BooksRepositoryImpl.kt +++ b/app/src/main/java/com/mobyle/abbay/data/repository/BooksRepositoryImpl.kt @@ -34,23 +34,17 @@ class BooksRepositoryImpl @Inject constructor( } override suspend fun getBookList(): List { - val bookFilesList = localDataSource.getBookFilesList().map { - it.toDomain() - } - - val booksFolderList = localDataSource.getMultipleBooksList().map { - it.toDomain() - } - - return booksFolderList + bookFilesList + return localDataSource.getBooksList() } override suspend fun upsertBookList(booksList: List) { val multipleBooksList = booksList.filterIsInstance().map { it.toEntity() } val bookFilesList = booksList.filterIsInstance().map { it.toEntity() } - localDataSource.addBookFileList(bookFilesList) - localDataSource.addMultipleBooksList(multipleBooksList) + localDataSource.upsertBooksList( + singleFileBooksList = bookFilesList, + multipleBooksList = multipleBooksList + ) } override suspend fun deleteBook(book: Book) { diff --git a/app/src/main/java/com/mobyle/abbay/infra/navigation/AbbayNavHost.kt b/app/src/main/java/com/mobyle/abbay/infra/navigation/AbbayNavHost.kt index 24bb2b8..be20791 100644 --- a/app/src/main/java/com/mobyle/abbay/infra/navigation/AbbayNavHost.kt +++ b/app/src/main/java/com/mobyle/abbay/infra/navigation/AbbayNavHost.kt @@ -7,16 +7,19 @@ import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel import androidx.media3.session.MediaController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.mobyle.abbay.presentation.booklist.BooksListScreen +import com.mobyle.abbay.presentation.booklist.BooksListViewModel import com.mobyle.abbay.presentation.settings.SettingsScreen @Composable fun AbbayNavHost( + viewModel: BooksListViewModel = hiltViewModel(), modifier: Modifier = Modifier, navController: NavHostController, player: MediaController, @@ -33,6 +36,7 @@ fun AbbayNavHost( route = NavigationItem.BookList.route, ) { BooksListScreen( + viewModel = viewModel, player = player, openAppSettings = { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { @@ -45,7 +49,9 @@ fun AbbayNavHost( } modal(NavigationItem.Settings.route) { - SettingsScreen { + SettingsScreen( + booksViewModel = viewModel + ) { navController.popBackStack() } } diff --git a/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListScreen.kt b/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListScreen.kt index 97f28c4..98d9a2d 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListScreen.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListScreen.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.media.MediaMetadataRetriever import android.net.Uri -import android.util.Log import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -27,8 +26,6 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.rememberBottomSheetScaffoldState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon @@ -62,7 +59,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.media3.common.PlaybackException @@ -97,6 +93,7 @@ import com.mobyle.abbay.presentation.utils.prepareMultipleBooks import com.mobyle.abbay.presentation.utils.toHHMMSS import com.model.Book import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -108,7 +105,7 @@ private const val AUTO_DENIAL_THRESHOLD = 300 @OptIn(ExperimentalPermissionsApi::class) @Composable fun BooksListScreen( - viewModel: BooksListViewModel = hiltViewModel(), + viewModel: BooksListViewModel, player: MediaController, navigateToSettings: () -> Unit, openAppSettings: () -> Unit, @@ -128,9 +125,6 @@ fun BooksListScreen( var componentHeight by remember { mutableStateOf(0.dp) } val activity = LocalContext.current as Activity val permissionRequestAttemptTime = remember { mutableLongStateOf(0) } - val isGestureDisabled = remember { - mutableStateOf(true) - } val permissionState = rememberMultiplePermissionsState( permissions = viewModel.getPermissionsList(), onPermissionsResult = { permissions -> @@ -145,7 +139,7 @@ fun BooksListScreen( val hasSelectedFolder by viewModel.hasSelectedFolder.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState() val hasBookEnded by viewModel.showBookEndedDialog.collectAsState() - + val isScreenLocked by viewModel.isScreenLocked.collectAsState() LifecycleEventEffect(event = Lifecycle.Event.ON_CREATE) { viewModel.shouldOpenPlayerInStartup() } @@ -206,10 +200,14 @@ fun BooksListScreen( } } - metadataRetriever.toBook(context, id.orEmpty()).getThumb(context) + metadataRetriever.toBook( + context = context, + id = id.orEmpty(), + type = BookType.FILE + ).getThumb(context) } - viewModel.updateBookList(newBookList) + viewModel.updateBookList(newBookList + viewModel.booksList) } } } @@ -222,7 +220,10 @@ fun BooksListScreen( viewModel.saveBookFolderPath(it.toString()) asyncScope.launch(Dispatchers.IO) { delay(500) - it.getBooks(context)?.let { + it.getBooks( + context = context, + type = BookType.FOLDER + )?.let { val booksWithThumbnails = it.mapNotNull { book -> book.getThumb(context) } @@ -235,13 +236,15 @@ fun BooksListScreen( // SideEffects BackHandler { - asyncScope.launch { - if (bottomSheetState.bottomSheetState.isCollapsed) { - activity.finishAffinity() - } else { - bottomSheetState.bottomSheetState.collapse() - } + if (!isScreenLocked) { + asyncScope.launch { + if (bottomSheetState.bottomSheetState.isCollapsed) { + activity.finishAffinity() + } else { + bottomSheetState.bottomSheetState.collapse() + } + } } } @@ -265,6 +268,7 @@ fun BooksListScreen( player.addListener(object : Player.Listener { override fun onPlayerError(error: PlaybackException) { selectedBook?.let { book -> + player.stop() viewModel.markBookAsError(book) } } @@ -279,9 +283,8 @@ fun BooksListScreen( if (selectedBook?.hasError == true) { showErrorDialog.value = true bottomSheetState.bottomSheetState.collapse() + viewModel.updateIsScreenLocked(false) } - - isGestureDisabled.value = selectedBook?.hasError == false } BottomSheetScaffold( @@ -296,6 +299,7 @@ fun BooksListScreen( MiniPlayer( player = player, book = it, + isScreenLocked = isScreenLocked, scaffoldState = bottomSheetState, progress = it.progress, onPlayingChange = { isPlaying -> @@ -318,9 +322,7 @@ fun BooksListScreen( ) } }, - onDisableGesture = { - isGestureDisabled.value = !it - }, + onLockScreen = viewModel::updateIsScreenLocked, updateBookSpeed = { selectedBook?.let { book -> viewModel.updateBookSpeed( @@ -340,7 +342,7 @@ fun BooksListScreen( } } }, - sheetGesturesEnabled = isGestureDisabled.value, + sheetGesturesEnabled = !isScreenLocked, sheetPeekHeight = if (hasBookSelected) 72.dp else 0.dp, ) { Box( @@ -383,7 +385,10 @@ fun BooksListScreen( viewModel.setRefreshingLoading() delay(500) - uri.getBooks(context)?.let { books -> + uri.getBooks( + context = context, + type = BookType.FOLDER + )?.let { books -> // Generate thumbnails for all books before checking for new ones val booksWithThumbnails = books.mapNotNull { book -> @@ -513,7 +518,7 @@ fun BooksListScreen( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = stringResource(R.string.delete_content_description_short), tint = Color.White ) } @@ -683,6 +688,15 @@ fun BooksListScreen( } if (showErrorDialog.value) { + LaunchedEffect(Unit) { + player.stop() + selectedBook?.let { + if (selectedBook?.hasError == false) { + viewModel.markBookAsError(it) + } + } + } + AlertDialog( onDismissRequest = { showErrorDialog.value = false @@ -735,7 +749,7 @@ fun BooksListScreen( ) { Text( stringResource(R.string.ok), - color = MaterialTheme.colorScheme.primary + color = Color.White ) } } diff --git a/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListViewModel.kt b/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListViewModel.kt index 18a6011..7495fdb 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListViewModel.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/booklist/BooksListViewModel.kt @@ -7,6 +7,7 @@ import com.mobyle.abbay.infra.common.BaseViewModel import com.mobyle.abbay.presentation.utils.permissions.CheckPermissionsProvider import com.model.Book import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import com.usecase.DeleteBook import com.usecase.GetBooksFolderPath @@ -59,6 +60,8 @@ class BooksListViewModel @Inject constructor( var shouldOpenPlayerInStartup = false + var isScreenLocked = MutableStateFlow(false) + private val _hasSelectedFolder = MutableStateFlow(getBooksFolderPath() != null) val hasSelectedFolder: StateFlow get() = _hasSelectedFolder @@ -79,19 +82,23 @@ class BooksListViewModel @Inject constructor( observeBooksList(), hasPermissions ) { domainBookList, hasPermissions -> val currentIds = booksList.map { it.id }.toSet() - val newBooks = domainBookList.filter { book -> - !currentIds.contains(book.id) - } + val newBooks = domainBookList.map { book -> + if (currentIds.contains(book.id)) { + booksList.firstOrNull { it.id == book.id }?.let { + it + } ?: run { + book + } + } else { + book + } + }.distinctBy { it.id } - if (domainBookList.isEmpty()) { + val state = if (hasPermissions) { booksList.clear() - } + booksList.addAll(newBooks.sortedBy { it.name }) - val state = if (hasPermissions) { if (newBooks.isNotEmpty()) { - booksList.addAll(newBooks) - BooksListUiState.BookListSuccess(booksList.toList()) - } else if (booksList.isNotEmpty()) { BooksListUiState.BookListSuccess(booksList.toList()) } else { BooksListUiState.NoBookSelected @@ -127,6 +134,15 @@ class BooksListViewModel @Inject constructor( _hasSelectedFolder.value = true } + fun addBooksFromNewFolder(filesList: List) { + val newBooks = filesList.distinctBy { it.id } + val individualBooks = booksList.filter { it.type == BookType.FILE } + + updateBookList(newBooks + individualBooks) + _hasSelectedFolder.value = true + _isRefreshing.value = false + } + fun setCurrentProgress( id: String, progress: Long, @@ -258,22 +274,29 @@ class BooksListViewModel @Inject constructor( else -> book } booksList[index] = updatedBook + + _uiState.tryEmit(BooksListUiState.BookListSuccess(booksList.toList())) } } fun checkForNewBooks(newBooksList: List) { val currentIds = booksList.map { it.id }.toSet() - val newBooks = newBooksList.filter { book -> - !currentIds.contains(book.id) - } + val newBooks = newBooksList.map { book -> + if (currentIds.contains(book.id)) { + booksList.firstOrNull { it.id == book.id }?.let { + it + } ?: run { + book + } + } else { + book + } + }.distinctBy { it.id } - if (newBooks.isNotEmpty()) { - updateBookList(newBooks) - } else { - _uiState.tryEmit(BooksListUiState.BookListSuccess(booksList.toList())) - _hasSelectedFolder.value = true - } + val individualBooks = booksList.filter { it.type == BookType.FILE } + updateBookList(newBooks + individualBooks) + _hasSelectedFolder.value = true _isRefreshing.value = false } @@ -296,6 +319,10 @@ class BooksListViewModel @Inject constructor( _showBookEndedDialog.value = false } + fun updateIsScreenLocked(isDisabled: Boolean) { + isScreenLocked.value = isDisabled + } + sealed class BooksListUiState { data class BookListSuccess(val audiobookList: List) : BooksListUiState() diff --git a/app/src/main/java/com/mobyle/abbay/presentation/booklist/Utils.kt b/app/src/main/java/com/mobyle/abbay/presentation/booklist/Utils.kt index 400f03f..5136801 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/booklist/Utils.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/booklist/Utils.kt @@ -15,6 +15,7 @@ import com.mobyle.abbay.presentation.utils.getFileName import com.mobyle.abbay.presentation.utils.getId import com.model.Book import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import java.io.File @@ -48,7 +49,7 @@ fun Uri.resolveContentUri(context: Context): String? { return File(base, split[1]).canonicalPath } -fun Uri.getBooks(context: Context): List? { +fun Uri.getBooks(context: Context, type: BookType): List? { return this.resolveContentUri(context)?.let { folderPath -> val contentResolver: ContentResolver = context.contentResolver val bookUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI @@ -78,7 +79,8 @@ fun Uri.getBooks(context: Context): List? { thumbnail = thumbnail, progress = progress, duration = duration, - speed = 1f + speed = 1f, + type = type ) filesHashMap[fileFolderPath]?.let { diff --git a/app/src/main/java/com/mobyle/abbay/presentation/booklist/widgets/MiniPlayer.kt b/app/src/main/java/com/mobyle/abbay/presentation/booklist/widgets/MiniPlayer.kt index 7764ef0..95e1d0c 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/booklist/widgets/MiniPlayer.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/booklist/widgets/MiniPlayer.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ExperimentalMotionApi @@ -103,14 +104,14 @@ fun MiniPlayer( book: Book, onPlayingChange: (Boolean) -> Unit, progress: Long, + isScreenLocked: Boolean, scaffoldState: BottomSheetScaffoldState, updateCurrentBookPosition: (Int) -> Unit, updateProgress: (Long) -> Unit, updateBookSpeed: (Float) -> Unit, - onDisableGesture: (Boolean) -> Unit, + onLockScreen: (Boolean) -> Unit, modifier: Modifier ) { - val isScreenLocked = remember { mutableStateOf(false) } val showUnlockDialog = remember { mutableStateOf(false) } val playerIcon = remember { @@ -146,13 +147,16 @@ fun MiniPlayer( onPlayingChange(player.isPlaying) } + Player.STATE_BUFFERING -> { // Keep current icon during buffering } + Player.STATE_ENDED -> { playerIcon.value = Icons.Default.PlayArrow onPlayingChange(false) } + Player.STATE_IDLE -> { playerIcon.value = Icons.Default.PlayArrow onPlayingChange(false) @@ -172,9 +176,9 @@ fun MiniPlayer( } } } - + player.addListener(listener) - + // Clean up listener when the composable is disposed // Note: In a real app, you might want to handle this differently // depending on your lifecycle management @@ -184,23 +188,19 @@ fun MiniPlayer( SingleFilePlayer( player = player, book = book, - isScreenLocked = isScreenLocked.value, + isScreenLocked = isScreenLocked, onPlayingChange = onPlayingChange, progress = progress, scaffoldState = scaffoldState, playerIcon = playerIcon, onLockScreen = { - if (it) { - onDisableGesture(true) - } - isScreenLocked.value = it + onLockScreen(true) }, showScreenLockedAlert = { showUnlockDialog.value = true }, unlockScreen = { - onDisableGesture(false) - isScreenLocked.value = false + onLockScreen(false) }, updateProgress = updateProgress, updateBookSpeed = updateBookSpeed, @@ -210,27 +210,23 @@ fun MiniPlayer( MultipleFilePlayer( player = player, book = book, - isScreenLocked = isScreenLocked.value, + isScreenLocked = isScreenLocked, onPlayingChange = onPlayingChange, progress = progress, scaffoldState = scaffoldState, playerIcon = playerIcon, updateProgress = updateProgress, onLockScreen = { - if (it) { - onDisableGesture(true) - } - isScreenLocked.value = it + onLockScreen(true) }, showScreenLockedAlert = { showUnlockDialog.value = true }, unlockScreen = { - onDisableGesture(false) - isScreenLocked.value = false + onLockScreen(false) }, updateCurrentBookPosition = updateCurrentBookPosition, - onDisableGesture = onDisableGesture, + onDisableGesture = onLockScreen, updateBookSpeed = updateBookSpeed, modifier = modifier ) @@ -344,11 +340,14 @@ private fun SingleFilePlayer( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f)) - .combinedClickable(onClick = { - showScreenLockedAlert() - }, onLongClick = { - unlockScreen() - }) + .combinedClickable( + onClick = { + showScreenLockedAlert() + }, + onDoubleClick = { + unlockScreen() + } + ) ) } } @@ -462,12 +461,17 @@ private fun MultipleFilePlayer( } AnimatedVisibility( - visible = swipeProgress == 1f, modifier = Modifier.padding(top = 8.dp) + visible = swipeProgress == 1f, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) ) { BookFileItem( - file = files.getOrNull(currentIndex), onClick = { + file = files.getOrNull(currentIndex), + onClick = { showChapters.value = !showChapters.value - }, isExpanded = showChapters.value + }, + isExpanded = showChapters.value ) } @@ -512,7 +516,8 @@ private fun MultipleFilePlayer( .combinedClickable( onClick = { showScreenLockedAlert() - }, onLongClick = { + }, + onDoubleClick = { unlockScreen() } ) @@ -533,12 +538,15 @@ private fun BookFileItem( .fillMaxWidth() .clickable { onClick() }, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = file?.fileName ?: stringResource(R.string.unknown_file), style = AbbayTextStyles.chapterTitle, - maxLines = 1 + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(8.dp)) Icon( @@ -546,7 +554,8 @@ private fun BookFileItem( contentDescription = if (isExpanded) stringResource(R.string.hide_chapters) else stringResource( R.string.show_chapters ), - tint = Color.White + tint = Color.White, + modifier = Modifier.size(24.dp) ) } } @@ -602,7 +611,9 @@ private fun BookFilesList( text = file.fileName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.tertiary, - maxLines = 1 + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis, + maxLines = 2, ) Text( text = file.duration.toHHMMSS(), @@ -633,7 +644,7 @@ private fun PlayerController( onPlayingChange(true) player.play() } else { - if(!player.playWhenReady) { + if (!player.playWhenReady) { player.prepareBook(id, position, MutableStateFlow(true)) } player.addListener(object : Player.Listener { @@ -693,7 +704,9 @@ private fun BooksTopBar( text = currentSpeed.value.text, color = Color.White ) Icon( - Icons.Default.Speed, contentDescription = "Change speed", tint = Color.White + Icons.Default.Speed, + contentDescription = stringResource(R.string.change_speed_content_description), + tint = Color.White ) } } @@ -825,7 +838,7 @@ private fun BookImage( onPlayingChange(true) player.play() } else { - if(!player.playWhenReady) { + if (!player.playWhenReady) { player.prepareBook(book.id, progress, MutableStateFlow(true)) } diff --git a/app/src/main/java/com/mobyle/abbay/presentation/common/mappers/ViewMappers.kt b/app/src/main/java/com/mobyle/abbay/presentation/common/mappers/ViewMappers.kt index 18a6b68..d27cbcc 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/common/mappers/ViewMappers.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/common/mappers/ViewMappers.kt @@ -8,12 +8,17 @@ import android.net.Uri import androidx.core.net.toUri import com.mobyle.abbay.presentation.booklist.widgets.models.BookSpeed import com.model.BookFile +import com.model.BookType import com.model.MultipleBooks import java.io.File import java.io.FileOutputStream import java.io.IOException -fun MediaMetadataRetriever.toBook(context: Context, id: String): BookFile { +fun MediaMetadataRetriever.toBook( + context: Context, + id: String, + type: BookType +): BookFile { val fileName = extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) val title = extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) val duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) ?: "0L" @@ -34,7 +39,8 @@ fun MediaMetadataRetriever.toBook(context: Context, id: String): BookFile { }, progress = 0L, duration = duration.toLong(), - speed = 1f + speed = 1f, + type = type, ) } @@ -63,7 +69,8 @@ fun List.toMultipleBooks(): MultipleBooks? { progress = 0L, duration = this.sumOf { it.duration }, currentBookPosition = 0, - speed = 1f + speed = 1f, + type = firstBook.type ) } } @@ -91,7 +98,7 @@ fun getImageUriFromBitmap(context: Context, bitmap: Bitmap, name: String): Uri { } fun Float.toBookSpeed(): BookSpeed { - return when(this) { + return when (this) { 0.5f -> BookSpeed.Half 1.25f -> BookSpeed.OnePointTwoFive 1.5f -> BookSpeed.OnePointFive diff --git a/app/src/main/java/com/mobyle/abbay/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/mobyle/abbay/presentation/settings/SettingsScreen.kt index 8c13ccb..03e8e9d 100644 --- a/app/src/main/java/com/mobyle/abbay/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mobyle/abbay/presentation/settings/SettingsScreen.kt @@ -1,5 +1,7 @@ package com.mobyle.abbay.presentation.settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Switch @@ -8,21 +10,55 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.mobyle.abbay.R +import com.mobyle.abbay.presentation.booklist.BooksListViewModel +import com.mobyle.abbay.presentation.booklist.getBooks +import com.mobyle.abbay.presentation.booklist.getThumb import com.mobyle.abbay.presentation.common.widgets.AbbayActionDialog import com.mobyle.abbay.presentation.common.widgets.AbbayScreen +import com.model.BookType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), + booksViewModel: BooksListViewModel, close: () -> Unit ) { val shouldPlayWhenAppIsClosed by viewModel.shouldPlayWhenAppIsClosed.collectAsState() val shouldOpenPlayerInStartup by viewModel.shouldOpenPlayerInStartup.collectAsState() val showShowDeleteConfirmation by viewModel.showShowDeleteConfirmation.collectAsState() + val asyncScope = rememberCoroutineScope() + val context = LocalContext.current + + val openFolderSelector = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + uri?.let { + booksViewModel.showLoading() + booksViewModel.saveBookFolderPath(it.toString()) + asyncScope.launch(Dispatchers.IO) { + delay(500) + it.getBooks( + context = context, + type = BookType.FOLDER + )?.let { + val booksWithThumbnails = it.mapNotNull { book -> + book.getThumb(context) + } + + booksViewModel.addBooksFromNewFolder(booksWithThumbnails) + } + } + } + } AbbayScreen( title = stringResource(R.string.settings), @@ -60,6 +96,13 @@ fun SettingsScreen( } ) + SettingItem( + text = stringResource(R.string.change_folder), + onClick = { + openFolderSelector.launch(null) + } + ) + SettingItem( text = stringResource(R.string.delete_all_books), onClick = viewModel::showDeleteConfirmation diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..4ef888a --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,73 @@ + + + Abbay + Tus Audiolibros + Agregar carpeta + Agregar archivo + Agregar un Archivo + Agregar una Carpeta + o + Necesitamos permisos para acceder a tus libros + Conceder Permisos + + + Cancelar + OK + Eliminar + Descartar + Entendido + Libro Finalizado + El audiolibro ha terminado de reproducirse. + + + Configuración + Reproducir cuando la app esté cerrada + Mantener libro abierto al abrir la app + Change books folder + Eliminar todos los libros + Eliminar Todos los Libros + ¿Estás seguro de que quieres eliminar todos los libros? Esta acción no se puede deshacer. + + + Actualizar + Agregar + Agregar Carpeta + Agregar Archivo + Eliminar + Eliminar Libro + ¿Estás seguro de que quieres eliminar este libro? + Actualizar Libros + Esto actualizará tu lista de libros. Los libros agregados individualmente no se verán afectados. + Archivo No Encontrado + No se pudo encontrar el archivo de este libro. + El archivo puede haber sido movido o eliminado. + + + Archivo Desconocido + Mostrar Capítulos + Ocultar Capítulos + Cambiar Velocidad + Pantalla Bloqueada + Mantén presionado en cualquier lugar de la pantalla para desbloquear + Esto evita toques accidentales durante la narración + + + Error al Reproducir Archivo + Icono del Libro + Icono del Reloj + + + 0.5x + 1.0x + 1.25x + 1.5x + 2.0x + + + + + + Eliminar + Cambiar velocidad + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..09fca7b --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,73 @@ + + + Abbay + Seus Audiobooks + Adicionar pasta + Adicionar arquivo + Adicionar um Arquivo + Adicionar uma Pasta + ou + Precisamos de permissões para acessar seus livros + Conceder Permissões + + + Cancelar + OK + Excluir + Descartar + Entendi + Livro Finalizado + O audiobook terminou de tocar. + + + Configurações + Tocar quando o app estiver fechado + Manter livro aberto ao abrir o app + Mudar pasta de livros + Excluir todos os livros + Excluir Todos os Livros + Tem certeza de que deseja excluir todos os livros? Esta ação não pode ser desfeita. + + + Atualizar + Adicionar + Adicionar Pasta + Adicionar Arquivo + Excluir + Excluir Livro + Tem certeza de que deseja excluir este livro? + Atualizar Livros + Isso atualizará a sua lista de livros. Livros adicionados individualmente não serão afetados. + Arquivo Não Encontrado + O arquivo deste livro não pôde ser encontrado. + O arquivo pode ter sido movido ou excluído. + + + Arquivo Desconhecido + Mostrar Capítulos + Ocultar Capítulos + Alterar Velocidade + Tela Bloqueada + Mantenha pressionado em qualquer lugar da tela para desbloquear + Isso evita toques acidentais durante a narração + + + Erro ao Reproduzir Arquivo + Ícone do Livro + Ícone do Relógio + + + 0.5x + 1.0x + 1.25x + 1.5x + 2.0x + + + + + + Excluir + Alterar velocidade + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 630d2d4..4e3ca6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Play when app is closed Open player on startup Delete all books + Change books folder Delete All Books Are you sure you want to delete all books? This action cannot be undone. @@ -63,5 +64,9 @@ + + + Delete + Change speed \ No newline at end of file diff --git a/domain/src/main/java/com/model/Book.kt b/domain/src/main/java/com/model/Book.kt index 3a675c9..19be2c1 100644 --- a/domain/src/main/java/com/model/Book.kt +++ b/domain/src/main/java/com/model/Book.kt @@ -8,5 +8,5 @@ interface Book { val duration: Long val speed: Float val hasError: Boolean + val type: BookType } - diff --git a/domain/src/main/java/com/model/BookFile.kt b/domain/src/main/java/com/model/BookFile.kt index 87815f5..7322525 100644 --- a/domain/src/main/java/com/model/BookFile.kt +++ b/domain/src/main/java/com/model/BookFile.kt @@ -8,5 +8,6 @@ data class BookFile( override val duration: Long, override val speed: Float, override val hasError: Boolean = false, + override val type: BookType, val fileName: String ) : Book \ No newline at end of file diff --git a/domain/src/main/java/com/model/BookType.kt b/domain/src/main/java/com/model/BookType.kt new file mode 100644 index 0000000..06c5c6f --- /dev/null +++ b/domain/src/main/java/com/model/BookType.kt @@ -0,0 +1,6 @@ +package com.model + +enum class BookType { + FOLDER, + FILE +} \ No newline at end of file diff --git a/domain/src/main/java/com/model/MultipleBooks.kt b/domain/src/main/java/com/model/MultipleBooks.kt index b5fa4a7..4b575af 100644 --- a/domain/src/main/java/com/model/MultipleBooks.kt +++ b/domain/src/main/java/com/model/MultipleBooks.kt @@ -8,6 +8,7 @@ data class MultipleBooks( override val duration: Long, override val speed: Float, override val hasError: Boolean = false, + override val type: BookType, val bookFileList: List, val currentBookPosition: Int = 0 ) : Book \ No newline at end of file