diff --git a/mage/src/main/AndroidManifest.xml b/mage/src/main/AndroidManifest.xml index 4dc51dd93..78c01082e 100644 --- a/mage/src/main/AndroidManifest.xml +++ b/mage/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + tools:ignore="LockedOrientationActivity"> + android:theme="@style/AppTheme3.NoActionBar"> @@ -133,7 +136,7 @@ + android:theme="@style/AppTheme.NoActionBar" + android:configChanges="orientation" + android:screenOrientation="portrait"/> diff --git a/mage/src/main/java/mil/nga/giat/mage/MageApplication.kt b/mage/src/main/java/mil/nga/giat/mage/MageApplication.kt index c84fe4188..a3b4bf236 100644 --- a/mage/src/main/java/mil/nga/giat/mage/MageApplication.kt +++ b/mage/src/main/java/mil/nga/giat/mage/MageApplication.kt @@ -31,7 +31,6 @@ import mil.nga.giat.mage.location.LocationFetchService import mil.nga.giat.mage.location.LocationReportingService import mil.nga.giat.mage.login.AccountStateActivity import mil.nga.giat.mage.login.LoginActivity -import mil.nga.giat.mage.login.ServerUrlActivity import mil.nga.giat.mage.login.SignupActivity import mil.nga.giat.mage.login.idp.IdpLoginActivity import mil.nga.giat.mage.network.Server @@ -45,6 +44,7 @@ import mil.nga.giat.mage.observation.sync.ObservationSyncWorker import mil.nga.giat.mage.data.datasource.observation.ObservationLocalDataSource import mil.nga.giat.mage.data.datasource.user.UserLocalDataSource import mil.nga.giat.mage.di.TokenStatus +import mil.nga.giat.mage.login.ServerUrlActivity import javax.inject.Inject @HiltAndroidApp diff --git a/mage/src/main/java/mil/nga/giat/mage/data/repository/api/ApiRepository.kt b/mage/src/main/java/mil/nga/giat/mage/data/repository/api/ApiRepository.kt index a433120ed..fd9f62886 100644 --- a/mage/src/main/java/mil/nga/giat/mage/data/repository/api/ApiRepository.kt +++ b/mage/src/main/java/mil/nga/giat/mage/data/repository/api/ApiRepository.kt @@ -11,9 +11,9 @@ import org.json.JSONObject import javax.inject.Inject sealed class ApiResponse { - object Valid: ApiResponse() - object Invalid: ApiResponse() - data class Error(val message: String): ApiResponse() + data object Success: ApiResponse() + data class Incompatible(val version: String): ApiResponse() + data class Error(val statusCode: Int? = null, val message: String? = null): ApiResponse() } class ApiRepository @Inject constructor( @@ -30,28 +30,23 @@ class ApiRepository @Inject constructor( removeValues() populateValues(SERVER_API_PREFERENCE_PREFIX, apiJson) parseAuthenticationStrategies(apiJson) - if (isApiValid()) ApiResponse.Valid else ApiResponse.Invalid + + val majorVersion = preferences.getInt(application.getString(R.string.serverVersionMajorKey), 0) + val minorVersion = preferences.getInt(application.getString(R.string.serverVersionMinorKey), 0) + val patchVersion = preferences.getInt(application.getString(R.string.serverVersionPatchKey), 0) + val isApiCompatible = Compatibility.isCompatibleWith(majorVersion, minorVersion) + if (isApiCompatible) { + ApiResponse.Success + } else { + ApiResponse.Incompatible("$majorVersion.$minorVersion.$patchVersion") + } } else { - val message = response.errorBody()?.string() ?: "Application is not compatible with server" - ApiResponse.Error(message) + ApiResponse.Error(response.code(), response.errorBody()?.string()) } } catch (e: Exception) { Log.e(LOG_NAME, "Error fetching API", e) - ApiResponse.Error(e.cause?.localizedMessage ?: "Cannot connect to server.") - } - } - - private fun isApiValid(): Boolean { - // check versions - var majorVersion: Int? = null - if (preferences.contains(application.getString(R.string.serverVersionMajorKey))) { - majorVersion = preferences.getInt(application.getString(R.string.serverVersionMajorKey), 0) - } - var minorVersion: Int? = null - if (preferences.contains(application.getString(R.string.serverVersionMinorKey))) { - minorVersion = preferences.getInt(application.getString(R.string.serverVersionMinorKey), 0) + ApiResponse.Error(message = e.message) } - return Compatibility.isCompatibleWith(majorVersion!!, minorVersion!!) } private fun populateValues(sharedPreferenceName: String, json: JSONObject) { diff --git a/mage/src/main/java/mil/nga/giat/mage/login/LoginActivity.kt b/mage/src/main/java/mil/nga/giat/mage/login/LoginActivity.kt index b9da1b560..47baa38ef 100644 --- a/mage/src/main/java/mil/nga/giat/mage/login/LoginActivity.kt +++ b/mage/src/main/java/mil/nga/giat/mage/login/LoginActivity.kt @@ -3,7 +3,6 @@ package mil.nga.giat.mage.login import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences -import android.graphics.Typeface import android.net.Uri import android.os.Bundle import android.util.Log @@ -147,8 +146,6 @@ class LoginActivity : AppCompatActivity() { // no title bar setContentView(R.layout.activity_login) hideKeyboardOnClick(findViewById(R.id.login)) - val appName = findViewById(R.id.mage) - appName.setTypeface(Typeface.createFromAsset(assets, "fonts/GondolaMage-Regular.otf")) (findViewById(R.id.login_version) as TextView).text = "App Version: " + preferences.getString(getString(R.string.buildVersionKey), "NA") serverUrlText = findViewById(R.id.server_url) val serverUrl = preferences.getString(getString(R.string.serverURLKey), getString(R.string.serverURLDefaultValue))!! @@ -187,12 +184,10 @@ class LoginActivity : AppCompatActivity() { } private fun observeAuthenticationState(state: AuthenticationState) { - if (state === AuthenticationState.LOADING) { - findViewById(R.id.login_status).visibility = View.VISIBLE - findViewById(R.id.login_form).visibility = View.GONE - } else if (state === AuthenticationState.ERROR) { - findViewById(R.id.login_status).visibility = View.GONE - findViewById(R.id.login_form).visibility = View.VISIBLE + findViewById(R.id.progress).visibility = if (state === AuthenticationState.LOADING) { + View.VISIBLE + } else { + View.VISIBLE } } @@ -356,7 +351,7 @@ class LoginActivity : AppCompatActivity() { } } - fun changeServerURL() { + private fun changeServerURL() { val intent = Intent(this, ServerUrlActivity::class.java) startActivity(intent) finish() diff --git a/mage/src/main/java/mil/nga/giat/mage/login/LoginViewModel.kt b/mage/src/main/java/mil/nga/giat/mage/login/LoginViewModel.kt index 1f4566afc..8803a52f6 100644 --- a/mage/src/main/java/mil/nga/giat/mage/login/LoginViewModel.kt +++ b/mage/src/main/java/mil/nga/giat/mage/login/LoginViewModel.kt @@ -99,7 +99,7 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { val response = apiRepository.getApi(url) if (authenticationState.value != AuthenticationState.LOADING) { - _apiStatus.value = response is ApiResponse.Valid + _apiStatus.value = response is ApiResponse.Success } } } diff --git a/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.java b/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.java deleted file mode 100644 index d93b21b8a..000000000 --- a/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.java +++ /dev/null @@ -1,168 +0,0 @@ -package mil.nga.giat.mage.login; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Typeface; -import android.os.Bundle; -import android.util.Patterns; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.lifecycle.ViewModelProvider; -import androidx.preference.PreferenceManager; - -import com.google.android.material.textfield.TextInputLayout; - -import org.apache.commons.lang3.StringUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -import javax.inject.Inject; - -import dagger.hilt.android.AndroidEntryPoint; -import mil.nga.giat.mage.R; -import mil.nga.giat.mage.contact.ContactDialog; -import mil.nga.giat.mage.network.Resource; -import mil.nga.giat.mage.database.dao.MageSqliteOpenHelper; -import mil.nga.giat.mage.data.datasource.observation.AttachmentLocalDataSource; -import mil.nga.giat.mage.data.datasource.observation.ObservationLocalDataSource; -import mil.nga.giat.mage.sdk.preferences.PreferenceHelper; - -@AndroidEntryPoint -public class ServerUrlActivity extends AppCompatActivity { - - @Inject - MageSqliteOpenHelper daoStore; - @Inject ObservationLocalDataSource observationLocalDataSource; - @Inject AttachmentLocalDataSource attachmentLocalDataSource; - - private View apiStatusView; - private View serverUrlForm; - private EditText serverUrlTextView; - private TextInputLayout serverUrlLayout; - private Button serverUrlButton; - - private ServerUrlViewModel viewModel; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_server_url); - - TextView appName = findViewById(R.id.mage); - appName.setTypeface(Typeface.createFromAsset(getAssets(),"fonts/GondolaMage-Regular.otf")); - - apiStatusView = findViewById(R.id.api_status); - serverUrlForm = findViewById(R.id.server_url_form); - - serverUrlLayout = findViewById(R.id.server_url_layout); - - serverUrlTextView = findViewById(R.id.server_url); - Button cancelButton = findViewById(R.id.cancel_button); - cancelButton.setOnClickListener(v -> done()); - - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - String serverUrl = sharedPreferences.getString(getString(R.string.serverURLKey), getString(R.string.serverURLDefaultValue)); - if (StringUtils.isNoneEmpty(serverUrl)) { - serverUrlTextView.setText(serverUrl); - } else { - // Don't let user cancel if no URL has been set. - cancelButton.setVisibility(View.GONE); - } - serverUrlTextView.setSelection(serverUrlTextView.getText().length()); - - serverUrlButton = findViewById(R.id.server_url_button); - serverUrlButton.setOnClickListener(v -> onChangeServerUrl()); - - viewModel = new ViewModelProvider(this).get(ServerUrlViewModel.class); - viewModel.getApi().observe(this, this::onApi); - } - - private void onChangeServerUrl() { - int unsavedObservations = observationLocalDataSource.getDirty().size(); - int unsavedAttachments = attachmentLocalDataSource.getDirtyAttachments().size(); - - List warnings = new ArrayList<>(); - if (unsavedObservations > 0) { - warnings.add(String.format(Locale.getDefault(), "%d unsaved observations", unsavedObservations)); - } - if (unsavedAttachments > 0) { - warnings.add(String.format(Locale.getDefault(),"%d unsaved attachments", unsavedAttachments)); - } - - if (warnings.size() > 0) { - new AlertDialog.Builder(this) - .setTitle("You Have Unsaved Data") - .setMessage(String.format("You have %s. All unsaved observations will be lost if you continue.", StringUtils.join(warnings, " and "))) - .setPositiveButton("Continue", (dialog, which) -> changeServerURL()) - .setNegativeButton(android.R.string.cancel, null) - .create() - .show(); - } else { - changeServerURL(); - } - } - - private void changeServerURL() { - String url = serverUrlTextView.getText().toString().trim(); - if (!Patterns.WEB_URL.matcher(url).matches()) { - serverUrlLayout.setError("Invalid URL"); - return; - } - - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(serverUrlTextView.getWindowToken(), 0); - - serverUrlTextView.setText(url); - viewModel.setUrl(url); - } - - public void onApi(Resource resource) { - if (resource.getStatus() == Resource.Status.LOADING) { - serverUrlButton.setEnabled(false); - apiStatusView.setVisibility(View.VISIBLE); - serverUrlForm.setVisibility(View.GONE); - } else { - if (resource.getStatus() == Resource.Status.SUCCESS) { - if (resource.getData() != null) { - done(); - } else { - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - ContactDialog dialog = new ContactDialog( - getApplicationContext(), - sharedPreferences, - "Compatibility Error", - "Your MAGE application is not compatible with this server. Please update your application or contact your MAGE administrator for support."); - dialog.show(null); - - serverUrlLayout.setError("Application is not compatible with server."); - } - } else { - apiStatusView.setVisibility(View.GONE); - serverUrlForm.setVisibility(View.VISIBLE); - serverUrlButton.setEnabled(true); - serverUrlLayout.setError(resource.getMessage()); - } - } - } - - private void done() { - daoStore.resetDatabase(); - PreferenceHelper preferenceHelper = PreferenceHelper.getInstance(getApplicationContext()); - preferenceHelper.initialize(true, R.xml.class); - - // finish this activity back to the login activity - Intent intent = new Intent(this, LoginActivity.class); - startActivity(intent); - finish(); - } -} \ No newline at end of file diff --git a/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.kt b/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.kt new file mode 100644 index 000000000..78d577383 --- /dev/null +++ b/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlActivity.kt @@ -0,0 +1,33 @@ +package mil.nga.giat.mage.login + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import mil.nga.giat.mage.ui.theme.MageTheme3 +import mil.nga.giat.mage.ui.url.ServerUrlScreen + +@AndroidEntryPoint +class ServerUrlActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MageTheme3 { + ServerUrlScreen( + onDone = { + val intent = Intent(this, LoginActivity::class.java) + startActivity(intent) + finish() + }, + onCancel = { + val intent = Intent(this, LoginActivity::class.java) + startActivity(intent) + finish() + } + ) + } + } + } +} \ No newline at end of file diff --git a/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlViewModel.kt b/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlViewModel.kt index 8be81ff90..a427d86c8 100644 --- a/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlViewModel.kt +++ b/mage/src/main/java/mil/nga/giat/mage/login/ServerUrlViewModel.kt @@ -2,48 +2,107 @@ package mil.nga.giat.mage.login import android.app.Application import android.content.SharedPreferences +import android.util.Patterns import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mil.nga.giat.mage.R +import mil.nga.giat.mage.data.datasource.observation.AttachmentLocalDataSource +import mil.nga.giat.mage.data.datasource.observation.ObservationLocalDataSource import mil.nga.giat.mage.database.MageDatabase import mil.nga.giat.mage.data.repository.api.ApiRepository import mil.nga.giat.mage.data.repository.api.ApiResponse import mil.nga.giat.mage.database.dao.MageSqliteOpenHelper -import mil.nga.giat.mage.network.Resource import javax.inject.Inject +const val ADMIN_EMAIL_PREFERENCE_KEY = "gContactinfoEmail" +const val ADMIN_PHONE_PREFERENCE_KEY = "gContactinfoPhone" + +data class ContactInfo( + val email: String? = null, + val phone: String? = null +) + +sealed class UrlState { + data object Valid: UrlState() + data object Invalid: UrlState() + data object InProgress: UrlState() + data class Error(val statusCode: Int?, val message: String?): UrlState() + data class Incompatible(val version: String, val contactInfo: ContactInfo): UrlState() +} + @HiltViewModel class ServerUrlViewModel @Inject constructor( private val application: Application, private val preferences: SharedPreferences, private val daoStore: MageSqliteOpenHelper, private val database: MageDatabase, - private val apiRepository: ApiRepository + private val apiRepository: ApiRepository, + private val observationLocalDataSource: ObservationLocalDataSource, + private val attachmentLocalDataSource: AttachmentLocalDataSource ): ViewModel() { + val url = preferences.getString(application.getString(R.string.serverURLKey), application.getString(R.string.serverURLDefaultValue)) ?: "" + val version = preferences.getString(application.getString(R.string.buildVersionKey), null) + + private val _unsavedData = MutableLiveData() + val unsavedData: LiveData = _unsavedData + + init { + viewModelScope.launch(Dispatchers.IO) { + val unsavedObservations = observationLocalDataSource.dirty + val unsavedAttachments = attachmentLocalDataSource.dirtyAttachments + if (unsavedObservations.isNotEmpty() || unsavedAttachments.isNotEmpty()) { + _unsavedData.postValue(true) + } + } + } - private val _api = MutableLiveData>() - val api: LiveData> = _api - fun setUrl(url: String) { - viewModelScope.launch(Dispatchers.IO) { + fun confirmUnsavedData() { + _unsavedData.value = false + } + + private val _urlState = MutableLiveData() + val urlState: LiveData = _urlState + + fun checkUrl(url: String) { + if (Patterns.WEB_URL.matcher(url).matches()) { + _urlState.value = UrlState.InProgress + + viewModelScope.launch(Dispatchers.IO) { when (val response = apiRepository.getApi(url)) { - is ApiResponse.Valid -> { - daoStore.resetDatabase() - database.destroy() - preferences.edit().putString(application.getString(R.string.serverURLKey), url).apply() - _api.postValue(Resource.success(true)) - } - is ApiResponse.Invalid -> { - _api.postValue(Resource.success(false)) - } - is ApiResponse.Error -> { - _api.postValue(Resource.error(response.message, false)) - } + is ApiResponse.Success -> { + daoStore.resetDatabase() + database.destroy() + preferences + .edit() + .putString(application.getString(R.string.serverURLKey), url) + .apply() + + _urlState.postValue(UrlState.Valid) + } + is ApiResponse.Incompatible -> { + val state = UrlState.Incompatible( + version = response.version, + contactInfo = ContactInfo( + email = preferences.getString(ADMIN_EMAIL_PREFERENCE_KEY, null), + phone = preferences.getString(ADMIN_PHONE_PREFERENCE_KEY, null) + ) + ) + _urlState.postValue(state) + } + is ApiResponse.Error -> { + _urlState.postValue(UrlState.Error(response.statusCode, response.message)) + } } - } + } + } else { + _urlState.postValue(UrlState.Invalid) + } } } \ No newline at end of file diff --git a/mage/src/main/java/mil/nga/giat/mage/login/SignupActivity.kt b/mage/src/main/java/mil/nga/giat/mage/login/SignupActivity.kt index 82431b943..a826bd14d 100644 --- a/mage/src/main/java/mil/nga/giat/mage/login/SignupActivity.kt +++ b/mage/src/main/java/mil/nga/giat/mage/login/SignupActivity.kt @@ -34,8 +34,6 @@ open class SignupActivity : AppCompatActivity() { binding = ActivitySignupBinding.inflate(layoutInflater) setContentView(binding.root) - binding.header.mage.typeface = Typeface.createFromAsset(assets, "fonts/GondolaMage-Regular.otf") - binding.signupButton.setOnClickListener { signup() } binding.cancelButton.setOnClickListener { cancel() } binding.refreshCaptcha.setOnClickListener { getCaptcha() } @@ -207,7 +205,6 @@ open class SignupActivity : AppCompatActivity() { } private fun toggleMask(visible: Boolean) { - binding.mask.visibility = if (visible) View.VISIBLE else View.GONE binding.progress.visibility = if (visible) View.VISIBLE else View.GONE } diff --git a/mage/src/main/java/mil/nga/giat/mage/network/Resource.kt b/mage/src/main/java/mil/nga/giat/mage/network/Resource.kt index bc8278368..2c3423c40 100644 --- a/mage/src/main/java/mil/nga/giat/mage/network/Resource.kt +++ b/mage/src/main/java/mil/nga/giat/mage/network/Resource.kt @@ -1,13 +1,10 @@ package mil.nga.giat.mage.network -import androidx.annotation.NonNull -import androidx.annotation.Nullable - // A generic class that contains data and userStatus about loading data. class Resource private constructor( - @param:NonNull @field:NonNull val status: Status, - @param:Nullable @field:Nullable val data: T?, - @param:Nullable @field:Nullable val message: String? + val status: Status, + val data: T?, + val message: String? ) { enum class Status { diff --git a/mage/src/main/java/mil/nga/giat/mage/ui/theme/Color.kt b/mage/src/main/java/mil/nga/giat/mage/ui/theme/Color.kt index 39e25cc28..e1373f61c 100644 --- a/mage/src/main/java/mil/nga/giat/mage/ui/theme/Color.kt +++ b/mage/src/main/java/mil/nga/giat/mage/ui/theme/Color.kt @@ -11,6 +11,7 @@ val OrangeA700 = Color(0xFFFF6D00) val Amber700 = Color(0xFFFFA000) +val Grey600 = Color(0xFF757575) val Grey800 = Color(0xFF424242) val Red300 = Color(0xFFE57373) diff --git a/mage/src/main/java/mil/nga/giat/mage/ui/theme/Theme3.kt b/mage/src/main/java/mil/nga/giat/mage/ui/theme/Theme3.kt index 4ab8f52ba..7af31048f 100644 --- a/mage/src/main/java/mil/nga/giat/mage/ui/theme/Theme3.kt +++ b/mage/src/main/java/mil/nga/giat/mage/ui/theme/Theme3.kt @@ -13,15 +13,17 @@ private val LightColorPalette = lightColorScheme( primary = Blue600, secondary = OrangeA700, tertiary = Blue800, + surfaceVariant = Color(red = 231, green = 231, blue = 231), error = Red800 ) private val DarkColorPalette = darkColorScheme( - primary = Grey800, + primary = Grey600, secondary = BlueA200, - tertiary = Grey800, + tertiary = Color(0xDDFFFFFF), error = Red300, - onPrimary = Color.White + onPrimary = Color.White, + surfaceVariant = Color(red = 42, green = 41, blue = 45) ) @Composable diff --git a/mage/src/main/java/mil/nga/giat/mage/ui/url/ServerUrlScreen.kt b/mage/src/main/java/mil/nga/giat/mage/ui/url/ServerUrlScreen.kt new file mode 100644 index 000000000..84993b9ea --- /dev/null +++ b/mage/src/main/java/mil/nga/giat/mage/ui/url/ServerUrlScreen.kt @@ -0,0 +1,448 @@ +package mil.nga.giat.mage.ui.url + +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.SecurityUpdateWarning +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import mil.nga.giat.mage.R +import mil.nga.giat.mage.login.ServerUrlViewModel +import mil.nga.giat.mage.login.UrlState +import mil.nga.giat.mage.ui.theme.onSurfaceDisabled + +@Composable +fun ServerUrlScreen( + onDone: () -> Unit, + onCancel: (() -> Unit)? = null, + viewModel: ServerUrlViewModel = hiltViewModel() +) { + val scrollState = rememberScrollState() + var url by remember { mutableStateOf(viewModel.url) } + val urlState by viewModel.urlState.observeAsState() + val unsavedData by viewModel.unsavedData.observeAsState(false) + var errorState by remember { mutableStateOf(null) } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(urlState) { + if (urlState is UrlState.Valid) { + onDone() + } + } + + if (unsavedData) { + UnsavedDataDialog( + onDismiss = { onCancel?.invoke() }, + onContinue = { viewModel.confirmUnsavedData() } + ) + } + + errorState?.let { state -> + ErrorDialog(state) { errorState = null } + } + + if (urlState is UrlState.InProgress) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + + ) { + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_wand_white_50dp), + contentDescription = "wand", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .padding(bottom = 16.dp) + .size(72.dp) + ) + + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Text( + text = "Welcome to MAGE!", + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceDisabled) { + Text( + text = "Specify a MAGE server URL to get started", + style = MaterialTheme.typography.titleMedium + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + TextField( + label = { Text("MAGE Server URL") }, + value = url, + onValueChange = { url = it }, + enabled = urlState != UrlState.InProgress, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { + keyboardController?.hide() + viewModel.checkUrl(url) + } + ), + isError = urlState is UrlState.Error || urlState is UrlState.Incompatible || urlState is UrlState.Invalid, + supportingText = { + if (urlState is UrlState.Invalid) { + Text("Please enter a valid URL") + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (url.isNotEmpty()) { + OutlinedButton( + onClick = { onCancel?.invoke() }, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + } + + Button( + onClick = { + keyboardController?.hide() + viewModel.checkUrl(url) + }, + enabled = urlState != UrlState.InProgress, + modifier = Modifier.weight(1f) + ) { + Text("Set URL") + } + } + } + + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + when (val state = urlState) { + is UrlState.Incompatible -> { Incompatible(state) } + is UrlState.Error -> { + ErrorContent(state.message) { errorState = state } + } + else -> { Spacer(Modifier.weight(1f)) } + } + + viewModel.version?.let{ version -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceDisabled) { + Text( + text = "MAGE Android $version", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + } + } +} + +@Composable +private fun Incompatible( + state: UrlState.Incompatible +) { + val context = LocalContext.current + + Column( + Modifier.padding(vertical = 16.dp) + ) { + Icon( + Icons.Outlined.SecurityUpdateWarning, + contentDescription = "Incompatible", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + .size(36.dp) + ) + + val text = buildAnnotatedString { + val textStyle = MaterialTheme.typography.bodyLarge.copy( + color = LocalContentColor.current.copy(alpha = .87f) + ).toSpanStyle() + withStyle(textStyle) { + append("Your MAGE application is not compatible with server version ${state.version}. Please update your application or contact your MAGE administrator") + } + + if (state.contactInfo.phone != null || state.contactInfo.email != null) { + withStyle(textStyle) { append(" at ") } + } + + if (state.contactInfo.phone != null) { + pushStringAnnotation( + tag = "phone", + annotation = state.contactInfo.phone + ) + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.tertiary + ).toSpanStyle() + ) { + append(state.contactInfo.phone) + } + pop() + } + + if (state.contactInfo.phone != null && state.contactInfo.email != null) { + withStyle(textStyle) { append(" or ") } + } + + if (state.contactInfo.email != null) { + pushStringAnnotation( + tag = "email", + annotation = state.contactInfo.email + ) + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.tertiary + ).toSpanStyle() + ) { + append(state.contactInfo.email) + } + pop() + } + + withStyle(textStyle) { append(" for support.") } + } + + ClickableText( + text = text, + style = TextStyle(textAlign = TextAlign.Center), + onClick = { offset -> + text.getStringAnnotations( + tag = "email", start = offset, end = offset + ).firstOrNull()?.let { + val uri = Uri.Builder() + .scheme("mailto") + .opaquePart(state.contactInfo.email) + .appendQueryParameter("subject", "MAGE Compatibility") + .appendQueryParameter("body", "MAGE Application not compatible with server version ${state.version}") + .build() + + val intent = Intent(Intent.ACTION_SENDTO, uri) + context.startActivity(intent) + } + + text.getStringAnnotations( + tag = "phone", start = offset, end = offset + ).firstOrNull()?.let { + val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${state.contactInfo.phone}")) + context.startActivity(intent) + } + } + ) + } +} + +@Composable +private fun ErrorContent( + message: String?, + onInfo: (String) -> Unit +) { + Column(Modifier.padding(vertical = 16.dp)) { + Icon( + Icons.Outlined.ErrorOutline, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + .size(32.dp) + ) + + val text = buildAnnotatedString { + val style = MaterialTheme.typography.bodyLarge.copy( + color = LocalContentColor.current.copy(alpha = .87f) + ).toSpanStyle() + withStyle(style) { append("This URL does not appear to be a MAGE server") } + + if (message != null) { + pushStringAnnotation( + tag = "info", + annotation = "info" + ) + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.tertiary + ).toSpanStyle() + ) { + append(" more info") + } + pop() + } + + withStyle(style) { append(".") } + } + + ClickableText( + text = text, + style = TextStyle(textAlign = TextAlign.Center), + onClick = { offset -> + if (message != null) { + text.getStringAnnotations( + tag = "info", start = offset, end = offset + ).firstOrNull()?.let { + onInfo(message) + } + } + } + ) + } +} + +@Composable +fun UnsavedDataDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + icon = { + Icon( + imageVector = Icons.Outlined.WarningAmber, + tint = MaterialTheme.colorScheme.error, + contentDescription = "warning" + ) + }, + title = { + Text(text = "Unsaved Data") + }, + text = { + Text( + text = "All data will be lost if you continue.", + textAlign = TextAlign.Center + ) + }, + onDismissRequest = { onDismiss() }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text("Cancel") + } + }, + confirmButton = { + TextButton(onClick = { onContinue() }) { + Text("Continue") + } + } + ) +} + +@Composable +fun ErrorDialog( + errorState: UrlState.Error, + onDismiss: () -> Unit +) { + AlertDialog( + icon = { + Icon( + imageVector = Icons.Outlined.Info, + tint = MaterialTheme.colorScheme.tertiary, + contentDescription = "info" + ) + }, + title = { + errorState.statusCode?.let { + Text( + text = it.toString(), + textAlign = TextAlign.Center + ) + } + }, + text = { + errorState.message?.let { + SelectionContainer { + Text( + text = it, + textAlign = TextAlign.Center, + fontFamily = FontFamily.Monospace + ) + } + } + }, + onDismissRequest = { onDismiss() }, + confirmButton = { + TextButton(onClick = { onDismiss() }) { + Text("OK") + } + } + ) +} \ No newline at end of file diff --git a/mage/src/main/res/layout/activity_login.xml b/mage/src/main/res/layout/activity_login.xml index a9676265c..8afe02b07 100644 --- a/mage/src/main/res/layout/activity_login.xml +++ b/mage/src/main/res/layout/activity_login.xml @@ -1,92 +1,50 @@ + android:layout_height="match_parent"> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + android:layout_height="0dp" + android:layout_weight="2" + android:gravity="center" + android:orientation="horizontal" > - + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:windowSoftInputMode="stateVisible"> - - + + - + - + + + + + + + + + + + + diff --git a/mage/src/main/res/layout/activity_server_url.xml b/mage/src/main/res/layout/activity_server_url.xml deleted file mode 100644 index 5a4f6d532..000000000 --- a/mage/src/main/res/layout/activity_server_url.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - -