diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt new file mode 100644 index 00000000..3b8c2dee --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt @@ -0,0 +1,193 @@ +package com.arflix.tv.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import com.arflix.tv.ui.theme.ArflixTypography +import com.arflix.tv.ui.theme.Pink +import com.arflix.tv.ui.theme.SuccessGreen +import com.arflix.tv.ui.theme.TextPrimary +import com.arflix.tv.ui.theme.TextSecondary +import com.arflix.tv.ui.skin.resolveAccentColor + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsRow( + icon: ImageVector, + title: String, + subtitle: String = "", + value: String, + isFocused: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val focusRingColor = resolveAccentColor(fallback = Pink) + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .background( + if (isFocused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), + RoundedCornerShape(12.dp) + ) + .border( + width = if (isFocused) 2.dp else 0.dp, + color = if (isFocused) focusRingColor else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = icon, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(19.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + Spacer(modifier = Modifier.width(12.dp)) + + if (value.isNotBlank()) { + Box( + modifier = Modifier + .background(Pink.copy(alpha = 0.15f), RoundedCornerShape(999.dp)) + .border(1.dp, Pink.copy(alpha = 0.3f), RoundedCornerShape(999.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = value.uppercase(), + style = ArflixTypography.label.copy(fontSize = 11.sp, letterSpacing = 0.5.sp), + color = Pink, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsToggleRow( + title: String, + subtitle: String, + isEnabled: Boolean, + isFocused: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val focusRingColor = resolveAccentColor(fallback = Pink) + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { onToggle(!isEnabled) } + ) + .background( + if (isFocused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), + RoundedCornerShape(12.dp) + ) + .border( + width = if (isFocused) 2.dp else 0.dp, + color = if (isFocused) focusRingColor else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Box( + modifier = Modifier + .width(44.dp) + .height(24.dp) + .background( + color = if (isEnabled) SuccessGreen else Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(13.dp) + ) + .padding(3.dp), + contentAlignment = if (isEnabled) Alignment.CenterEnd else Alignment.CenterStart + ) { + Box( + modifier = Modifier + .size(18.dp) + .background( + color = Color.White, + shape = RoundedCornerShape(10.dp) + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index d75bb23f..d11229b8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -138,6 +138,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.arflix.tv.ui.components.SettingsRow +import com.arflix.tv.ui.components.SettingsToggleRow import androidx.core.widget.doAfterTextChanged import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -196,29 +198,6 @@ private val LocalSettingsFocusTracker = compositionLocalOf { - return when (section) { - "language" -> listOf(0, 3) - "subtitles" -> listOf(1, 2, 4, 5, 6, 7, 8, 9) - "ai_subtitles" -> listOf(28, 29, 30, 31, 32, 33) - "playback" -> listOf(10, 11, 12, 13, 14, 34, 16, 15, 27) - "appearance" -> listOf(17, 18, 20, 21, 24, 23, 22) - "profiles" -> listOf(19) - "network" -> listOf(25, 26, 35) - else -> emptyList() - } -} - private fun openExternalUrl(context: Context, url: String) { runCatching { context.startActivity( @@ -375,17 +354,18 @@ fun SettingsScreen( } } val sectionMaxIndex: (String) -> Int = { section -> - when (section) { - in tvGeneralSectionIds -> (tvGeneralRowsForSection(section).size - 1).coerceAtLeast(0) - "iptv" -> if (showIptvCategoriesSettings) { + val generalRows = tvGeneralRowsForSection(section) + when { + generalRows.isNotEmpty() -> (generalRows.size - 1).coerceAtLeast(0) + section == "iptv" -> if (showIptvCategoriesSettings) { uiState.iptvAvailableGroups.size // Reset row + category rows } else { 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear } - "home_server" -> uiState.homeServerConnections.size + 3 - "catalogs" -> uiState.catalogs.size // Add + rows - "stremio" -> stremioAddons.size // rows + add button - "accounts" -> 4 // Cloud + Trakt + Force Sync + App Update + Privacy/Data + section == "home_server" -> uiState.homeServerConnections.size + 3 + section == "catalogs" -> uiState.catalogs.size // Add + rows + section == "stremio" -> stremioAddons.size // rows + add button + section == "accounts" -> 4 // Cloud + Trakt + Force Sync + App Update + Privacy/Data else -> 0 } } @@ -805,48 +785,52 @@ fun SettingsScreen( } Zone.SECTION -> activeZone = Zone.CONTENT Zone.CONTENT -> { - when (currentSection) { - in tvGeneralSectionIds -> { - when (tvGeneralRowsForSection(currentSection).getOrNull(contentFocusIndex)) { - 0 -> openContentLanguagePicker() - 1 -> openSubtitlePicker() - 2 -> openSecondarySubtitlePicker() - 3 -> openAudioLanguagePicker() - 4 -> viewModel.cycleSubtitleSize() - 5 -> viewModel.cycleSubtitleColor() - 6 -> viewModel.cycleSubtitleOffset() - 7 -> viewModel.cycleSubtitleStyle() - 8 -> viewModel.toggleSubtitleStylized() - 9 -> viewModel.setFilterSubtitlesByLanguage(!uiState.filterSubtitlesByLanguage) - 10 -> viewModel.setAutoPlayNext(!uiState.autoPlayNext) - 11 -> viewModel.setAutoPlaySingleSource(!uiState.autoPlaySingleSource) - 12 -> viewModel.cycleAutoPlayMinQuality() - 13 -> viewModel.setTrailerAutoPlay(!uiState.trailerAutoPlay) - 14 -> viewModel.setTrailerSoundEnabled(!uiState.trailerSoundEnabled) - 15 -> viewModel.cycleFrameRateMatchingMode() - 16 -> showQualityFiltersModal = true - 17 -> viewModel.toggleCardLayoutMode() - 18 -> openUiModeWarningDialog() - 19 -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection) - 20 -> viewModel.setOledBlackBackground(!uiState.oledBlackBackground) - 21 -> viewModel.cycleClockFormat() - 22 -> viewModel.setShowBudget(!uiState.showBudget) - 23 -> viewModel.setSpoilerBlurEnabled(!uiState.spoilerBlurEnabled) - 24 -> viewModel.cycleAccentColor() - 25 -> openDnsProviderPicker() - 26 -> viewModel.setShowLoadingStats(!uiState.showLoadingStats) - 35 -> showCustomUserAgentDialog = true - 27 -> viewModel.cycleVolumeBoost() - 28 -> viewModel.setSubtitleAiEnabled(!uiState.subtitleAiEnabled) - 29 -> showAiModelDialog = true - 30 -> viewModel.setSubtitleAiAutoSelect(!uiState.subtitleAiAutoSelect) - 31 -> viewModel.setSubtitleRemoveHearingImpaired(!uiState.subtitleRemoveHearingImpaired) - 32 -> showAiApiKeyDialog = true - 33 -> viewModel.startAiKeyServer() - 34 -> viewModel.cycleTrailerDelay() - } + val generalRows = tvGeneralRowsForSection(currentSection) + if (generalRows.isNotEmpty()) { + // Row behavior follows the symbolic metadata order so later inserts only + // require updates in SettingsSectionMetadata.kt. + when (generalRows.getOrNull(contentFocusIndex)) { + TvGeneralSettingRow.CONTENT_LANGUAGE -> openContentLanguagePicker() + TvGeneralSettingRow.DEFAULT_SUBTITLE -> openSubtitlePicker() + TvGeneralSettingRow.SECONDARY_SUBTITLE -> openSecondarySubtitlePicker() + TvGeneralSettingRow.AUDIO_LANGUAGE -> openAudioLanguagePicker() + TvGeneralSettingRow.SUBTITLE_SIZE -> viewModel.cycleSubtitleSize() + TvGeneralSettingRow.SUBTITLE_COLOR -> viewModel.cycleSubtitleColor() + TvGeneralSettingRow.SUBTITLE_OFFSET -> viewModel.cycleSubtitleOffset() + TvGeneralSettingRow.SUBTITLE_STYLE -> viewModel.cycleSubtitleStyle() + TvGeneralSettingRow.SUBTITLE_STYLIZED -> viewModel.toggleSubtitleStylized() + TvGeneralSettingRow.FILTER_SUBTITLES_BY_LANGUAGE -> viewModel.setFilterSubtitlesByLanguage(!uiState.filterSubtitlesByLanguage) + TvGeneralSettingRow.AUTO_PLAY_NEXT -> viewModel.setAutoPlayNext(!uiState.autoPlayNext) + TvGeneralSettingRow.AUTO_PLAY_SINGLE_SOURCE -> viewModel.setAutoPlaySingleSource(!uiState.autoPlaySingleSource) + TvGeneralSettingRow.AUTO_PLAY_MIN_QUALITY -> viewModel.cycleAutoPlayMinQuality() + TvGeneralSettingRow.TRAILER_AUTO_PLAY -> viewModel.setTrailerAutoPlay(!uiState.trailerAutoPlay) + TvGeneralSettingRow.TRAILER_SOUND_ENABLED -> viewModel.setTrailerSoundEnabled(!uiState.trailerSoundEnabled) + TvGeneralSettingRow.FRAME_RATE_MATCHING_MODE -> viewModel.cycleFrameRateMatchingMode() + TvGeneralSettingRow.QUALITY_FILTERS -> showQualityFiltersModal = true + TvGeneralSettingRow.CARD_LAYOUT_MODE -> viewModel.toggleCardLayoutMode() + TvGeneralSettingRow.UI_MODE -> openUiModeWarningDialog() + TvGeneralSettingRow.SKIP_PROFILE_SELECTION -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection) + TvGeneralSettingRow.OLED_BLACK_BACKGROUND -> viewModel.setOledBlackBackground(!uiState.oledBlackBackground) + TvGeneralSettingRow.CLOCK_FORMAT -> viewModel.cycleClockFormat() + TvGeneralSettingRow.SHOW_BUDGET -> viewModel.setShowBudget(!uiState.showBudget) + TvGeneralSettingRow.SPOILER_BLUR -> viewModel.setSpoilerBlurEnabled(!uiState.spoilerBlurEnabled) + TvGeneralSettingRow.ACCENT_COLOR -> viewModel.cycleAccentColor() + TvGeneralSettingRow.DNS_PROVIDER -> openDnsProviderPicker() + TvGeneralSettingRow.SHOW_LOADING_STATS -> viewModel.setShowLoadingStats(!uiState.showLoadingStats) + TvGeneralSettingRow.VOLUME_BOOST -> viewModel.cycleVolumeBoost() + TvGeneralSettingRow.SUBTITLE_AI_ENABLED -> viewModel.setSubtitleAiEnabled(!uiState.subtitleAiEnabled) + TvGeneralSettingRow.SUBTITLE_AI_MODEL -> showAiModelDialog = true + TvGeneralSettingRow.SUBTITLE_AI_AUTO_SELECT -> viewModel.setSubtitleAiAutoSelect(!uiState.subtitleAiAutoSelect) + TvGeneralSettingRow.SUBTITLE_REMOVE_HEARING_IMPAIRED -> viewModel.setSubtitleRemoveHearingImpaired(!uiState.subtitleRemoveHearingImpaired) + TvGeneralSettingRow.SUBTITLE_AI_API_KEY -> showAiApiKeyDialog = true + TvGeneralSettingRow.SUBTITLE_AI_SERVER -> viewModel.startAiKeyServer() + TvGeneralSettingRow.TRAILER_DELAY -> viewModel.cycleTrailerDelay() + TvGeneralSettingRow.CUSTOM_USER_AGENT -> showCustomUserAgentDialog = true + null -> Unit } - "iptv" -> { + } else { + when (currentSection) { + "iptv" -> { if (showIptvCategoriesSettings) { val playlistId = uiState.iptvSelectedPlaylistId.orEmpty() val orderedGroups = ( @@ -920,8 +904,8 @@ fun SettingsScreen( viewModel.clearIptvConfig() } } - } - "home_server" -> { + } + "home_server" -> { when (contentFocusIndex) { 0 -> { homeServerUrl = "" @@ -946,8 +930,8 @@ fun SettingsScreen( uiState.homeServerConnections.size + 2 -> viewModel.testHomeServerConnection() uiState.homeServerConnections.size + 3 -> viewModel.disconnectHomeServer() } - } - "catalogs" -> { + } + "catalogs" -> { if (contentFocusIndex == 0) { showCatalogInput = true } else { @@ -970,8 +954,8 @@ fun SettingsScreen( } } } - } - "stremio" -> { + } + "stremio" -> { when { contentFocusIndex in 0 until stremioAddons.size -> { val addon = stremioAddons[contentFocusIndex] @@ -990,8 +974,8 @@ fun SettingsScreen( showCustomAddonInput = true } } - } - "accounts" -> { + } + "accounts" -> { when (contentFocusIndex) { 0 -> { if (uiState.isLoggedIn) { @@ -1020,8 +1004,8 @@ fun SettingsScreen( } } } + else -> Unit } - else -> Unit } } else -> {} @@ -5893,165 +5877,6 @@ private fun IptvSettings( } } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SettingsRow( - icon: ImageVector, - title: String, - subtitle: String = "", - value: String, - isFocused: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - val focusRingColor = resolveAccentColor(fallback = Pink) - Row( - modifier = modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick - ) - .background( - if (isFocused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), - RoundedCornerShape(12.dp) - ) - .border( - width = if (isFocused) 2.dp else 0.dp, - color = if (isFocused) focusRingColor else Color.Transparent, - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - Icon( - imageVector = icon, - contentDescription = null, - tint = TextSecondary, - modifier = Modifier.size(19.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (subtitle.isNotEmpty()) { - Text( - text = subtitle, - style = ArflixTypography.caption.copy(fontSize = 13.sp), - color = TextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - Spacer(modifier = Modifier.width(12.dp)) - - if (value.isNotBlank()) { - Box( - modifier = Modifier - .background(Pink.copy(alpha = 0.15f), RoundedCornerShape(999.dp)) - .border(1.dp, Pink.copy(alpha = 0.3f), RoundedCornerShape(999.dp)) - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = value.uppercase(), - style = ArflixTypography.label.copy(fontSize = 11.sp, letterSpacing = 0.5.sp), - color = Pink, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SettingsToggleRow( - title: String, - subtitle: String, - isEnabled: Boolean, - isFocused: Boolean, - onToggle: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - val focusRingColor = resolveAccentColor(fallback = Pink) - Row( - modifier = modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = { onToggle(!isEnabled) } - ) - .background( - if (isFocused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), - RoundedCornerShape(12.dp) - ) - .border( - width = if (isFocused) 2.dp else 0.dp, - color = if (isFocused) focusRingColor else Color.Transparent, - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (subtitle.isNotEmpty()) { - Text( - text = subtitle, - style = ArflixTypography.caption.copy(fontSize = 13.sp), - color = TextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - // Custom toggle indicator instead of Switch - Box( - modifier = Modifier - .width(44.dp) - .height(24.dp) - .background( - color = if (isEnabled) SuccessGreen else Color.White.copy(alpha = 0.2f), - shape = RoundedCornerShape(13.dp) - ) - .padding(3.dp), - contentAlignment = if (isEnabled) Alignment.CenterEnd else Alignment.CenterStart - ) { - Box( - modifier = Modifier - .size(18.dp) - .background( - color = Color.White, - shape = RoundedCornerShape(10.dp) - ) - ) - } - } -} - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun CatalogDiscoveryModal( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsSectionMetadata.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsSectionMetadata.kt new file mode 100644 index 00000000..1d928aaf --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsSectionMetadata.kt @@ -0,0 +1,85 @@ +package com.arflix.tv.ui.screens.settings + +internal data class SettingsSectionDefinition( + val id: String, + val rows: List, +) + +private val tvGeneralSectionDefinitions = listOf( + SettingsSectionDefinition( + id = "language", + rows = listOf( + 0, + 3, + ), + ), + SettingsSectionDefinition( + id = "subtitles", + rows = listOf( + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + ), + ), + SettingsSectionDefinition( + id = "ai_subtitles", + rows = listOf( + 28, + 29, + 30, + 31, + 32, + 33, + ), + ), + SettingsSectionDefinition( + id = "playback", + rows = listOf( + 10, + 11, + 12, + 13, + 14, + 34, + 16, + 15, + 27, + ), + ), + SettingsSectionDefinition( + id = "appearance", + rows = listOf( + 17, + 18, + 20, + 21, + 24, + 23, + 22, + 36, + ), + ), + SettingsSectionDefinition( + id = "profiles", + rows = listOf( + 19, + ), + ), + SettingsSectionDefinition( + id = "network", + rows = listOf( + 25, + 26, + 35, + ), + ), +) + +internal fun tvGeneralRowsForSection(section: String): List { + return tvGeneralSectionDefinitions.firstOrNull { it.id == section }?.rows.orEmpty() +} \ No newline at end of file