aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--androidApp/src/main/java/mx/trackermap/TrackerMap/android/TrackerApp.kt4
-rw-r--r--androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginFragment.kt23
-rw-r--r--androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginViewModel.kt20
-rw-r--r--androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsActivity.kt5
-rw-r--r--androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsViewModel.kt14
-rw-r--r--androidApp/src/main/res/layout/login.xml247
-rw-r--r--androidApp/src/main/res/layout/offline_banner.xml15
-rw-r--r--androidApp/src/main/res/layout/units_activity.xml17
-rw-r--r--androidApp/src/main/res/values-es-rMX/strings.xml1
-rw-r--r--androidApp/src/main/res/values/strings.xml1
-rw-r--r--iosApp/iosApp.xcodeproj/project.pbxproj4
-rw-r--r--iosApp/iosApp/Session/RootView.swift32
-rw-r--r--iosApp/iosApp/Session/RootViewModel.swift36
-rw-r--r--iosApp/iosApp/Shared/OfflineBanner.swift27
-rw-r--r--iosApp/iosApp/Units/UnitsViewModel.swift12
-rw-r--r--iosApp/iosApp/en.lproj/Localizable.strings1
-rw-r--r--iosApp/iosApp/es-419.lproj/Localizable.strings1
-rw-r--r--iosApp/iosApp/iOSApp.swift9
-rw-r--r--shared/src/androidMain/AndroidManifest.xml4
-rw-r--r--shared/src/androidMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt55
-rw-r--r--shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/ApiClient.kt2
-rw-r--r--shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/SessionManager.kt2
-rw-r--r--shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt8
-rw-r--r--shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/SessionController.kt18
-rw-r--r--shared/src/iosMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt25
25 files changed, 427 insertions, 156 deletions
diff --git a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/TrackerApp.kt b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/TrackerApp.kt
index aa92c91..1bb921d 100644
--- a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/TrackerApp.kt
+++ b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/TrackerApp.kt
@@ -28,6 +28,7 @@ import mx.trackermap.TrackerMap.android.units.UnitsViewModel
import mx.trackermap.TrackerMap.client.apis.*
import mx.trackermap.TrackerMap.client.infrastructure.SessionManager
import mx.trackermap.TrackerMap.controllers.*
+import mx.trackermap.TrackerMap.controllers.NetworkController
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
@@ -56,7 +57,8 @@ open class TrackerApp : Application() {
factory { ReportsApi(get()) }
factory { GeofencesApi(get()) }
- factory { SessionController(get(), get()) }
+ factory { NetworkController(applicationContext) }
+ factory { SessionController(get(), get(), get()) }
factory { UnitsController(get(), get()) }
factory { GeofencesController(get()) }
factory { ReportController(get(), get()) }
diff --git a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginFragment.kt b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginFragment.kt
index 6a30789..8879349 100644
--- a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginFragment.kt
+++ b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginFragment.kt
@@ -67,7 +67,6 @@ class LoginFragment : Fragment() {
getString(R.string.default_server_url)
) ?: getString(R.string.default_server_url)
)
- loginViewModel.restoreSession()
}
override fun onStart() {
@@ -100,6 +99,26 @@ class LoginFragment : Fragment() {
}
private fun setupObservers() {
+ loginViewModel.networkAvailable.observe(viewLifecycleOwner) { available ->
+ Log.d("LoginFragment", "available = $available, session = ${loginViewModel.hasSession}")
+
+ binding.offlineBanner.root.visibility =
+ if (available == true) View.GONE else View.VISIBLE
+
+ binding.infoLoading.root.visibility = when (available) {
+ null -> View.VISIBLE
+ true -> if (loginViewModel.hasSession) {
+ loginViewModel.restoreSession()
+ View.VISIBLE
+ } else View.GONE
+ false -> if (loginViewModel.hasSession) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ }
+ }
+
loginViewModel.loginState.observe(viewLifecycleOwner) { result ->
Log.d("LoginFragment", result.toString())
when (result) {
@@ -164,4 +183,4 @@ class LoginFragment : Fragment() {
const val PREFERENCE_SERVER_URL = "server_url"
const val PREFERENCE_TOKEN = "token"
}
-} \ No newline at end of file
+}
diff --git a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginViewModel.kt b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginViewModel.kt
index bcee2ec..434ac44 100644
--- a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginViewModel.kt
+++ b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/session/LoginViewModel.kt
@@ -17,32 +17,46 @@
*/
package mx.trackermap.TrackerMap.android.session
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zhuinden.eventemitter.EventEmitter
import com.zhuinden.eventemitter.EventSource
import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import mx.trackermap.TrackerMap.client.models.SessionBody
+import mx.trackermap.TrackerMap.controllers.NetworkController
import mx.trackermap.TrackerMap.controllers.SessionController
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@DelicateCoroutinesApi
class LoginViewModel : ViewModel(), KoinComponent {
-
+ private val networkController: NetworkController by inject()
private val sessionController: SessionController by inject()
+ private val _networkAvailable = MutableLiveData<Boolean?>(null)
+ val networkAvailable: LiveData<Boolean?> = _networkAvailable
private val loginStateEmitter = EventEmitter<SessionController.LoginState>()
val loginState: EventSource<SessionController.LoginState> = loginStateEmitter
+ val hasSession: Boolean get() = sessionController.hasSession
init {
viewModelScope.launch {
+ setupNetworkObserver()
+ }
+ viewModelScope.launch {
setupLoginStateObserver()
}
}
+ private suspend fun setupNetworkObserver() {
+ networkController.networkAvailable.collect {
+ _networkAvailable.postValue(it)
+ }
+ }
+
private suspend fun setupLoginStateObserver() {
sessionController.loginStateFlow.collect {
it?.let {
@@ -52,7 +66,7 @@ class LoginViewModel : ViewModel(), KoinComponent {
}
fun restoreSession() {
- sessionController.restoreSession()
+ sessionController.getSession()
}
fun login(email: String, password: String, url: String, token: String?) {
diff --git a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsActivity.kt b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsActivity.kt
index 8a161c3..443a205 100644
--- a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsActivity.kt
+++ b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsActivity.kt
@@ -28,7 +28,6 @@ import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.TooltipCompat
-import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.commit
import kotlinx.coroutines.DelicateCoroutinesApi
import mx.trackermap.TrackerMap.android.R
@@ -150,6 +149,10 @@ class UnitsActivity : AppCompatActivity() {
}
private fun setupObservers() {
+ unitsViewModel.networkAvailable.observe(this) { available ->
+ binding.offlineBanner.root.visibility =
+ if (available == true) View.GONE else View.VISIBLE
+ }
unitsViewModel.unitsDisplayMode.observe(this) { displayMode ->
binding.displayModeToggle.setImageResource(
when (displayMode) {
diff --git a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsViewModel.kt b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsViewModel.kt
index b5ed78d..05962e9 100644
--- a/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsViewModel.kt
+++ b/androidApp/src/main/java/mx/trackermap/TrackerMap/android/units/UnitsViewModel.kt
@@ -27,6 +27,7 @@ import mx.trackermap.TrackerMap.client.models.Geofence
import mx.trackermap.TrackerMap.client.models.MapLayer
import mx.trackermap.TrackerMap.client.models.UnitInformation
import mx.trackermap.TrackerMap.controllers.GeofencesController
+import mx.trackermap.TrackerMap.controllers.NetworkController
import mx.trackermap.TrackerMap.controllers.UnitsController
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -48,9 +49,11 @@ class UnitsViewModel(
val animated: Boolean,
)
+ private val networkController: NetworkController by inject()
private val unitsController: UnitsController by inject()
private val geofenceController: GeofencesController by inject()
+ private var _networkAvailable = MutableLiveData<Boolean?>()
private var _searchQuery = savedStateHandle.getLiveData("searchQuery", "")
private var _unitsDisplayMode = MutableLiveData(UnitsDisplayMode.MAP)
private var _units = MutableLiveData<List<UnitInformation>>()
@@ -61,6 +64,7 @@ class UnitsViewModel(
private var _geofences = MutableLiveData<Map<Int, Geofence>>()
private val _camera = MutableLiveData<Camera?>()
+ val networkAvailable: LiveData<Boolean?> get() = _networkAvailable
val searchQuery: LiveData<String> get() = _searchQuery
val unitsDisplayMode: LiveData<UnitsDisplayMode> get() = _unitsDisplayMode
val units: LiveData<List<UnitInformation>> get() = _units
@@ -75,6 +79,9 @@ class UnitsViewModel(
Log.d("UnitsViewModel", "Initializing Units View Model")
unitsController.fetchUnits(viewModelScope)
viewModelScope.launch {
+ setupNetworkObserver()
+ }
+ viewModelScope.launch {
setupUnitsObserver()
}
viewModelScope.launch {
@@ -86,6 +93,13 @@ class UnitsViewModel(
}
}
+ private suspend fun setupNetworkObserver() {
+ networkController.networkAvailable.collect { available ->
+ Log.d("UnitsViewModel", "Collecting network state")
+ _networkAvailable.value = available
+ }
+ }
+
private suspend fun setupUnitsObserver() {
(unitsController.displayedUnitsFlow as StateFlow<List<UnitInformation>>).collect { units ->
Log.d("UnitsViewModel", "Collecting units")
diff --git a/androidApp/src/main/res/layout/login.xml b/androidApp/src/main/res/layout/login.xml
index 78c3e46..816e625 100644
--- a/androidApp/src/main/res/layout/login.xml
+++ b/androidApp/src/main/res/layout/login.xml
@@ -1,131 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:name="mx.trackermap.TrackerMap.android.session.LoginFragment"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <androidx.constraintlayout.widget.ConstraintLayout
+<androidx.constraintlayout.widget.ConstraintLayout
+ android:name="mx.trackermap.TrackerMap.android.session.LoginFragment"
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/bannerImage"
- android:layout_width="0dp"
- android:layout_height="@dimen/about_logo_height"
- app:layout_constraintWidth_percent="0.5"
- app:layout_constraintDimensionRatio="1"
+ <include
+ android:id="@+id/offlineBanner"
+ layout="@layout/offline_banner"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- android:layout_marginHorizontal="@dimen/card_margin"
- app:srcCompat="@drawable/about_logo" />
-
- <androidx.cardview.widget.CardView
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- app:cardCornerRadius="@dimen/card_border_radius"
- app:cardElevation="@dimen/card_elevation"
- app:cardUseCompatPadding="true"
- app:layout_constraintTop_toBottomOf="@id/bannerImage"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- android:layout_marginHorizontal="@dimen/card_margin"
- android:layout_marginBottom="16dp">
+ android:visibility="gone"
+ tools:visibility="visible" />
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ app:layout_constraintTop_toBottomOf="@id/offlineBanner"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.appcompat.widget.AppCompatImageView
+ android:id="@+id/bannerImage"
+ android:layout_width="0dp"
+ android:layout_height="@dimen/about_logo_height"
+ app:layout_constraintWidth_percent="0.5"
+ app:layout_constraintDimensionRatio="1"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginHorizontal="@dimen/card_margin"
+ app:srcCompat="@drawable/about_logo" />
+
+ <androidx.cardview.widget.CardView
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:paddingVertical="@dimen/card_large_padding"
- android:paddingHorizontal="@dimen/card_padding">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/usernameInputLayout"
- style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
+ app:cardCornerRadius="@dimen/card_border_radius"
+ app:cardElevation="@dimen/card_elevation"
+ app:cardUseCompatPadding="true"
+ app:layout_constraintTop_toBottomOf="@id/bannerImage"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginHorizontal="@dimen/card_margin"
+ android:layout_marginBottom="16dp">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:hint="@string/login_username"
- app:layout_constraintBottom_toTopOf="@+id/passwordInputLayout"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0.5"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/usernameEditText"
+ android:paddingVertical="@dimen/card_large_padding"
+ android:paddingHorizontal="@dimen/card_padding">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/usernameInputLayout"
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:inputType="textEmailAddress"/>
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/passwordInputLayout"
- style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/login_password"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0.5"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/usernameInputLayout"
- app:layout_constraintBottom_toTopOf="@id/urlInputLayout"
- android:layout_marginTop="@dimen/fields_spacing">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/passwordEditText"
+ android:hint="@string/login_username"
+ app:layout_constraintBottom_toTopOf="@+id/passwordInputLayout"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/usernameEditText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="textEmailAddress"/>
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/passwordInputLayout"
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:inputType="textPassword"/>
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/urlInputLayout"
- style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/login_url"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0.5"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/passwordInputLayout"
- app:layout_constraintBottom_toTopOf="@id/signinButton"
- android:layout_marginTop="@dimen/fields_spacing">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/urlEditText"
+ android:hint="@string/login_password"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/usernameInputLayout"
+ app:layout_constraintBottom_toTopOf="@id/urlInputLayout"
+ android:layout_marginTop="@dimen/fields_spacing">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/passwordEditText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="textPassword"/>
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/urlInputLayout"
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:inputType="textUri"/>
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/signinButton"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- app:layout_constraintWidth_percent="0.5"
- app:layout_constraintTop_toBottomOf="@id/urlInputLayout"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintHorizontal_bias="0.5"
- android:layout_marginTop="@dimen/fields_large_spacing"
- android:text="@string/login_login"/>
-
- </androidx.constraintlayout.widget.ConstraintLayout>
-
- </androidx.cardview.widget.CardView>
-
- </androidx.constraintlayout.widget.ConstraintLayout>
-
- <include
- android:id="@+id/infoLoading"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- layout="@layout/loading_indicator"
- android:visibility="gone"/>
-
-</FrameLayout> \ No newline at end of file
+ android:hint="@string/login_url"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/passwordInputLayout"
+ app:layout_constraintBottom_toTopOf="@id/signinButton"
+ android:layout_marginTop="@dimen/fields_spacing">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/urlEditText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="textUri"/>
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/signinButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_constraintWidth_percent="0.5"
+ app:layout_constraintTop_toBottomOf="@id/urlInputLayout"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintHorizontal_bias="0.5"
+ android:layout_marginTop="@dimen/fields_large_spacing"
+ android:text="@string/login_login"/>
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ </androidx.cardview.widget.CardView>
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <include
+ android:id="@+id/infoLoading"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ layout="@layout/loading_indicator"
+ android:visibility="gone"/>
+
+ </FrameLayout>
+</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file
diff --git a/androidApp/src/main/res/layout/offline_banner.xml b/androidApp/src/main/res/layout/offline_banner.xml
new file mode 100644
index 0000000..6710d08
--- /dev/null
+++ b/androidApp/src/main/res/layout/offline_banner.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="10dp"
+ android:background="@color/colorAccent">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAlignment="center"
+ android:text="@string/login_no_internet"
+ android:textColor="@android:color/white" />
+
+</FrameLayout> \ No newline at end of file
diff --git a/androidApp/src/main/res/layout/units_activity.xml b/androidApp/src/main/res/layout/units_activity.xml
index 60a77db..8f46516 100644
--- a/androidApp/src/main/res/layout/units_activity.xml
+++ b/androidApp/src/main/res/layout/units_activity.xml
@@ -15,7 +15,16 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"/>
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <include
+ android:id="@+id/offlineBanner"
+ layout="@layout/offline_banner"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:visibility="gone"
+ tools:visibility="visible" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/displayModeToggle"
@@ -28,7 +37,7 @@
app:elevation="@dimen/fab_elevation"
app:fabSize="mini"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/offlineBanner"
tools:ignore="ContentDescription" />
<com.google.android.material.card.MaterialCardView
@@ -83,7 +92,7 @@
app:elevation="@dimen/fab_elevation"
app:fabSize="mini"
app:layout_constraintEnd_toStartOf="@id/userButton"
- app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintTop_toBottomOf="@id/offlineBanner" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/userButton"
@@ -98,6 +107,6 @@
app:elevation="@dimen/fab_elevation"
app:fabSize="mini"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toTopOf="parent"/>
+ app:layout_constraintTop_toBottomOf="@id/offlineBanner" />
</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file
diff --git a/androidApp/src/main/res/values-es-rMX/strings.xml b/androidApp/src/main/res/values-es-rMX/strings.xml
index 912a5d9..59e0408 100644
--- a/androidApp/src/main/res/values-es-rMX/strings.xml
+++ b/androidApp/src/main/res/values-es-rMX/strings.xml
@@ -19,6 +19,7 @@
<string name="login_password_missing">Falta la contraseña</string>
<string name="login_url_missing">Falta la URL del servidor</string>
<string name="login_login_failed">Falló el inicio de sesión</string>
+ <string name="login_no_internet">No hay conexión a internet</string>
<!-- UnitsActivity -->
<string name="toggle_list">Cambiar a lista de dispositivos</string>
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index 57eaf66..93cda7f 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -33,6 +33,7 @@
<string name="login_password_missing">Password is missing</string>
<string name="login_url_missing">Server URL is missing</string>
<string name="login_login_failed">Login failed</string>
+ <string name="login_no_internet">No internet connection</string>
<!-- UnitsActivity -->
<string name="toggle_list">Switch to device list</string>
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index 2e88271..28bf50a 100644
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -51,6 +51,7 @@
E3C651E727CB5426002F6F4C /* Tabler in Frameworks */ = {isa = PBXBuildFile; productRef = E3C651E627CB5426002F6F4C /* Tabler */; };
E3C651EA27CB61EE002F6F4C /* GEOSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E3C651E927CB61EE002F6F4C /* GEOSwift */; };
E3E77EE6279E6CE400150070 /* FlowCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E77EE5279E6CE400150070 /* FlowCollector.swift */; };
+ E3F5F2792AB8080E008A47A7 /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3F5F2782AB8080E008A47A7 /* OfflineBanner.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -121,6 +122,7 @@
E3B5740727F68F5F0018AFCF /* XlsxFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XlsxFile.swift; sourceTree = "<group>"; };
E3B5740927F69F750018AFCF /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
E3E77EE5279E6CE400150070 /* FlowCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCollector.swift; sourceTree = "<group>"; };
+ E3F5F2782AB8080E008A47A7 /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -287,6 +289,7 @@
E31D721427CF159900CDA320 /* SidewaysScroller.swift */,
E3B5740727F68F5F0018AFCF /* XlsxFile.swift */,
E3B5740927F69F750018AFCF /* ShareViewController.swift */,
+ E3F5F2782AB8080E008A47A7 /* OfflineBanner.swift */,
);
path = Shared;
sourceTree = "<group>";
@@ -420,6 +423,7 @@
E3385A4F27B0D8A10025311C /* UnitCommandsViewModel.swift in Sources */,
E392BE1727B77B7E002698F3 /* AppDelegate.swift in Sources */,
E36DF77B27AB740C003C561C /* MapViewController.swift in Sources */,
+ E3F5F2792AB8080E008A47A7 /* OfflineBanner.swift in Sources */,
E33A237327A7581A00DD647F /* Utils.swift in Sources */,
E3E77EE6279E6CE400150070 /* FlowCollector.swift in Sources */,
E33A236527A530F300DD647F /* SmallLabelStyle.swift in Sources */,
diff --git a/iosApp/iosApp/Session/RootView.swift b/iosApp/iosApp/Session/RootView.swift
index 297a2aa..d4f28f5 100644
--- a/iosApp/iosApp/Session/RootView.swift
+++ b/iosApp/iosApp/Session/RootView.swift
@@ -35,16 +35,28 @@ struct RootView: View {
var body: some View {
Group {
- switch rootViewModel.loginState {
- case is SessionController.LoginStateLoading:
- LoadingView()
- case is SessionController.LoginStateSuccess:
- UnitsView()
- default:
- LoginContentView(username: $username,
- password: $password,
- server: $server,
- onLogin: rootViewModel.login)
+ VStack {
+ if (rootViewModel.networkAvailable != true) {
+ OfflineBanner()
+ }
+
+ if rootViewModel.showLoadingView {
+ LoadingView()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ } else {
+ switch rootViewModel.loginState {
+ case is SessionController.LoginStateSuccess:
+ UnitsView()
+ case is SessionController.LoginStateLoading:
+ LoadingView()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ default:
+ LoginContentView(username: $username,
+ password: $password,
+ server: $server,
+ onLogin: rootViewModel.login)
+ }
+ }
}
}.environmentObject(rootViewModel)
}
diff --git a/iosApp/iosApp/Session/RootViewModel.swift b/iosApp/iosApp/Session/RootViewModel.swift
index ec103ba..16f21f3 100644
--- a/iosApp/iosApp/Session/RootViewModel.swift
+++ b/iosApp/iosApp/Session/RootViewModel.swift
@@ -20,14 +20,42 @@ import shared
@MainActor
class RootViewModel: ObservableObject {
+ @Inject private var networkController: NetworkController
@Inject private var sessionController: SessionController
+ @Published var networkAvailable: Bool? = nil
@Published var loginState: SessionController.LoginState = SessionController.LoginStateNothing()
+ @Published var showLoadingView: Bool = false
+
+ var hasSession: Bool{
+ get { return sessionController.hasSession }
+ }
init() {
- let collector = Collector<SessionController.LoginState?>(callback: setLoginState)
- sessionController.loginStateFlow.collect(collector: collector) { _ in }
- restoreSession()
+ let networkCollector = Collector<Bool?>(callback: setNetworkState)
+ networkController.networkAvailable.collect(collector: networkCollector) { _ in }
+ let sessionCollector = Collector<SessionController.LoginState?>(callback: setLoginState)
+ sessionController.loginStateFlow.collect(collector: sessionCollector) { _ in }
+ }
+
+ func setNetworkState(state: Bool?) {
+ print("Network state is: \(state?.description ?? "")")
+ Task { @MainActor in
+ self.networkAvailable = state
+
+ // Wait for internet to restore session
+ if (state == true && sessionController.hasSession) {
+ showLoadingView = false
+ restoreSession()
+ return
+ }
+
+ if (state == nil) {
+ showLoadingView = true
+ } else {
+ showLoadingView = hasSession
+ }
+ }
}
func setLoginState(state: SessionController.LoginState?) {
@@ -38,7 +66,7 @@ class RootViewModel: ObservableObject {
}
func restoreSession() {
- sessionController.restoreSession()
+ sessionController.getSession()
}
private func getFcmToken() -> String? {
diff --git a/iosApp/iosApp/Shared/OfflineBanner.swift b/iosApp/iosApp/Shared/OfflineBanner.swift
new file mode 100644
index 0000000..f29ab7d
--- /dev/null
+++ b/iosApp/iosApp/Shared/OfflineBanner.swift
@@ -0,0 +1,27 @@
+//
+// OfflineBanner.swift
+// iosApp
+//
+// Created by Ivan Avalos on 17/09/23.
+// Copyright © 2023 orgName. All rights reserved.
+//
+
+import SwiftUI
+
+struct OfflineBanner: View {
+ var body: some View {
+ Group {
+ Text("offline")
+ .foregroundColor(.white)
+ .padding(5)
+ }
+ .frame(minWidth: 0, maxWidth: .infinity)
+ .background(Color.red)
+ }
+}
+
+struct OfflineBanner_Previews: PreviewProvider {
+ static var previews: some View {
+ OfflineBanner()
+ }
+}
diff --git a/iosApp/iosApp/Units/UnitsViewModel.swift b/iosApp/iosApp/Units/UnitsViewModel.swift
index 7fcc80e..b12291e 100644
--- a/iosApp/iosApp/Units/UnitsViewModel.swift
+++ b/iosApp/iosApp/Units/UnitsViewModel.swift
@@ -21,6 +21,7 @@ import shared
@MainActor
class UnitsViewModel: ObservableObject {
+ @Inject var networkController: NetworkController
@Inject var unitsController: UnitsController
@Inject var geofenceController: GeofencesController
@@ -34,6 +35,7 @@ class UnitsViewModel: ObservableObject {
var detailsUnit: UnitInformation? = nil
var detailsAction = DeviceRow.Action.details
+ @Published var networkAvailable: Bool? = nil
@Published var searchQuery = "" {
didSet {
unitsDisplayMode = .list
@@ -83,6 +85,9 @@ class UnitsViewModel: ObservableObject {
}
private func setupObservers() {
+ let networkCollector = Collector<Bool?>(callback: setNetworkState)
+ networkController.networkAvailable.collect(collector: networkCollector) { _ in }
+
let unitsCollector = Collector<[UnitInformation]>(callback: setUnits)
unitsController.displayedUnitsFlow.collect(collector: unitsCollector) { _ in }
@@ -90,6 +95,13 @@ class UnitsViewModel: ObservableObject {
geofenceController.geofencesFlow.collect(collector: geofencesCollector) { _ in }
}
+ private func setNetworkState(state: Bool?) {
+ print("Network state is: \(state?.description ?? "")")
+ Task { @MainActor in
+ self.networkAvailable = state
+ }
+ }
+
private func setUnits(units: [UnitInformation]) {
print("Positions")
Task { @MainActor in
diff --git a/iosApp/iosApp/en.lproj/Localizable.strings b/iosApp/iosApp/en.lproj/Localizable.strings
index ca1c3b8..0c5ff4a 100644
--- a/iosApp/iosApp/en.lproj/Localizable.strings
+++ b/iosApp/iosApp/en.lproj/Localizable.strings
@@ -22,6 +22,7 @@
"loading" = "Loading";
"done" = "Done";
+"offline" = "No internet access";
"username" = "Username";
"password" = "Password";
diff --git a/iosApp/iosApp/es-419.lproj/Localizable.strings b/iosApp/iosApp/es-419.lproj/Localizable.strings
index 146da98..995473e 100644
--- a/iosApp/iosApp/es-419.lproj/Localizable.strings
+++ b/iosApp/iosApp/es-419.lproj/Localizable.strings
@@ -22,6 +22,7 @@
"loading" = "Cargando";
"done" = "Hecho";
+"offline" = "No hay acceso a internet";
"username" = "Usuario";
"password" = "Contraseña";
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index 763e0e2..46848ae 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -53,8 +53,15 @@ struct iOSApp: App {
Resolver.shared.add(CommandsApi.self) { resolver in
return CommandsApi(sessionManager: resolver.resolve())
}
+ Resolver.shared.add(NetworkController.self) { resolver in
+ return NetworkController()
+ }
Resolver.shared.add(SessionController.self) { resolver in
- return SessionController(sessionApi: resolver.resolve(), usersApi: resolver.resolve())
+ return SessionController(
+ sessionManager: resolver.resolve(),
+ sessionApi: resolver.resolve(),
+ usersApi: resolver.resolve()
+ )
}
Resolver.shared.add(UnitsController.self) { resolver in
return UnitsController(devicesApi: resolver.resolve(), positionsApi: resolver.resolve())
diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml
index 568741e..f0f34af 100644
--- a/shared/src/androidMain/AndroidManifest.xml
+++ b/shared/src/androidMain/AndroidManifest.xml
@@ -1,2 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
-<manifest /> \ No newline at end of file
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+</manifest> \ No newline at end of file
diff --git a/shared/src/androidMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt b/shared/src/androidMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
new file mode 100644
index 0000000..eecd7de
--- /dev/null
+++ b/shared/src/androidMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
@@ -0,0 +1,55 @@
+package mx.trackermap.TrackerMap.controllers
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import mx.trackermap.TrackerMap.Injectable
+
+actual class NetworkController(context: Context): Injectable {
+ private val networkRequest = NetworkRequest.Builder().build()
+ private val connectivityManager: ConnectivityManager
+ private val _networkAvailable = MutableStateFlow<Boolean?>(null)
+ actual val networkAvailable = _networkAvailable.asStateFlow()
+
+ private val networkCallback = object: ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(
+ network: Network,
+ networkCapabilities: NetworkCapabilities
+ ) {
+ super.onCapabilitiesChanged(network, networkCapabilities)
+ _networkAvailable.value = checkNetworkAccess(networkCapabilities)
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ _networkAvailable.value = false
+ }
+ }
+
+ init {
+ connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ _networkAvailable.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ checkNetworkAccess(connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork))
+ } else {
+ connectivityManager.activeNetworkInfo?.isConnected == true
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ connectivityManager.registerDefaultNetworkCallback(networkCallback)
+ } else {
+ connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
+ }
+ }
+
+ private fun checkNetworkAccess(capabilities: NetworkCapabilities?) =
+ capabilities != null
+ && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ } else true
+} \ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/ApiClient.kt b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/ApiClient.kt
index 937b2dd..8238f7e 100644
--- a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/ApiClient.kt
+++ b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/ApiClient.kt
@@ -168,7 +168,7 @@ open class ApiClient(
}
}
- if (sessionManager.token.isNotEmpty()) {
+ if (sessionManager.hasSession) {
request.headers["Cookie"] = sessionManager.token
}
val response: HttpResponse = client.request(request)
diff --git a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/SessionManager.kt b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/SessionManager.kt
index caf2da1..71ae5d0 100644
--- a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/SessionManager.kt
+++ b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/client/infrastructure/SessionManager.kt
@@ -28,6 +28,8 @@ class SessionManager(
settings[ACCESS_TOKEN_KEY] = token
}
+ val hasSession: Boolean get() = settings.hasKey(ACCESS_TOKEN_KEY)
+
fun clearSession() {
settings.remove(ACCESS_TOKEN_KEY)
settings.remove(SERVER_URL_KEY)
diff --git a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
new file mode 100644
index 0000000..08dcc87
--- /dev/null
+++ b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
@@ -0,0 +1,8 @@
+package mx.trackermap.TrackerMap.controllers
+
+import kotlinx.coroutines.flow.StateFlow
+import mx.trackermap.TrackerMap.Injectable
+
+expect class NetworkController: Injectable {
+ val networkAvailable: StateFlow<Boolean?>
+} \ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/SessionController.kt b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/SessionController.kt
index a63bba2..5cfdf96 100644
--- a/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/SessionController.kt
+++ b/shared/src/commonMain/kotlin/mx/trackermap/TrackerMap/controllers/SessionController.kt
@@ -25,13 +25,15 @@ import kotlinx.serialization.json.JsonPrimitive
import mx.trackermap.TrackerMap.Injectable
import mx.trackermap.TrackerMap.client.apis.SessionApi
import mx.trackermap.TrackerMap.client.apis.UsersApi
+import mx.trackermap.TrackerMap.client.infrastructure.SessionManager
import mx.trackermap.TrackerMap.client.models.SessionBody
import mx.trackermap.TrackerMap.client.models.User
@DelicateCoroutinesApi
class SessionController(
+ private val sessionManager: SessionManager,
private val sessionApi: SessionApi,
- private val usersApi: UsersApi
+ private val usersApi: UsersApi,
): Injectable {
sealed class LoginState {
object Nothing: LoginState()
@@ -46,6 +48,7 @@ class SessionController(
val loginStateFlow = MutableStateFlow<LoginState?>(null)
val userFlow = MutableStateFlow<User?>(null)
+ val hasSession: Boolean get() = sessionManager.hasSession
fun getSession() {
loginStateFlow.value = LoginState.Loading
@@ -59,19 +62,6 @@ class SessionController(
}
}
- fun restoreSession() {
- loginStateFlow.value = LoginState.Loading
- GlobalScope.launch {
- try {
- userFlow.value = sessionApi.sessionGet()
- loginStateFlow.value = LoginState.Success
- } catch (e: Exception) {
- e.printStackTrace()
- loginStateFlow.value = LoginState.Nothing
- }
- }
- }
-
fun login(body: SessionBody) {
val url = body.url.trim()
val email = body.email.trim()
diff --git a/shared/src/iosMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt b/shared/src/iosMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
new file mode 100644
index 0000000..d112c11
--- /dev/null
+++ b/shared/src/iosMain/kotlin/mx/trackermap/TrackerMap/controllers/NetworkController.kt
@@ -0,0 +1,25 @@
+package mx.trackermap.TrackerMap.controllers
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import mx.trackermap.TrackerMap.Injectable
+import platform.Network.*
+import platform.darwin.*
+
+actual class NetworkController: Injectable {
+ private val monitor = nw_path_monitor_create()
+ private val queue: dispatch_queue_t = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND.toLong(), 0u)
+ private val _networkAvailable = MutableStateFlow<Boolean?>(null)
+ actual val networkAvailable = _networkAvailable.asStateFlow()
+
+ private val updateHandler: nw_path_monitor_update_handler_t = { path: nw_path_t ->
+ val status = nw_path_get_status(path)
+ _networkAvailable.value = status in arrayOf(nw_path_status_satisfied, nw_path_status_satisfiable)
+ }
+
+ init {
+ nw_path_monitor_set_update_handler(monitor, updateHandler)
+ nw_path_monitor_set_queue(monitor, queue)
+ nw_path_monitor_start(monitor)
+ }
+} \ No newline at end of file