diff options
author | Iván Ávalos <avalos@disroot.org> | 2023-09-17 21:56:55 -0600 |
---|---|---|
committer | Iván Ávalos <avalos@disroot.org> | 2023-09-17 23:51:33 -0600 |
commit | edbd2c7713a0ba4e7e7a3ba6d59d16861ea4eb23 (patch) | |
tree | 885ca095c993c7a661303d215d9be0a6271ba3ea | |
parent | 7aec305729b872d668df45eae4821b106c1a20cb (diff) | |
download | etbsa-trackermap-mobile-edbd2c7713a0ba4e7e7a3ba6d59d16861ea4eb23.tar.gz etbsa-trackermap-mobile-edbd2c7713a0ba4e7e7a3ba6d59d16861ea4eb23.tar.bz2 etbsa-trackermap-mobile-edbd2c7713a0ba4e7e7a3ba6d59d16861ea4eb23.zip |
- [shared] Implement network state monitoring
- [android] UI reacts to network state
- [ios] UI reacts to network state
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 |