From 0207f830f7ef6cbec8c2e2c2197956706d92823c Mon Sep 17 00:00:00 2001 From: HuanCheng65 <22636177+HuanCheng65@users.noreply.github.com> Date: Sun, 28 Jan 2024 18:09:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(Search):=20=E5=B0=81=E8=A3=85=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=A1=86=E3=80=81=E7=BB=93=E6=9E=9C=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=AD=89=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tieba/post/ui/page/search/SearchPage.kt | 113 ++------ .../ui/page/search/thread/SearchThreadPage.kt | 141 +-------- .../tieba/post/ui/widgets/compose/Search.kt | 274 ++++++++++++++++++ 3 files changed, 296 insertions(+), 232 deletions(-) create mode 100644 app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt index 34f6300c..3ba1d842 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt @@ -1,6 +1,5 @@ package com.huanchengfly.tieba.post.ui.page.search -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,20 +25,16 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState 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.ButtonDefaults import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.ProvideTextStyle -import androidx.compose.material.Surface import androidx.compose.material.Tab import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Search @@ -59,13 +53,11 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -87,13 +79,13 @@ import com.huanchengfly.tieba.post.ui.page.search.thread.SearchThreadPage import com.huanchengfly.tieba.post.ui.page.search.thread.SearchThreadSortType import com.huanchengfly.tieba.post.ui.page.search.thread.SearchThreadUiEvent import com.huanchengfly.tieba.post.ui.page.search.user.SearchUserPage -import com.huanchengfly.tieba.post.ui.widgets.compose.BaseTextField import com.huanchengfly.tieba.post.ui.widgets.compose.Button import com.huanchengfly.tieba.post.ui.widgets.compose.Container import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoadHorizontalPager import com.huanchengfly.tieba.post.ui.widgets.compose.MyBackHandler import com.huanchengfly.tieba.post.ui.widgets.compose.MyScaffold import com.huanchengfly.tieba.post.ui.widgets.compose.PagerTabIndicator +import com.huanchengfly.tieba.post.ui.widgets.compose.SearchBox import com.huanchengfly.tieba.post.ui.widgets.compose.TabClickMenu import com.huanchengfly.tieba.post.ui.widgets.compose.TabRow import com.huanchengfly.tieba.post.ui.widgets.compose.TopAppBarContainer @@ -269,8 +261,10 @@ fun SearchPage( } } } - ) { - Box(modifier = Modifier.fillMaxSize()) { + ) { paddingValues -> + Box(modifier = Modifier + .fillMaxSize() + .padding(paddingValues)) { if (!isKeywordEmpty) { ProvideNavigator(navigator = navigator) { LazyLoadHorizontalPager( @@ -562,20 +556,18 @@ private fun SearchTopBar( onKeywordSubmit: (String) -> Unit = {}, onBack: () -> Unit = {}, ) { - val isKeywordNotEmpty = remember(keyword) { keyword.isNotEmpty() } - var isFocused by remember { mutableStateOf(false) } - Surface( + SearchBox( + keyword = keyword, + onKeywordChange = onKeywordChange, modifier = Modifier.fillMaxSize(), - shape = RoundedCornerShape(6.dp), - color = ExtendedTheme.colors.topBarSurface, - contentColor = ExtendedTheme.colors.onTopBarSurface, - elevation = 0.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { + onKeywordSubmit = onKeywordSubmit, + placeholder = { + Text( + text = stringResource(id = R.string.hint_search), + color = ExtendedTheme.colors.onTopBarSurface.copy(alpha = ContentAlpha.medium) + ) + }, + prependIcon = { Box( modifier = Modifier .clip(RoundedCornerShape(100)) @@ -588,76 +580,13 @@ private fun SearchTopBar( contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Rounded.ArrowBack, + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.button_back) ) } - BaseTextField( - value = keyword, - onValueChange = { - onKeywordChange(it) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Search, - ), - keyboardActions = KeyboardActions( - onSearch = { - onKeywordSubmit(keyword) - } - ), - placeholder = { - Text( - text = stringResource(id = R.string.hint_search), - color = ExtendedTheme.colors.onTopBarSurface.copy(alpha = ContentAlpha.medium) - ) - }, - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .onFocusEvent { isFocused = it.isFocused } - ) - AnimatedVisibility(visible = isKeywordNotEmpty && isFocused) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(100)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, 24.dp), - role = Role.Button - ) { onKeywordChange("") }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(id = R.string.button_clear) - ) - } - } - } - AnimatedVisibility(visible = isKeywordNotEmpty) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(100)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, 24.dp), - role = Role.Button - ) { onKeywordSubmit(keyword) }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = stringResource(id = R.string.button_search) - ) - } - } - } - } + }, + shape = RoundedCornerShape(6.dp) + ) } @Preview("SearchBox") diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/thread/SearchThreadPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/thread/SearchThreadPage.kt index 1ce2ba8d..a35e648e 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/thread/SearchThreadPage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/thread/SearchThreadPage.kt @@ -1,15 +1,9 @@ package com.huanchengfly.tieba.post.ui.page.search.thread import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -20,8 +14,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.huanchengfly.tieba.post.api.models.SearchThreadBean import com.huanchengfly.tieba.post.arch.collectPartialAsState import com.huanchengfly.tieba.post.arch.onGlobalEvent import com.huanchengfly.tieba.post.arch.pageViewModel @@ -32,24 +24,12 @@ import com.huanchengfly.tieba.post.ui.page.destinations.ForumPageDestination import com.huanchengfly.tieba.post.ui.page.destinations.ThreadPageDestination import com.huanchengfly.tieba.post.ui.page.destinations.UserProfilePageDestination import com.huanchengfly.tieba.post.ui.page.search.SearchUiEvent -import com.huanchengfly.tieba.post.ui.widgets.compose.Avatar -import com.huanchengfly.tieba.post.ui.widgets.compose.Card import com.huanchengfly.tieba.post.ui.widgets.compose.ErrorScreen -import com.huanchengfly.tieba.post.ui.widgets.compose.ForumInfoChip import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoad import com.huanchengfly.tieba.post.ui.widgets.compose.LoadMoreLayout import com.huanchengfly.tieba.post.ui.widgets.compose.LocalShouldLoad -import com.huanchengfly.tieba.post.ui.widgets.compose.MyLazyColumn -import com.huanchengfly.tieba.post.ui.widgets.compose.Sizes -import com.huanchengfly.tieba.post.ui.widgets.compose.ThreadAgreeBtn -import com.huanchengfly.tieba.post.ui.widgets.compose.ThreadContent -import com.huanchengfly.tieba.post.ui.widgets.compose.ThreadReplyBtn -import com.huanchengfly.tieba.post.ui.widgets.compose.ThreadShareBtn -import com.huanchengfly.tieba.post.ui.widgets.compose.UserHeader +import com.huanchengfly.tieba.post.ui.widgets.compose.SearchThreadList import com.huanchengfly.tieba.post.ui.widgets.compose.states.StateScreen -import com.huanchengfly.tieba.post.utils.DateTimeUtils -import com.huanchengfly.tieba.post.utils.StringUtil -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterialApi::class) @@ -59,7 +39,6 @@ fun SearchThreadPage( initialSortType: Int = SearchThreadSortType.SORT_TYPE_NEWEST, viewModel: SearchThreadViewModel = pageViewModel(), ) { - val context = LocalContext.current val navigator = LocalNavigator.current LazyLoad(loaded = viewModel.initialized) { viewModel.send(SearchThreadUiIntent.Refresh(keyword, initialSortType)) @@ -187,121 +166,3 @@ fun SearchThreadPage( } } } - -@Composable -private fun SearchThreadList( - data: ImmutableList, - lazyListState: LazyListState, - onItemClick: (SearchThreadBean.ThreadInfoBean) -> Unit, - onItemUserClick: (SearchThreadBean.UserInfoBean) -> Unit, - onItemForumClick: (SearchThreadBean.ForumInfo) -> Unit, - modifier: Modifier = Modifier, -) { - MyLazyColumn( - state = lazyListState, - modifier = modifier - ) { - items(data) { - SearchThreadItem( - item = it, - onClick = onItemClick, - onUserClick = onItemUserClick, - onForumClick = onItemForumClick, - ) - } - } -} - -@Composable -private fun SearchThreadUserHeader( - user: SearchThreadBean.UserInfoBean, - time: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - UserHeader( - avatar = { - Avatar( - data = StringUtil.getAvatarUrl(user.portrait), - size = Sizes.Small, - contentDescription = null - ) - }, - name = { - Text( - text = StringUtil.getUsernameAnnotatedString( - LocalContext.current, - user.userName.orEmpty(), - user.showNickname, - color = LocalContentColor.current - ) - ) - }, - desc = { - Text( - text = DateTimeUtils.getRelativeTimeString(LocalContext.current, time) - ) - }, - onClick = onClick, - modifier = modifier - ) -} - -@Composable -private fun SearchThreadItem( - item: SearchThreadBean.ThreadInfoBean, - onClick: (SearchThreadBean.ThreadInfoBean) -> Unit, - onUserClick: (SearchThreadBean.UserInfoBean) -> Unit, - onForumClick: (SearchThreadBean.ForumInfo) -> Unit, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier, - header = { - SearchThreadUserHeader( - user = item.user, - time = item.time, - onClick = { onUserClick(item.user) } - ) - }, - content = { - ThreadContent( - title = item.title, - abstractText = item.content, - showTitle = item.title.isNotBlank(), - showAbstract = item.content.isNotBlank(), - ) - if (item.forumName.isNotEmpty()) { - ForumInfoChip( - imageUriProvider = { item.forumInfo.avatar }, - nameProvider = { item.forumName } - ) { - onForumClick(item.forumInfo) - } - } - }, - action = { - Row(modifier = Modifier.fillMaxWidth()) { - ThreadReplyBtn( - replyNum = item.postNum.toInt(), - onClick = {}, - modifier = Modifier.weight(1f) - ) - - ThreadAgreeBtn( - hasAgree = false, - agreeNum = item.likeNum.toInt(), - onClick = {}, - modifier = Modifier.weight(1f) - ) - - ThreadShareBtn( - shareNum = item.shareNum.toLong(), - onClick = {}, - modifier = Modifier.weight(1f) - ) - } - }, - onClick = { onClick(item) }, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt new file mode 100644 index 00000000..ee6ab649 --- /dev/null +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt @@ -0,0 +1,274 @@ +package com.huanchengfly.tieba.post.ui.widgets.compose + +import androidx.compose.animation.AnimatedVisibility +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.ripple.rememberRipple +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.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.huanchengfly.tieba.post.R +import com.huanchengfly.tieba.post.api.models.SearchThreadBean +import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme +import com.huanchengfly.tieba.post.utils.DateTimeUtils +import com.huanchengfly.tieba.post.utils.StringUtil +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SearchThreadList( + data: ImmutableList, + lazyListState: LazyListState, + onItemClick: (SearchThreadBean.ThreadInfoBean) -> Unit, + onItemUserClick: (SearchThreadBean.UserInfoBean) -> Unit, + onItemForumClick: (SearchThreadBean.ForumInfo) -> Unit, + modifier: Modifier = Modifier, + hideForum: Boolean = false, + header: LazyListScope.() -> Unit = {}, +) { + MyLazyColumn( + state = lazyListState, + modifier = modifier + ) { + header() + itemsIndexed(data) { index, item -> + if (index > 0) { + VerticalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + SearchThreadItem( + item = item, + onClick = onItemClick, + onUserClick = onItemUserClick, + onForumClick = onItemForumClick, + hideForum = hideForum, + ) + } + } +} + +@Composable +fun SearchThreadUserHeader( + user: SearchThreadBean.UserInfoBean, + time: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UserHeader( + avatar = { + Avatar( + data = StringUtil.getAvatarUrl(user.portrait), + size = Sizes.Small, + contentDescription = null + ) + }, + name = { + Text( + text = StringUtil.getUsernameAnnotatedString( + LocalContext.current, + user.userName.orEmpty(), + user.showNickname, + color = LocalContentColor.current + ) + ) + }, + desc = { + Text( + text = DateTimeUtils.getRelativeTimeString(LocalContext.current, time) + ) + }, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun SearchThreadItem( + item: SearchThreadBean.ThreadInfoBean, + onClick: (SearchThreadBean.ThreadInfoBean) -> Unit, + onUserClick: (SearchThreadBean.UserInfoBean) -> Unit, + onForumClick: (SearchThreadBean.ForumInfo) -> Unit, + modifier: Modifier = Modifier, + hideForum: Boolean = false, +) { + Card( + modifier = modifier, + header = { + SearchThreadUserHeader( + user = item.user, + time = item.time, + onClick = { onUserClick(item.user) } + ) + }, + content = { + ThreadContent( + title = item.title, + abstractText = item.content, + showTitle = item.title.isNotBlank(), + showAbstract = item.content.isNotBlank(), + ) + if (!hideForum && item.forumName.isNotEmpty()) { + ForumInfoChip( + imageUriProvider = { item.forumInfo.avatar }, + nameProvider = { item.forumName } + ) { + onForumClick(item.forumInfo) + } + } + }, + action = { + Row(modifier = Modifier.fillMaxWidth()) { + ThreadReplyBtn( + replyNum = item.postNum.toInt(), + onClick = {}, + modifier = Modifier.weight(1f) + ) + + ThreadAgreeBtn( + hasAgree = false, + agreeNum = item.likeNum.toInt(), + onClick = {}, + modifier = Modifier.weight(1f) + ) + + ThreadShareBtn( + shareNum = item.shareNum.toLong(), + onClick = {}, + modifier = Modifier.weight(1f) + ) + } + }, + onClick = { onClick(item) }, + ) +} + +@Composable +fun SearchBox( + keyword: String, + onKeywordChange: (String) -> Unit, + modifier: Modifier = Modifier, + onKeywordSubmit: (String) -> Unit = {}, + placeholder: @Composable () -> Unit = {}, + prependIcon: @Composable () -> Unit = {}, + appendIcon: @Composable () -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, + shape: Shape = RectangleShape, + color: Color = ExtendedTheme.colors.topBarSurface, + contentColor: Color = ExtendedTheme.colors.onTopBarSurface, + elevation: Dp = 0.dp, +) { + val isKeywordNotEmpty = remember(keyword) { keyword.isNotEmpty() } + var isFocused by remember { mutableStateOf(false) } + Surface( + modifier = modifier, + shape = shape, + color = color, + contentColor = contentColor, + elevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + prependIcon() + BaseTextField( + value = keyword, + onValueChange = { + onKeywordChange(it) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + onKeywordSubmit(keyword) + } + ), + placeholder = placeholder, + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .focusRequester(focusRequester) + .onFocusEvent { + isFocused = it.isFocused + } + ) + appendIcon() + AnimatedVisibility(visible = isKeywordNotEmpty && isFocused) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(100)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, 24.dp), + role = Role.Button + ) { onKeywordChange("") }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource(id = R.string.button_clear) + ) + } + } + } + AnimatedVisibility(visible = isKeywordNotEmpty) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(100)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, 24.dp), + role = Role.Button + ) { onKeywordSubmit(keyword) }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(id = R.string.button_search) + ) + } + } + } + } +} \ No newline at end of file