email sender

This commit is contained in:
Vineyro 2025-06-17 03:52:31 +07:00
parent 39297abc6c
commit 02138f3f2f
14 changed files with 676 additions and 73 deletions

View File

@ -22,6 +22,5 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=true
org.gradle.unsafe.configuration-cache=true

View File

@ -20,8 +20,8 @@ android {
applicationId = "llc.arma.vgate"
minSdk = 26
targetSdk = 35
versionCode = 2
versionName = "1.0.1"
versionCode = 3
versionName = "1.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -33,10 +33,12 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import llc.arma.vgate.app.framework.SendRequestWorker
import llc.arma.vgate.app.ui.screens.main.MainScreen
import llc.arma.vgate.app.ui.theme.BleTheme
import llc.arma.vgate.domain.usecase.GetReceiverEmailFlow
import llc.arma.vgate.domain.usecase.GetWaitingReportsFlow
import java.time.Duration
import javax.inject.Inject
@ -45,6 +47,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var getWaitingReportsFlow: GetWaitingReportsFlow
@Inject lateinit var getReceiverEmailFlow: GetReceiverEmailFlow
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
@ -63,11 +66,13 @@ class MainActivity : ComponentActivity() {
val workManager = WorkManager.getInstance(this)
getWaitingReportsFlow().onEach {
merge(getWaitingReportsFlow.invoke(), getReceiverEmailFlow.invoke()).onEach {
println(it)
workManager.enqueueUniqueWork(
uniqueWorkName = "Upload",
existingWorkPolicy = ExistingWorkPolicy.KEEP,
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
request = uploadWorkRequest
)

View File

@ -50,7 +50,7 @@ fun HomeScreen(
}
) {
Image(
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
)

View File

@ -22,6 +22,8 @@ import llc.arma.vgate.app.ui.screens.result.ReadResultContract
import llc.arma.vgate.app.ui.screens.result.ReadResultScreen
import llc.arma.vgate.app.ui.screens.selector.BleSelectorContract
import llc.arma.vgate.app.ui.screens.selector.BleSelectorScreen
import llc.arma.vgate.app.ui.screens.sender.EmailSenderContract
import llc.arma.vgate.app.ui.screens.sender.EmailSenderScreen
import llc.arma.vgate.app.ui.screens.vehicle.form.VehicleFormContract
import llc.arma.vgate.app.ui.screens.vehicle.form.VehicleFormScreen
import llc.arma.vgate.app.ui.screens.vehicle.selector.VehicleSelectorContract
@ -54,6 +56,11 @@ data class ReadResultScreenRoute(
val resultId: Long
)
@Serializable
data class EmailSenderScreenRoute(
val requestId: Long
)
@Serializable
data object VehiclesScreenRoute
@ -145,6 +152,17 @@ fun MainScreen() {
}
composable<EmailSenderScreenRoute> {
EmailSenderScreen(
onNavigationEvent = {
when(it){
EmailSenderContract.Effect.Navigation.Up ->
controller.navigateUp()
}
}
)
}
composable<SendRequestsScreenRoute> {
SendRequestsScreen(
@ -152,6 +170,9 @@ fun MainScreen() {
when(it){
SendRequestsContract.Effect.Navigation.NavigateUp ->
controller.popBackStack()
is SendRequestsContract.Effect.Navigation.RequestSend ->
controller.navigate(EmailSenderScreenRoute(it.requestId))
}
}
)

View File

@ -8,7 +8,13 @@ import llc.arma.vgate.domain.model.Vehicle
class SendRequestsContract {
sealed class Event : ViewEvent
sealed class Event : ViewEvent {
data class OnSend(
val requestId: Long
) : Event()
}
sealed class State : ViewState {
@ -24,6 +30,10 @@ class SendRequestsContract {
sealed class Navigation : Effect() {
data class RequestSend(
val requestId: Long
) : Navigation()
data object NavigateUp : Navigation()
}

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -12,7 +13,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material.icons.rounded.Upload
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@ -25,6 +26,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -53,6 +55,16 @@ fun SendRequestsScreen(
val viewModel = hiltViewModel<SendRequestsViewModel>()
val state = viewModel.viewState.value
LaunchedEffect(Unit) {
viewModel.effect.collect {
when(it){
is SendRequestsContract.Effect.Navigation -> onNavigationEvent(it)
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
@ -126,7 +138,9 @@ private fun DisplayState(
items(items = state.sendRequests.toList()){
RequestItem(it.first, it.second)
RequestItem(it.first, it.second){
viewModel.setEvent(SendRequestsContract.Event.OnSend(it.first.id))
}
}
@ -139,7 +153,8 @@ private val formatter: DateFormat = SimpleDateFormat.getDateTimeInstance(3, 2)
@Composable
private fun RequestItem(
request: SendRequest,
vehicle: Vehicle
vehicle: Vehicle,
onSend: () -> Unit
){
Surface(
@ -148,23 +163,42 @@ private fun RequestItem(
modifier = Modifier.fillMaxWidth()
) {
Column(
Row(
modifier = Modifier.padding(16.dp)
) {
Text(
text = vehicle.name
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Создан: ${formatter.format(Date(request.date))}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = vehicle.name
)
Text(
text = "Статус: ${request.status.localized}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Создан: ${formatter.format(Date(request.date))}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Статус: ${request.status.localized}",
style = MaterialTheme.typography.bodySmall
)
}
if(request.status == SendRequest.Status.Waiting) {
IconButton(
onClick = onSend
) {
Icon(
imageVector = Icons.Rounded.Upload,
contentDescription = null
)
}
}
}

View File

@ -42,7 +42,18 @@ class SendRequestsViewModel @Inject constructor(
override fun setInitialState() = SendRequestsContract.State.Loading
override fun handleEvents(event: SendRequestsContract.Event) {
when(event){
is SendRequestsContract.Event.OnSend -> reduce(viewState.value, event)
}
}
private fun reduce(
state: SendRequestsContract.State,
event: SendRequestsContract.Event.OnSend
){
setEffect {
SendRequestsContract.Effect.Navigation.RequestSend(event.requestId)
}
}
}

View File

@ -0,0 +1,39 @@
package llc.arma.vgate.app.ui.screens.sender
import llc.arma.vgate.app.ui.ViewEvent
import llc.arma.vgate.app.ui.ViewSideEffect
import llc.arma.vgate.app.ui.ViewState
class EmailSenderContract {
sealed class Event : ViewEvent {
data object OnNavigateUp : Event()
data object OnRetry : Event()
}
sealed class State : ViewState {
data object Loading : State()
data object Success : State()
data class Error(
val error: Throwable
) : State()
}
sealed class Effect : ViewSideEffect {
sealed class Navigation : Effect() {
data object Up : Navigation()
}
}
}

View File

@ -0,0 +1,377 @@
package llc.arma.vgate.app.ui.screens.sender
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.Button
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmailSenderScreen(
onNavigationEvent: (EmailSenderContract.Effect.Navigation) -> Unit
) {
val viewModel = hiltViewModel<EmailSenderViewModel>()
val state = viewModel.viewState.value
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
LaunchedEffect(Unit) {
viewModel.effect.collect {
when(it){
is EmailSenderContract.Effect.Navigation -> onNavigationEvent(it)
}
}
}
Scaffold(
topBar = {
TopAppBar(
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
onClick = {
onNavigationEvent(EmailSenderContract.Effect.Navigation.Up)
}
){
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest
),
title = {
Text(
text = "Отправка"
)
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
Column(
modifier = Modifier.padding(it)
) {
when(state){
is EmailSenderContract.State.Error -> ErrorState(viewModel, state)
EmailSenderContract.State.Loading -> LoadingState()
EmailSenderContract.State.Success -> SuccessState(viewModel)
}
}
}
}
@Composable
private fun ErrorState(
viewModel: EmailSenderViewModel,
state: EmailSenderContract.State.Error
){
var showError by remember {
mutableStateOf(false)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.widthIn(max = 270.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.size(128.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = null,
modifier = Modifier.size(56.dp)
)
}
}
Spacer(
modifier = Modifier.height(16.dp)
)
Text(
text = "Ошибка"
)
Text(
text = "Во время отправки данных произошла ошибка",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(16.dp)
)
Button(
onClick = {
viewModel.setEvent(EmailSenderContract.Event.OnRetry)
},
modifier = Modifier.widthIn(min = 128.dp)
) {
Text(
text = "Повторить"
)
}
OutlinedButton(
onClick = {
showError = true
},
modifier = Modifier.widthIn(min = 128.dp)
) {
Text(
text = "Показать ошибку"
)
}
Spacer(
modifier = Modifier.height(8.dp)
)
TextButton (
onClick = {
viewModel.setEvent(EmailSenderContract.Event.OnNavigateUp)
},
modifier = Modifier.widthIn(min = 128.dp)
) {
Text(
text = "Отмена"
)
}
}
}
if(showError){
Dialog(
onDismissRequest = {
showError = false
}
) {
Surface(
shape = RoundedCornerShape(20.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Ошибка отправки",
style = MaterialTheme.typography.titleLarge
)
Column(
modifier = Modifier.heightIn(max = 300.dp).verticalScroll(rememberScrollState())
) {
Text(
text = state.error.stackTraceToString(),
style = MaterialTheme.typography.bodySmall
)
}
Button(
onClick = {
showError = false
},
modifier = Modifier.align(Alignment.End)
) {
Text(
text = "Ок"
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun LoadingState(){
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.widthIn(max = 270.dp)
) {
ContainedLoadingIndicator()
Spacer(
modifier = Modifier.height(16.dp)
)
Text(
text = "Отправка данных",
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun SuccessState(
viewModel: EmailSenderViewModel
){
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.widthIn(max = 270.dp)
) {
Surface(
shape = CircleShape,
color = Color.Green.copy(alpha = 0.8f),
modifier = Modifier.size(128.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Icon(
tint = Color.White,
imageVector = Icons.Rounded.Done,
contentDescription = null,
modifier = Modifier.size(56.dp)
)
}
}
Spacer(
modifier = Modifier.height(16.dp)
)
Text(
text = "Успешно"
)
Text(
text = "Данные с устройства успешно отправлены",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(16.dp)
)
Button(
onClick = {
viewModel.setEvent(EmailSenderContract.Event.OnNavigateUp)
},
modifier = Modifier.widthIn(min = 128.dp)
) {
Text(
text = "Ок"
)
}
}
}
}

View File

@ -0,0 +1,82 @@
package llc.arma.vgate.app.ui.screens.sender
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import llc.arma.vgate.app.ui.BaseViewModel
import llc.arma.vgate.app.ui.screens.main.EmailSenderScreenRoute
import llc.arma.vgate.domain.usecase.SendWaitingReports
import javax.inject.Inject
@HiltViewModel
class EmailSenderViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val sendWaitingReports: SendWaitingReports
) : BaseViewModel<EmailSenderContract.State, EmailSenderContract.Event, EmailSenderContract.Effect>() {
private var sendJob: Job? = null
init {
send()
}
override fun setInitialState() = EmailSenderContract.State.Loading
override fun handleEvents(event: EmailSenderContract.Event) {
when(event){
is EmailSenderContract.Event.OnRetry -> reduce(viewState.value, event)
is EmailSenderContract.Event.OnNavigateUp -> reduce(viewState.value, event)
}
}
private fun reduce(
state: EmailSenderContract.State,
event: EmailSenderContract.Event.OnRetry
){
send()
}
private fun reduce(
state: EmailSenderContract.State,
event: EmailSenderContract.Event.OnNavigateUp
){
setEffect {
EmailSenderContract.Effect.Navigation.Up
}
}
private fun send() {
val params = savedStateHandle.toRoute<EmailSenderScreenRoute>()
setState {
EmailSenderContract.State.Loading
}
sendJob = viewModelScope.launch {
sendWaitingReports.invoke(params.requestId).fold(
onSuccess = {
setState {
EmailSenderContract.State.Success
}
},
onFailure = {
setState {
EmailSenderContract.State.Error(it)
}
}
)
}
}
}

View File

@ -18,6 +18,7 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import llc.arma.common.domain.Result
import llc.arma.vgate.domain.repository.EmailRepository
import java.io.File
import java.util.Properties
@ -87,7 +88,7 @@ class EmailRepositoryImpl @Inject constructor(
body: String,
fileName: String,
file: File
): Boolean {
): Result<Unit, Throwable> {
return coroutineScope {
@ -103,10 +104,10 @@ class EmailRepositoryImpl @Inject constructor(
body = body,
)
)
true
Result.success(Unit)
} catch (err: Throwable) {
err.printStackTrace()
false
Result.failure(err)
}
}

View File

@ -1,6 +1,7 @@
package llc.arma.vgate.domain.repository
import kotlinx.coroutines.flow.Flow
import llc.arma.common.domain.Result
import java.io.File
interface EmailRepository {
@ -10,7 +11,7 @@ interface EmailRepository {
body: String,
fileName: String,
file: File
) : Boolean
): Result<Unit, Throwable>
suspend fun saveEmail(email: String)

View File

@ -1,8 +1,10 @@
package llc.arma.vgate.domain.usecase
import android.app.Application
import android.content.res.Resources.NotFoundException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import llc.arma.common.domain.Result
import llc.arma.vgate.domain.model.ReadResult
import llc.arma.vgate.domain.model.ReadResultPoint
import llc.arma.vgate.domain.model.SendRequest
@ -45,66 +47,87 @@ class SendWaitingReports @Inject constructor(
return sendRequestRepository.getByStatus(SendRequest.Status.Waiting).all {
send(it)
send(it).isSuccess
}
}
suspend operator fun invoke(id: Long): Result<Unit, Throwable> {
val request = sendRequestRepository.getById(id) ?: return Result.failure(NotFoundException())
return send(request)
}
private suspend fun send(
request: SendRequest
): Boolean {
): Result<Unit, Throwable> {
val formatter = SimpleDateFormat.getDateInstance()
return withContext(Dispatchers.IO) {
suspend fun finishRequest(
status: SendRequest.Status = SendRequest.Status.Sent
){
sendRequestRepository.save(
request.copy(
status = status
val formatter = SimpleDateFormat.getDateInstance()
suspend fun finishRequest(
status: SendRequest.Status = SendRequest.Status.Sent
) {
sendRequestRepository.save(
request.copy(
status = status
)
)
)
}
val statistic = getReadResultSummary(request.readResultId) ?: return run { finishRequest(); true}
val readResult = readResultRepository.getById(request.readResultId) ?: return run { finishRequest(); true}
val vehicle = vehicleRepository.getById(readResult.vehicleId) ?: return run { finishRequest(); true}
val ranges = vibrationRangeRepository.getByVehicleId(vehicle.id)
val workbook = XSSFWorkbook()
workbook.createTableSheet(
vehicle = vehicle,
statistic = statistic
)
workbook.createRawDataSheet(
vehicle = vehicle,
ranges = ranges,
history = statistic.history
)
val reportFile = File.createTempFile("snd", ".xlsx", app.cacheDir)
withContext(Dispatchers.IO) {
FileOutputStream(reportFile).use {
workbook.write(it)
}
val statistic = getReadResultSummary(request.readResultId)
?: return@withContext run { finishRequest(); Result.success(Unit) }
val readResult = readResultRepository.getById(request.readResultId)
?: return@withContext run { finishRequest(); Result.success(Unit) }
val vehicle = vehicleRepository.getById(readResult.vehicleId)
?: return@withContext run { finishRequest(); Result.success(Unit) }
val ranges = vibrationRangeRepository.getByVehicleId(vehicle.id)
val workbook = XSSFWorkbook()
workbook.createTableSheet(
vehicle = vehicle,
statistic = statistic
)
workbook.createRawDataSheet(
vehicle = vehicle,
ranges = ranges,
history = statistic.history
)
val reportFile = File.createTempFile("snd", ".xlsx", app.cacheDir)
withContext(Dispatchers.IO) {
FileOutputStream(reportFile).use {
workbook.write(it)
}
}
val sendResult = emailRepository.sendFile(
subject = "Отчет от vGate по \"${vehicle.name}\"",
fileName = "Report.xlsx",
body = "Статистика активности \"${vehicle.name}\" за ${
formatter.format(
Date(
readResult.date
)
)
}",
file = reportFile
)
if (sendResult.isSuccess) finishRequest(SendRequest.Status.Sent)
return@withContext sendResult
}
val sendResult = emailRepository.sendFile(
subject = "Отчет от vGate по \"${vehicle.name}\"",
fileName = "Report.xlsx",
body = "Статистика активности \"${vehicle.name}\" за ${formatter.format(Date(readResult.date))}" ,
file = reportFile
)
if(sendResult) finishRequest(SendRequest.Status.Sent)
return sendResult
}
private fun XSSFWorkbook.createTableSheet(