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 { dependencies {
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
@@ -2,42 +2,19 @@ package com.lsp.view
import android.app.Application import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import android.content.ServiceConnection
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.os.IBinder
import com.lsp.view.service.DownloadService.DownloadBinder
import com.google.android.material.color.DynamicColors 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 { 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() { override fun onCreate() {
super<Application>.onCreate() super<Application>.onCreate()
context = applicationContext context = applicationContext
val serviceIntent = Intent(this, DownloadService::class.java)
bindService(serviceIntent, connection, BIND_AUTO_CREATE)
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
} }
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbindService(connection)
}
companion object { companion object {
var context: Context? = null var context: Context? = null
private set private set
+2 -1
View File
@@ -2,6 +2,7 @@ package com.lsp.view.bean
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import java.io.Serializable
open class Post( open class Post(
open val postId: String, open val postId: String,
@@ -11,7 +12,7 @@ open class Post(
open val sampleHeight: Int, open val sampleHeight: Int,
open val sampleWidth: Int, open val sampleWidth: Int,
open val tags:String? open val tags:String?
): Parcelable { ): Parcelable,Serializable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readString()!!, parcel.readString()!!,
parcel.readString()!!, parcel.readString()!!,
@@ -7,6 +7,9 @@ import retrofit2.converter.gson.GsonConverterFactory
object ServiceCreator { object ServiceCreator {
val CLIENT = OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}).build()
/** /**
* @param serviceClass:所属类的Service接口 * @param serviceClass:所属类的Service接口
@@ -14,16 +17,11 @@ object ServiceCreator {
* *
*/ */
fun <T> create(serviceClass: Class<T>, source: String): T { 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() val retrofit = Retrofit.Builder()
.baseUrl(source) .baseUrl(source)
.client(client) .client(CLIENT)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
return retrofit.create(serviceClass) return retrofit.create(serviceClass)
@@ -6,7 +6,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.os.* 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 okhttp3.Request
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -17,48 +21,47 @@ class DownloadService : Service() {
class DownloadBinder(val context: Context) : Binder() { class DownloadBinder(val context: Context) : Binder() {
fun downloadImage(fileUrl: String):Result<Serializable> { fun downloadImage(fileUrl: String): Deferred<Result<Serializable>> {
return CoroutineScope(Dispatchers.IO).async {
val fileDir = val fileDir =
File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/") File("${Environment.getExternalStorageDirectory()}/${Environment.DIRECTORY_PICTURES}/LspMake/")
if (!fileDir.exists()) { if (!fileDir.exists()) {
fileDir.mkdirs() 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"))
} }
} catch (e: Exception) { val split = fileUrl.split("/")
Result.failure<Exception>(e) val fileName = split[split.size - 1]
} finally { val file =
fos.close() 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 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.Bundle
import android.os.IBinder
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid 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.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape 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.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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -29,39 +47,117 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.lsp.view.bean.Post import com.lsp.view.bean.Post
import com.lsp.view.ui.compose.theme.LspViewTheme import com.lsp.view.ui.compose.theme.LspViewTheme
import androidx.lifecycle.viewmodel.compose.viewModel 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.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent 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.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private const val TAG = "Compose_PostActivity" private const val TAG = "Compose_PostActivity"
class PostActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
LspViewTheme { LspViewTheme {
// A surface container using the 'background' color from the theme
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PostList(viewModel: PostViewModel = viewModel()) { fun PostListScreen(
val uiState by viewModel.uiState.collectAsState() navController: NavController,
onNavigateToDetail: (Post) -> Unit,
viewModel: PostViewModel
) {
val postList by viewModel.postData.collectAsState() val postList by viewModel.postData.collectAsState()
val listState = rememberLazyStaggeredGridState() val listState = rememberLazyStaggeredGridState()
@@ -78,18 +174,22 @@ fun PostList(viewModel: PostViewModel = viewModel()) {
} }
} }
} }
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
state = listState, state = listState,
columns = StaggeredGridCells.Adaptive(200.dp), columns = StaggeredGridCells.Adaptive(200.dp),
verticalItemSpacing = 4.dp, verticalItemSpacing = 4.dp,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
content = { content = {
items(items=postList, key = { items(items = postList, key = {
it.postId//提供key以保持列表顺序稳定 it.postId//提供key以保持列表顺序稳定
}) { }) {
Row(Modifier.animateItemPlacement(tween(durationMillis = 250))) { Row(Modifier.animateItemPlacement(tween(durationMillis = 250))) {
PostItem(it) PostItem(
it,
clickable = {
onNavigateToDetail.invoke(it)
}
)
} }
} }
@@ -99,19 +199,22 @@ fun PostList(viewModel: PostViewModel = viewModel()) {
} }
@Composable @Composable
fun PostItem(post:Post){ fun PostItem(post: Post, clickable: (Post) -> Unit) {
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = post.sampleUrl, model = post.sampleUrl,
contentScale = ContentScale.Crop, contentScale = ContentScale.FillWidth,
contentDescription = null, contentDescription = null,
loading ={ loading = {
Box(modifier = Modifier Box(
.fillMaxWidth() modifier = Modifier
.height(300.dp)) { .fillMaxWidth()
.height(300.dp)
) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.size(50.dp) .size(50.dp)
.align(Alignment.Center)) .align(Alignment.Center)
)
} }
}, },
success = { success = {
@@ -121,5 +224,43 @@ fun PostItem(post:Post){
.clip(RoundedCornerShape(5.dp)) .clip(RoundedCornerShape(5.dp))
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .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 package com.lsp.view.ui.compose
import com.lsp.view.bean.Post
data class PostUiState( data class PostUiState(
var refresh:Boolean = false, var refresh:Boolean = false,
var searchTarget: String = "", var searchTarget: String = "",
val source: String = "", val source: String = "",
val safeModel: Boolean = false, //安全模式 val safeModel: Boolean = false, //安全模式
var page:Int = 1, var page:Int = 1,
var selectPost: Post? = null
) )
@@ -1,5 +1,6 @@
package com.lsp.view.ui.compose package com.lsp.view.ui.compose
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lsp.view.bean.YandPost import com.lsp.view.bean.YandPost
@@ -17,6 +18,7 @@ class PostViewModel:ViewModel() {
private val repository by lazy { private val repository by lazy {
PostRepository() PostRepository()
} }
val downloadAction = MutableLiveData<Unit>()
init { init {
initData() initData()
@@ -26,6 +28,10 @@ class PostViewModel:ViewModel() {
fetchPost() fetchPost()
} }
fun actionDownload(){
downloadAction.postValue(Unit)
}
fun fetchPost(){ fun fetchPost(){
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_uiState.value.refresh = true _uiState.value.refresh = true
@@ -130,17 +130,6 @@ class ImageFragment : Fragment() {
} }
private fun download(fileUrl: String){ 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())
}
}
} }