refactor: jetpack compose (3/n)

This commit is contained in:
AnranYus
2024-06-14 16:20:52 +08:00
parent 65c911585c
commit 87c6ecf74a
9 changed files with 216 additions and 96 deletions
+2
View File
@@ -57,6 +57,8 @@ android {
}
dependencies {
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
@@ -2,42 +2,19 @@ package com.lsp.view
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import android.content.ServiceConnection
import android.content.ComponentName
import android.content.Context
import android.os.IBinder
import com.lsp.view.service.DownloadService.DownloadBinder
import com.google.android.material.color.DynamicColors
import android.content.Intent
import android.util.Log
import com.lsp.view.service.DownloadService
import androidx.lifecycle.LifecycleOwner
class YandViewApplication : Application(), DefaultLifecycleObserver {
lateinit var downloadBinder:DownloadBinder
private val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) {
downloadBinder = iBinder as DownloadBinder
}
override fun onServiceDisconnected(componentName: ComponentName) {}
}
override fun onCreate() {
super<Application>.onCreate()
context = applicationContext
val serviceIntent = Intent(this, DownloadService::class.java)
bindService(serviceIntent, connection, BIND_AUTO_CREATE)
DynamicColors.applyToActivitiesIfAvailable(this)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbindService(connection)
}
companion object {
var context: Context? = null
private set
+2 -1
View File
@@ -2,6 +2,7 @@ package com.lsp.view.bean
import android.os.Parcel
import android.os.Parcelable
import java.io.Serializable
open class Post(
open val postId: String,
@@ -11,7 +12,7 @@ open class Post(
open val sampleHeight: Int,
open val sampleWidth: Int,
open val tags:String?
): Parcelable {
): Parcelable,Serializable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
@@ -7,6 +7,9 @@ import retrofit2.converter.gson.GsonConverterFactory
object ServiceCreator {
val CLIENT = OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}).build()
/**
* @param serviceClass:所属类的Service接口
@@ -14,16 +17,11 @@ object ServiceCreator {
*
*/
fun <T> create(serviceClass: Class<T>, source: String): T {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()
val retrofit = Retrofit.Builder()
.baseUrl(source)
.client(client)
.client(CLIENT)
.addConverterFactory(GsonConverterFactory.create())
.build()
return retrofit.create(serviceClass)
@@ -6,7 +6,11 @@ import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection
import android.os.*
import okhttp3.OkHttpClient
import com.lsp.view.repository.network.retrofit.ServiceCreator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
@@ -17,48 +21,47 @@ class DownloadService : Service() {
class DownloadBinder(val context: Context) : Binder() {
fun downloadImage(fileUrl: String):Result<Serializable> {
val fileDir =
File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/")
if (!fileDir.exists()) {
fileDir.mkdirs()
}
val split = fileUrl.split("/")
val fileName = split[split.size - 1]
val file =
File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/$fileName")
if (file.exists()) {
return Result.failure<Exception>(Exception("File exists"))
}
val fos = FileOutputStream(file)
try {
val client = OkHttpClient()
val request = Request.Builder()
.url(fileUrl)
.build()
val response = client.newCall(request).execute()
val responseData = response.body?.bytes()
return if (response.code == 200) {
fos.write(responseData)
//通知媒体更新
MediaScannerConnection.scanFile(
context, arrayOf(file.path),
null, null
)
Result.success("File download successfully")
} else {
file.delete()
Result.failure<Exception>(NetworkErrorException("Connection timed out"))
fun downloadImage(fileUrl: String): Deferred<Result<Serializable>> {
return CoroutineScope(Dispatchers.IO).async {
val fileDir =
File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/")
if (!fileDir.exists()) {
fileDir.mkdirs()
}
} catch (e: Exception) {
Result.failure<Exception>(e)
} finally {
fos.close()
val split = fileUrl.split("/")
val fileName = split[split.size - 1]
val file =
File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/$fileName")
if (file.exists()) {
return@async Result.failure<Exception>(Exception("File exists"))
}
val fos = FileOutputStream(file)
try {
val request = Request.Builder()
.url(fileUrl)
.build()
val response = ServiceCreator.CLIENT.newCall(request).execute()
val responseData = response.body?.bytes()
return@async if (response.code == 200) {
fos.write(responseData)
//通知媒体更新
MediaScannerConnection.scanFile(
context, arrayOf(file.path),
null, null
)
Result.success("File download successfully")
} else {
file.delete()
Result.failure<Exception>(NetworkErrorException("Connection timed out"))
}
} catch (e: Exception) {
Result.failure<Exception>(e)
} finally {
fos.close()
}
return@async Result.failure(Exception("Unknown error"))
}
return Result.failure(Exception("Unknown error"))
}
}
@@ -1,16 +1,24 @@
package com.lsp.view.ui.compose
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
@@ -18,9 +26,19 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
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.Surface
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -29,39 +47,117 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.lsp.view.bean.Post
import com.lsp.view.ui.compose.theme.LspViewTheme
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import com.lsp.view.R
import com.lsp.view.service.DownloadService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private const val TAG = "Compose_PostActivity"
class PostActivity : ComponentActivity() {
lateinit var downloadBinder: DownloadService.DownloadBinder
val viewModel: PostViewModel by viewModels<PostViewModel>()
private val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) {
downloadBinder = iBinder as DownloadService.DownloadBinder
}
override fun onServiceDisconnected(componentName: ComponentName) {}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LspViewTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
PostList()
App()
}
}
}
viewModel.downloadAction.observe(this){
CoroutineScope(Dispatchers.IO).launch {
val result =
downloadBinder.downloadImage(viewModel.uiState.value.selectPost?.fileUrl ?: "")
.await()
launch(Dispatchers.Main) {
if (result.isSuccess) {
Toast.makeText(
this@PostActivity,
"Download successful",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
this@PostActivity,
"Download fail",
Toast.LENGTH_SHORT
).show()
}
}
}
}
val serviceIntent = Intent(this, DownloadService::class.java)
bindService(serviceIntent, connection, BIND_AUTO_CREATE)
}
override fun onDestroy() {
super.onDestroy()
unbindService(connection)
}
}
const val NAV_ROUTE_POSTLIST_SCREEN = "NAV_ROUTE_POSTLIST_SCREEN"
const val NAV_ROUTE_DETAIL_SCREEN = "NAV_ROUTE_DETAIL_SCREEN"
@Composable
fun App(viewModel: PostViewModel = viewModel()) {
val navController = rememberNavController()
NavHost(navController, startDestination = NAV_ROUTE_POSTLIST_SCREEN) {
composable(NAV_ROUTE_POSTLIST_SCREEN) {
PostListScreen(onNavigateToDetail = {
viewModel.uiState.value.selectPost = it
navController.navigate(NAV_ROUTE_DETAIL_SCREEN)
}, navController = navController, viewModel = viewModel)
}
composable(NAV_ROUTE_DETAIL_SCREEN) {
DetailScreen(navController = navController, viewModel = viewModel)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostList(viewModel: PostViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
fun PostListScreen(
navController: NavController,
onNavigateToDetail: (Post) -> Unit,
viewModel: PostViewModel
) {
val postList by viewModel.postData.collectAsState()
val listState = rememberLazyStaggeredGridState()
@@ -78,18 +174,22 @@ fun PostList(viewModel: PostViewModel = viewModel()) {
}
}
}
LazyVerticalStaggeredGrid(
state = listState,
columns = StaggeredGridCells.Adaptive(200.dp),
verticalItemSpacing = 4.dp,
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = {
items(items=postList, key = {
items(items = postList, key = {
it.postId//提供key以保持列表顺序稳定
}) {
Row(Modifier.animateItemPlacement(tween(durationMillis = 250))) {
PostItem(it)
PostItem(
it,
clickable = {
onNavigateToDetail.invoke(it)
}
)
}
}
@@ -99,19 +199,22 @@ fun PostList(viewModel: PostViewModel = viewModel()) {
}
@Composable
fun PostItem(post:Post){
fun PostItem(post: Post, clickable: (Post) -> Unit) {
SubcomposeAsyncImage(
model = post.sampleUrl,
contentScale = ContentScale.Crop,
contentScale = ContentScale.FillWidth,
contentDescription = null,
loading ={
Box(modifier = Modifier
.fillMaxWidth()
.height(300.dp)) {
loading = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.size(50.dp)
.align(Alignment.Center))
.align(Alignment.Center)
)
}
},
success = {
@@ -121,5 +224,43 @@ fun PostItem(post:Post){
.clip(RoundedCornerShape(5.dp))
.fillMaxWidth()
.wrapContentHeight()
.clickable { clickable.invoke(post) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(navController: NavController, viewModel: PostViewModel) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val uiState by viewModel.uiState.collectAsState()
Scaffold(topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "back")
}
},
actions = {
IconButton(onClick = { viewModel.actionDownload()}) {
Icon(painter = painterResource(id = R.drawable.ic_twotone_arrow_downward_24), contentDescription = "Download")
}
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
content = {
AsyncImage(
model = uiState.selectPost?.sampleUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.padding(top = it.calculateTopPadding())
)
}
)
}
@@ -1,9 +1,12 @@
package com.lsp.view.ui.compose
import com.lsp.view.bean.Post
data class PostUiState(
var refresh:Boolean = false,
var searchTarget: String = "",
val source: String = "",
val safeModel: Boolean = false, //安全模式
var page:Int = 1,
var selectPost: Post? = null
)
@@ -1,5 +1,6 @@
package com.lsp.view.ui.compose
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lsp.view.bean.YandPost
@@ -17,6 +18,7 @@ class PostViewModel:ViewModel() {
private val repository by lazy {
PostRepository()
}
val downloadAction = MutableLiveData<Unit>()
init {
initData()
@@ -26,6 +28,10 @@ class PostViewModel:ViewModel() {
fetchPost()
}
fun actionDownload(){
downloadAction.postValue(Unit)
}
fun fetchPost(){
viewModelScope.launch(Dispatchers.IO) {
_uiState.value.refresh = true
@@ -130,17 +130,6 @@ class ImageFragment : Fragment() {
}
private fun download(fileUrl: String){
lifecycleScope.launch(Dispatchers.IO) {
activityContext.viewModel.postNewToast("Start download")
val result = (activityContext.application as YandViewApplication).downloadBinder.downloadImage(fileUrl)
if (result.isSuccess){
activityContext.viewModel.postNewToast("Download successfully")
}else{
val exception = result.exceptionOrNull()
activityContext.viewModel.postNewToast(exception?.message.toString())
}
}
}