Repository: reedu-reengineering-education/sensebox-bike-flutter
Pull request #25: Bug: app hangs on login screen
Author: login: mashazyu
Created: 2025-04-01T10:57:39Z | Merged: 2025-04-17T08:38:49Z

Closes #16 
Closes #10 (?) 
Closes #17 (?) 

As we discussed previously, I took 2 approaches:
- reviewed code of OpenSenseMapService and OpenSenseMapBloc,
- looked for problems in DevTools and addressed most prominent ones.

### Detailed Changes

*   refactor OpenSenseMapBloc, OpenSenseMapService to improve readibility
*   add tests for OpenSenseMapService
*   refactor IsarService to use singleton pattern = prevent creation of multiple instances of a service
*   refactor navigation to prevent generation of multiple instances of widget
*   refactor LoginForm and RegisterForm and disable login and register button to prevent multiple login/registration event submission
*   LiveUploadService: limit number of reconnection attempts

### Testing
1. I can navigate to the login screen right after app starts, this is why I assume that #16 is fixed with this PR.
2. I walked a bit with a senseBox and compared functionality of current version of app with one in the Google PlayStore. 
- I noticed that Google PlayStore version stopped recording data for a bit.
- After a couple of tests with current version I didn't notice any disconnects, therefore I assume that this PR closes also #10 .
3. LiveUploadService: I verified that if i 
- start track recording,
- disable Internet connection on my phone,
- enable it back in 6 minutes
=> data flow to openSenseMap dashboard restores. 
4. I can login to the existing and register new account. Also i verified that I get error messages, when I use incorrect data (including validation).

Open questions 
- if refreshing auth- and refresh tokens makes sense to you.

### Checklist before requesting a review

[X] I have performed a self-review of my code
[ ] I have added or updated relevant documentation

### Additional Information
<!-- Add any other relevant information that the reviewer should know. This might include:
    *   Screenshots or videos demonstrating the changes
    *   Links to external resources or documentation
-->


Linked issue #16: Login screen: app hangs, when navigate login page
## Description
On the first app login, if navigate to the login page, app hangs and throws an error and app hangs

## Steps to reproduce
- Open app
- click login button
=> app hangs

## Additional information
This is [where](https://github.com/reedu-reengineering-education/sensebox-bike-flutter/blob/main/lib/blocs/opensensemap_bloc.dart) the error is thrown  
```
  Future<List> fetchSenseBoxes({int page = 0}) async {
    try {
      var myBoxes = await _service.getSenseBoxes(page: page);
      _senseBoxes[page] = myBoxes;
      notifyListeners();
      return myBoxes;
    } catch (e) {
      throw Exception('Failed to fetch senseBoxes');
    }
  }
```

Diff:
diff --git a/lib/blocs/opensensemap_bloc.dart b/lib/blocs/opensensemap_bloc.dart
index 9c9e991c..17bddb44 100644
--- a/lib/blocs/opensensemap_bloc.dart
+++ b/lib/blocs/opensensemap_bloc.dart
@@ -9,48 +9,78 @@ import 'package:shared_preferences/shared_preferences.dart'; // Add for StreamCo
 class OpenSenseMapBloc with ChangeNotifier, WidgetsBindingObserver {
   final OpenSenseMapService _service = OpenSenseMapService();
   bool _isAuthenticated = false;
-
   // make senseboxes a key value store. key is the page number, value is the list of senseboxes
   final Map<int, List<dynamic>> _senseBoxes = {};
-
   final _senseBoxController =
       StreamController<SenseBox?>.broadcast(); // StreamController
-
   Stream<SenseBox?> get senseBoxStream =>
       _senseBoxController.stream; // Expose stream
-
   // get selected sensebox
   SenseBox? _selectedSenseBox;
-
   SenseBox? get selectedSenseBox => _selectedSenseBox;
-
   bool get isAuthenticated => _isAuthenticated;
   List<dynamic> get senseBoxes => _senseBoxes.values.expand((e) => e).toList();
 
   OpenSenseMapBloc() {
-    _service.refreshToken().then(
-        (_) async => {_isAuthenticated = true, await loadSelectedSenseBox()});
+    _initializeAuth();
+  }
+
+  Future<void> _initializeAuth() async {
+    try {
+      await _service.refreshToken();
+      _isAuthenticated = true;
+      notifyListeners();
+      await loadSelectedSenseBox();
+    } catch (_) {
+      _isAuthenticated = false;
+      notifyListeners();
+    }
+  }
+
+  Future<void> loadSelectedSenseBox() async {
+    final prefs = await SharedPreferences.getInstance();
+
+    if (!_isAuthenticated) {
+      await prefs.remove('selectedSenseBox');
+      _senseBoxController.add(null);
+      _selectedSenseBox = null;
+
+      return;
+    }
+
+    final selectedSenseBoxJson = prefs.getString('selectedSenseBox');
+
+    if (selectedSenseBoxJson == null) {
+      _senseBoxController.add(null); // Push null if no senseBox is selected
+      _selectedSenseBox = null;
+    } else {
+      final newSenseBox = SenseBox.fromJson(jsonDecode(selectedSenseBoxJson));
+
+      // Avoid creating duplicate instances
+      if (_selectedSenseBox?.id != newSenseBox.id) {
+        _senseBoxController.add(newSenseBox);
+        _selectedSenseBox = newSenseBox;
+      }
+    }
+    notifyListeners();
   }
 
   @override
   void didChangeAppLifecycleState(AppLifecycleState state) async {
-    switch (state) {
-      case AppLifecycleState.resumed:
+    if (state == AppLifecycleState.resumed) {
+      try {
         await _service.refreshToken();
         _isAuthenticated = true;
-        await loadSelectedSenseBox();
-        break;
-      case AppLifecycleState.inactive:
-        print("app in inactive");
-        break;
-      case AppLifecycleState.paused:
-        print("app in paused");
-        break;
-      case AppLifecycleState.detached:
-        print("app in detached");
-        break;
-      case AppLifecycleState.hidden:
-      // TODO: Handle this case.
+        
+        // Avoid creating duplicate SenseBoxes by checking current state
+        if (_selectedSenseBox == null) {
+          await loadSelectedSenseBox();
+        }
+      } catch (_) {
+        _isAuthenticated = false;
+      } finally {
+        notifyListeners();
+      }
     }
   }
 
@@ -62,6 +92,8 @@ class OpenSenseMapBloc with ChangeNotifier, WidgetsBindingObserver {
     } catch (e) {
       _isAuthenticated = false;
       rethrow;
+    } finally {
+      notifyListeners();
     }
   }
 
@@ -70,18 +102,32 @@ class OpenSenseMapBloc with ChangeNotifier, WidgetsBindingObserver {
       await _service.login(email, password);
       _isAuthenticated = true;
       notifyListeners();
+
+      // Fetch the first page of sense boxes
+      final senseBoxes = await fetchSenseBoxes(page: 0);
+
+      // If there are sense boxes, set the first one as the selected box
+      if (senseBoxes.isNotEmpty) {
+        await setSelectedSenseBox(SenseBox.fromJson(senseBoxes.first));
+      }
     } catch (e) {
       _isAuthenticated = false;
       rethrow;
+    } finally {
+      notifyListeners();
     }
   }
 
   Future<void> logout() async {
-    await _service.logout();
-    _isAuthenticated = false;
-    _senseBoxController.add(null); // Clear senseBox on logout
-    _selectedSenseBox = null;
-    notifyListeners();
+    try {
+      await _service.logout();
+      _isAuthenticated = false;
+      _senseBoxController.add(null); // Clear senseBox on logout
+      _selectedSenseBox = null;
+      notifyListeners();
+    } catch (_) {
+      rethrow;
+    }
   }
 
   Future<void> createSenseBoxBike(String name, double latitude,
@@ -105,50 +151,30 @@ class OpenSenseMapBloc with ChangeNotifier, WidgetsBindingObserver {
         return false;
       }
     }
+
     return true;
   }
 
   Future<List> fetchSenseBoxes({int page = 0}) async {
     try {
-      var myBoxes = await _service.getSenseBoxes(page: page);
+      final myBoxes = await _service.getSenseBoxes(page: page);
       _senseBoxes[page] = myBoxes;
       notifyListeners();
       return myBoxes;
-    } catch (e) {
-      throw Exception('Failed to fetch senseBoxes');
+    } catch (_) {
+      return [];
     }
   }
 
-  void setSelectedSenseBox(SenseBox senseBox) {
-    final prefs = SharedPreferences.getInstance();
-    prefs.then((prefs) =>
-        prefs.setString('selectedSenseBox', jsonEncode(senseBox.toJson())));
-    _senseBoxController.add(senseBox); // Push selected senseBox to the stream
-    _selectedSenseBox = senseBox;
-  }
-
-  Future<void> loadSelectedSenseBox() async {
+  Future<void> setSelectedSenseBox(SenseBox senseBox) async {
     final prefs = await SharedPreferences.getInstance();
 
-    if (!_isAuthenticated) {
-      await prefs.remove('selectedSenseBox');
-      _senseBoxController.add(null);
-      _selectedSenseBox = null;
-
-      return;
-    }
-
-    final selectedSenseBoxJson = prefs.getString('selectedSenseBox');
+    // Clear previous data before adding new senseBox
+    _senseBoxController.add(null);
 
-    if (selectedSenseBoxJson == null) {
-      _senseBoxController.add(null); // Push null if no senseBox is selected
-      _selectedSenseBox = null;
-    } else {
-      _senseBoxController.add(SenseBox.fromJson(jsonDecode(
-          selectedSenseBoxJson))); // Push selected senseBox to the stream
-
-      _selectedSenseBox = SenseBox.fromJson(jsonDecode(selectedSenseBoxJson));
-    }
+    await prefs.setString('selectedSenseBox', jsonEncode(senseBox.toJson()));
+    _senseBoxController.add(senseBox); // Push selected senseBox to the stream
+    _selectedSenseBox = senseBox;
     notifyListeners();
   }
 
diff --git a/lib/constants.dart b/lib/constants.dart
new file mode 100644
index 00000000..8480f11e
--- /dev/null
+++ b/lib/constants.dart
@@ -0,0 +1,196 @@
+const maxRetries = 10;
+const retryPeriod = 2; // in minutes
+const premanentConnectivityFalurePeriod = 20; // in minutes
+const defaultTimeout = 30; // in seconds
+
+const openSenseMapUrl = 'https://api.opensensemap.org';
+const privacyPolicyUrl =
+    'https://opensensemap.org/privacy'; // URL for the privacy policy
+
+const classicModelSensors = [
+{
+    "id": "0",
+    "icon": 'osem-thermometer',
+    "title": 'Temperature',
+    "unit": '°C',
+    "sensorType": 'HDC1080'
+  },
+  {
+    "id": "1",
+    "icon": 'osem-humidity',
+    "title": 'Rel. Humidity',
+    "unit": '%',
+    "sensorType": 'HDC1080'
+  },
+  {
+    "id": "2",
+    "icon": 'osem-cloud',
+    "title": 'Finedust PM1',
+    "unit": 'µg/m³',
+    "sensorType": 'SPS30'
+  },
+  {
+    "id": "3",
+    "icon": 'osem-cloud',
+    "title": 'Finedust PM2.5',
+    "unit": 'µg/m³',
+    "sensorType": 'SPS30'
+  },
+  {
+    "id": "4",
+    "icon": 'osem-cloud',
+    "title": 'Finedust PM4',
+    "unit": 'µg/m³',
+    "sensorType": 'SPS30'
+  },
+  {
+    "id": "5",
+    "icon": 'osem-cloud',
+    "title": 'Finedust PM10',
+    "unit": 'µg/m³',
+    "sensorType": 'SPS30'
+  },
+  {
+    "id": "6",
+    "icon": 'osem-signal',
+    "title": 'Overtaking Distance',
+    "unit": 'cm',
+    "sensorType": 'HC-SR04'
+  },
+  {
+    "id": "7",
+    "icon": 'osem-shock',
+    "title": 'Acceleration X',
+    "unit": 'm/s²',
+    "sensorType": 'MPU-6050'
+  },
+  {
+    "id": "8",
+    "icon": 'osem-shock',
+    "title": 'Acceleration Y',
+    "unit": 'm/s²',
+    "sensorType": 'MPU-6050'
+  },
+  {
+    "id": "9",
+    "icon": 'osem-shock',
+    "title": 'Acceleration Z',
+    "unit": 'm/s²',
+    "sensorType": 'MPU-6050'
+  },
+  {
+    "id": "10",
+    "icon": 'osem-dashboard',
+    "title": 'Speed',
+    "unit": 'm/s',
+    "sensorType": 'GPS'
+  }
+];
+
+    const atraiModelSensors = [
+      {
+        "id": "0",
+        "icon": 'osem-thermometer',
+        "title": 'Temperature',
+        "unit": '°C',
+        "sensorType": 'HDC1080'
+      },
+      {
+        "id": "1",
+        "icon": 'osem-humidity',
+        "title": 'Rel. Humidity',
+        "unit": '%',
+        "sensorType": 'HDC1080'
+      },
+      {
+        "id": "2",
+        "icon": 'osem-cloud',
+        "title": 'Finedust PM1',
+        "unit": 'µg/m³',
+        "sensorType": 'SPS30'
+      },
+      {
+        "id": "3",
+        "icon": 'osem-cloud',
+        "title": 'Finedust PM2.5',
+        "unit": 'µg/m³',
+        "sensorType": 'SPS30'
+      },
+      {
+        "id": "4",
+        "icon": 'osem-cloud',
+        "title": 'Finedust PM4',
+        "unit": 'µg/m³',
+        "sensorType": 'SPS30'
+      },
+      {
+        "id": "5",
+        "icon": 'osem-cloud',
+        "title": 'Finedust PM10',
+        "unit": 'µg/m³',
+        "sensorType": 'SPS30'
+      },
+      {
+        "id": "6",
+        "icon": 'osem-shock',
+        "title": 'Overtaking Manoeuvre',
+        "unit": '%',
+        "sensorType": 'VL53L8CX'
+      },
+      {
+        "id": "7",
+        "icon": 'osem-shock',
+        "title": 'Overtaking Distance',
+        "unit": 'cm',
+        "sensorType": 'VL53L8CX'
+      },
+      {
+        "id": "8",
+        "icon": 'osem-shock',
+        "title": 'Surface Asphalt',
+        "unit": '%',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "9",
+        "icon": 'osem-shock',
+        "title": 'Surface Sett',
+        "unit": '%',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "10",
+        "icon": 'osem-shock',
+        "title": 'Surface Compacted',
+        "unit": '%',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "11",
+        "icon": 'osem-shock',
+        "title": 'Surface Paving',
+        "unit": '%',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "12",
+        "icon": 'osem-shock',
+        "title": 'Standing',
+        "unit": '%',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "13",
+        "icon": 'osem-shock',
+        "title": 'Surface Anomaly',
+        "unit": 'Δ',
+        "sensorType": 'MPU-6050'
+      },
+      {
+        "id": "14",
+        "icon": 'osem-dashboard',
+        "title": 'Speed',
+        "unit": 'm/s',
+        "sensorType": 'GPS'
+      }
+    ];
\ No newline at end of file
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 6228d94d..4a071dae 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -81,6 +81,9 @@
     "openSenseMapBoxSelectionCreateHint": "Erstelle eine mit dem '+' Button",
     "openSenseMapBoxSelectionUnnamedBox": "Unbenannte senseBox",
     "openSenseMapBoxSelectionIncompatible": "Nicht kompatibel mit senseBox:bike",
+    "openSenesMapRegisterAcceptTermsPrefix": "Ich akzeptiere die",
+    "openSenseMapRegisterAcceptTermsPrivacy": "Datenschutzbestimmungen",
+    "openSenseMapRegisterAcceptTermsError": "Sie müssen die Datenschutzbestimmungen akzeptieren",
     "settingsGeneral": "Allgemeine",
     "settingsOther": "Andere",
     "settingsVibrateOnDisconnect": "Vibration bei Verbindungsabbruch",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 247cb970..3ab97023 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -64,6 +64,9 @@
     "openSenseMapRegisterPasswordErrorMismatch": "Passwords do not match",
     "openSenseMapRegisterPasswordErrorCharacters": "Password must contain at least 8 characters",
     "openSenseMapRegisterFailed": "Registration failed",
+    "openSenesMapRegisterAcceptTermsPrefix": "I accept the",
+    "openSenseMapRegisterAcceptTermsPrivacy": "privacy policy",
+    "openSenseMapRegisterAcceptTermsError": "You must accept the privacy policy",
     "connectionButtonConnect": "Connect",
     "connectionButtonDisconnect": "Disconnect",
     "connectionButtonConnecting": "Connecting...",
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 23a0f99e..95724dec 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -64,6 +64,9 @@
     "openSenseMapRegisterPasswordErrorMismatch": "As senhas não coincidem",
     "openSenseMapRegisterPasswordErrorCharacters": "A senha deve conter pelo menos 8 caracteres",
     "openSenseMapRegisterFailed": "Falha no registro",
+    "openSenesMapRegisterAcceptTermsPrefix": "Eu aceito os",
+    "openSenseMapRegisterAcceptTermsPrivacy": "termos de privacidade",
+    "openSenseMapRegisterAcceptTermsError": "Você deve aceitar os termos de privacidade",
     "connectionButtonConnect": "Conectar",
     "connectionButtonDisconnect": "Desconectar",
     "connectionButtonConnecting": "Conectando...",
diff --git a/lib/models/track_data.dart b/lib/models/track_data.dart
index 5ade6a58..b5a48112 100644
--- a/lib/models/track_data.dart
+++ b/lib/models/track_data.dart
@@ -1,7 +1,10 @@
+import 'dart:math';
+
 import 'package:google_polyline_algorithm/google_polyline_algorithm.dart';
 import 'package:sensebox_bike/models/geolocation_data.dart';
 import 'package:isar/isar.dart';
 import 'package:sensebox_bike/utils/geo_utils.dart';
+import 'package:simplify/simplify.dart';
 
 part 'track_data.g.dart';
 
@@ -24,12 +27,35 @@ class TrackData {
     return getDistance(geolocations.toList());
   }
 
+  double calculateTolerance(int numberOfCoordinates) {
+    // Base tolerance for small datasets
+    const double baseTolerance = 0.00001;
+    // Growth factor for exponential scaling
+    const double growthFactor = 1.01;
+
+    return baseTolerance * pow(growthFactor, (numberOfCoordinates / 1000));
+  }
+
   @ignore
   String get encodedPolyline {
-    final List<List<num>> coordinates = geolocations
-        .map((geolocation) => [geolocation.latitude, geolocation.longitude])
+    // Convert geolocations to a list of Point<double>
+    final List<Point<double>> coordinates = geolocations
+        .map(
+            (geolocation) => Point(geolocation.latitude, geolocation.longitude))
         .toList();
+    // tolerance based on number of geolocations
+    final tolerance = calculateTolerance(coordinates.length);
+    // Simplify the polyline with a tolerance (e.g., 0.0001)
+    final simplifiedCoordinates = simplify<Point<double>>(
+      coordinates,
+      tolerance: tolerance, // Adjust tolerance to control simplification
+      highestQuality: false, // Use high-quality simplification
+    );
+
+    // Convert simplified points back to a list of lists for encoding
+    final simplifiedList =
+        simplifiedCoordinates.map((point) => [point.x, point.y]).toList();
 
-    return encodePolyline(coordinates);
+    return encodePolyline(simplifiedList);
   }
 }
diff --git a/lib/services/custom_exceptions.dart b/lib/services/custom_exceptions.dart
new file mode 100644
index 00000000..4ee74a52
--- /dev/null
+++ b/lib/services/custom_exceptions.dart
@@ -0,0 +1,8 @@
+class TooManyRequestsException implements Exception {
+  final int retryAfter;
+
+  TooManyRequestsException(this.retryAfter);
+
+  @override
+  String toString() => 'TooManyRequestsException: Retry after $retryAfter seconds.';
+}
\ No newline at end of file
diff --git a/lib/services/isar_service.dart b/lib/services/isar_service.dart
index 6344a2fd..1141c3ec 100644
--- a/lib/services/isar_service.dart
+++ b/lib/services/isar_service.dart
@@ -11,6 +11,11 @@ import 'package:sensebox_bike/services/isar_service/sensor_service.dart';
 import 'package:sensebox_bike/services/isar_service/track_service.dart';
 
 class IsarService {
+  // Use Singleton pattern to avoid creation of multiple instances
+  static final IsarService _instance = IsarService._internal();
+  factory IsarService() => _instance;
+  IsarService._internal();
+
   final TrackService trackService = TrackService();
   final GeolocationService geolocationService = GeolocationService();
   final SensorService sensorService = SensorService();
diff --git a/lib/services/isar_service/isar_provider.dart b/lib/services/isar_service/isar_provider.dart
index 5bfe8020..618bb9e5 100644
--- a/lib/services/isar_service/isar_provider.dart
+++ b/lib/services/isar_service/isar_provider.dart
@@ -7,14 +7,14 @@ import 'package:sensebox_bike/models/track_data.dart';
 
 class IsarProvider {
   static final IsarProvider _instance = IsarProvider._internal();
-  late Future<Isar> db;
+  static Isar? _isar;
+  factory IsarProvider() => _instance;
+  IsarProvider._internal();
 
-  factory IsarProvider() {
-    return _instance;
-  }
-
-  IsarProvider._internal() {
-    db = _initDB();
+  Future<Isar> get db async {
+    if (_isar != null && _isar!.isOpen) return _isar!;
+    
+    return _isar = await _initDB();
   }
 
   Future<Isar> _initDB() async {
@@ -29,5 +29,15 @@ class IsarProvider {
     );
   }
 
-  Future<Isar> getDatabase() => db;
+  /// Added back `getDatabase` method for direct access
+  Future<Isar> getDatabase() async {
+    return await db;
+  }
+
+  /// Close the database connection
+  Future<void> close() async {
+    if (_isar != null && _isar!.isOpen) {
+      await _isar!.close();
+    }
+  }
 }
diff --git a/lib/services/live_upload_service.dart b/lib/services/live_upload_service.dart
index cd746b3f..0458c0f3 100644
--- a/lib/services/live_upload_service.dart
+++ b/lib/services/live_upload_service.dart
@@ -1,10 +1,13 @@
 import 'dart:async';
+import 'package:flutter/widgets.dart';
 import 'package:sensebox_bike/blocs/settings_bloc.dart';
 import 'package:sensebox_bike/models/geolocation_data.dart';
 import 'package:sensebox_bike/models/sensebox.dart';
+import 'package:sensebox_bike/services/custom_exceptions.dart';
 import 'package:sensebox_bike/services/isar_service.dart';
 import 'package:sensebox_bike/services/opensensemap_service.dart';
 import 'package:sensebox_bike/utils/sensor_utils.dart';
+import "package:sensebox_bike/constants.dart";
 
 class LiveUploadService {
   final OpenSenseMapService openSenseMapService;
@@ -14,6 +17,10 @@ class LiveUploadService {
 
   final int trackId;
 
+  // vars to handle connectivity issues
+  DateTime? _lastSuccessfulUpload;
+  int _consecutiveFails = 0;
+
   // save uploaded ids to prevent double uploads
   final List<int> _uploadedIds = [];
 
@@ -52,12 +59,33 @@ class LiveUploadService {
           try {
             Map<String, dynamic> data = prepareDataToUpload(geoDataToUpload);
 
-            await uploadDataToOpenSenseMap(data);
+            //await uploadDataToOpenSenseMap(data);
+            await uploadDataWithRetry(data);
 
             _uploadedIds.addAll(geoDataToUpload.map((e) => e.id));
+            // track successful upload
+            _lastSuccessfulUpload = DateTime.now();
+            _consecutiveFails = 0;
           } catch (e) {
             // Handle upload error
-            throw Exception('Failed to upload data: $e');
+            _consecutiveFails++;
+            final lastSuccessfulUploadPeriod = DateTime.now()
+                .subtract(Duration(minutes: premanentConnectivityFalurePeriod));
+            final isPermanentConnectivityIssue =
+                _lastSuccessfulUpload != null &&
+                    _lastSuccessfulUpload!.isBefore(lastSuccessfulUploadPeriod);
+            final isMaxRetries = _consecutiveFails >= maxRetries;
+
+            if (isPermanentConnectivityIssue || isMaxRetries) {
+              debugPrint(
+                  'Permanent connectivity failure: No connection for more than $premanentConnectivityFalurePeriod minutes.');
+              return;
+            } else {
+              // Retry posting data after the retry period if the number of consecutive fails is less than maxRetries
+              debugPrint(
+                  'Failed to upload data: $e. Retrying in $retryPeriod minutes (Attempt $_consecutiveFails of $maxRetries).');
+              await Future.delayed(Duration(minutes: retryPeriod));
+            }
           }
         }
 
@@ -66,6 +94,21 @@ class LiveUploadService {
     });
   }
 
+  Future<void> uploadDataWithRetry(Map<String, dynamic> data) async {
+    try {
+      await uploadDataToOpenSenseMap(data);
+    } catch (e) {
+      if (e is TooManyRequestsException) {
+        debugPrint(
+            'Received 429 Too Many Requests. Retrying after ${e.retryAfter} seconds.');
+        await Future.delayed(Duration(seconds: e.retryAfter));
+        await uploadDataToOpenSenseMap(data); // Retry once after waiting
+      } else {
+        rethrow; // Propagate other exceptions
+      }
+    }
+  }
+
   Map<String, dynamic> prepareDataToUpload(
       List<GeolocationData> geoDataToUpload) {
     Map<String, dynamic> data = {};
diff --git a/lib/services/opensensemap_service.dart b/lib/services/opensensemap_service.dart
index a0ed4ec1..a606b7c1 100644
--- a/lib/services/opensensemap_service.dart
+++ b/lib/services/opensensemap_service.dart
@@ -1,25 +1,60 @@
 import 'package:flutter/material.dart';
 import 'package:http/http.dart' as http;
+import 'package:sensebox_bike/services/custom_exceptions.dart';
 import 'dart:convert';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'dart:async';
 
+import 'package:sensebox_bike/constants.dart';
+
 enum SenseBoxBikeModel { classic, atrai }
 
 class OpenSenseMapService {
-  static const String _baseUrl = 'https://api.opensensemap.org';
+  static const String _baseUrl = openSenseMapUrl;
+
+  // The following class variables and constructor are added to allow testing
+  // No refactoring of other parts of the code should be needed
+  final http.Client client;
+  final Future<SharedPreferences> _prefs;
+
+  OpenSenseMapService({
+    http.Client? client,
+    Future<SharedPreferences>? prefs,
+  })  : client = client ?? http.Client(),
+        _prefs = prefs ?? SharedPreferences.getInstance();
+
+  Future<void> setTokens(http.Response response) async {
+    final prefs = await _prefs;
+    final responseData = jsonDecode(response.body);
+    final String accessToken = responseData['token'];
+    final String refreshToken = responseData['refreshToken'];
+
+    await prefs.setString('accessToken', accessToken);
+    await prefs.setString('refreshToken', refreshToken);
+  }
+
+  Future<void> removeTokens() async {
+    final prefs = await _prefs;
+
+    await prefs.remove('accessToken');
+    await prefs.remove('refreshToken');
+  }
+
+  Future<String?> getRefreshTokenFromPreferences() async {
+    final prefs = await _prefs;
+
+    return prefs.getString('refreshToken');
+  }
 
   Future<void> register(String name, String email, String password) async {
-    final response = await http.post(
+    final response = await client.post(
       Uri.parse('$_baseUrl/users/register'),
       body: jsonEncode({
         'name': name,
         'email': email,
         'password': password,
       }),
-      headers: {
-        'Content-Type': 'application/json',
-      },
+      headers: {'Content-Type': 'application/json'},
     );
 
     if (response.statusCode != 201) {
@@ -28,36 +63,26 @@ class OpenSenseMapService {
     }
 
     final responseData = jsonDecode(response.body);
-    final String accessToken = responseData['token'];
-    final String refreshToken = responseData['refreshToken'];
-
-    final prefs = await SharedPreferences.getInstance();
-    await prefs.setString('accessToken', accessToken);
-    await prefs.setString('refreshToken', refreshToken);
+   
+    await setTokens(response);
 
     return responseData;
   }
 
   Future<Map<String, dynamic>> login(String email, String password) async {
-    final response = await http.post(
+    final response = await client.post(
       Uri.parse('$_baseUrl/users/sign-in'),
       body: jsonEncode({
         'email': email,
         'password': password,
       }),
-      headers: {
-        'Content-Type': 'application/json',
-      },
+      headers: {'Content-Type': 'application/json'},
     );
 
     if (response.statusCode == 200) {
       final responseData = jsonDecode(response.body);
-      final String accessToken = responseData['token'];
-      final String refreshToken = responseData['refreshToken'];
-
-      final prefs = await SharedPreferences.getInstance();
-      await prefs.setString('accessToken', accessToken);
-      await prefs.setString('refreshToken', refreshToken);
+      
+      await setTokens(response);
 
       return responseData;
     } else {
@@ -66,41 +91,32 @@ class OpenSenseMapService {
   }
 
   Future<void> logout() async {
-    final prefs = await SharedPreferences.getInstance();
-    await prefs.remove('accessToken');
-    await prefs.remove('refreshToken');
+    await removeTokens();
   }
 
   Future<String?> getAccessToken() async {
-    final prefs = await SharedPreferences.getInstance();
+    final prefs = await _prefs;
     return prefs.getString('accessToken');
   }
 
   Future<void> refreshToken() async {
-    final prefs = await SharedPreferences.getInstance();
-    final refreshToken = prefs.getString('refreshToken');
+    final refreshToken = await getRefreshTokenFromPreferences();
 
     if (refreshToken == null) {
-      debugPrint('No refresh token found');
+      throw Exception('No refresh token found');
     }
 
-    final response = await http.post(
+    final response = await client.post(
       Uri.parse('$_baseUrl/users/refresh-auth'),
       body: jsonEncode({'token': refreshToken}),
-      headers: {
-        'Content-Type': 'application/json',
-      },
+      headers: {'Content-Type': 'application/json'},
     );
 
     if (response.statusCode == 200) {
-      final responseData = jsonDecode(response.body);
-      final String newAccessToken = responseData['token'];
-      final String newRefreshToken = responseData['refreshToken'];
-
-      await prefs.setString('accessToken', newAccessToken);
-      await prefs.setString('refreshToken', newRefreshToken);
+      await setTokens(response);
     } else {
-      debugPrint('Failed to refresh token: ${response.body}');
+      await removeTokens();
+      throw Exception('Failed to refresh token: ${response.body}');
     }
   }
 
@@ -109,7 +125,7 @@ class OpenSenseMapService {
     final accessToken = await getAccessToken();
     if (accessToken == null) throw Exception('Not authenticated');
 
-    final response = await http.post(
+    final response = await client.post(
       Uri.parse('$_baseUrl/boxes'),
       body: jsonEncode(
           createSenseBoxBikeModel(name, latitude, longitude, model: model)),
@@ -132,11 +148,9 @@ class OpenSenseMapService {
     final accessToken = await getAccessToken();
     if (accessToken == null) throw Exception('Not authenticated');
 
-    final response = await http.get(
+    final response = await client.get(
       Uri.parse('$_baseUrl/users/me/boxes?page=$page'),
-      headers: {
-        'Authorization': 'Bearer $accessToken',
-      },
+      headers: {'Authorization': 'Bearer $accessToken'},
     );
 
     if (response.statusCode == 200) {
@@ -150,224 +164,50 @@ class OpenSenseMapService {
     }
   }
 
-  Future<void> uploadData(
+Future<void> uploadData(
       String senseBoxId, Map<String, dynamic> sensorData) async {
     final accessToken = await getAccessToken();
     if (accessToken == null) throw Exception('Not authenticated');
 
     List<dynamic> data = sensorData.values.toList();
 
-    final response = await http.post(
-      Uri.parse('$_baseUrl/boxes/$senseBoxId/data'),
-      body: jsonEncode(data),
-      headers: {
-        'Authorization': 'Bearer $accessToken',
-        'Content-Type': 'application/json',
-      },
-    );
+    try {
+      final response = await client.post(
+        Uri.parse('$_baseUrl/boxes/$senseBoxId/data'),
+        body: jsonEncode(data),
+        headers: {
+          'Authorization': 'Bearer $accessToken',
+          'Content-Type': 'application/json',
+        },
+      ).timeout(
+          const Duration(seconds: defaultTimeout)); // Set a 30-second timeout
 
     if (response.statusCode == 201) {
       debugPrint('Data uploaded');
     } else if (response.statusCode == 401) {
       await refreshToken();
       return uploadData(senseBoxId, sensorData);
-    } else if (response.statusCode == 429) {
-      throw Exception(
-          'Failed to upload data (${response.statusCode}) ${response.body}');
+      } else if (response.statusCode == 429) {
+        final retryAfter = response.headers['retry-after'];
+        if (retryAfter != null) {
+          final waitTime = int.tryParse(retryAfter) ?? defaultTimeout;
+          throw TooManyRequestsException(waitTime);
+        } else {
+          throw TooManyRequestsException(defaultTimeout * 2);
+        }
     } else {
-      throw Exception(
-          'Failed to upload data (${response.statusCode}) ${response.body}');
+        throw Exception(
+            'Failed to upload data (${response.statusCode}) ${response.body}');
+      }
+    } on TimeoutException {
+      throw Exception('Request timed out after 30 seconds');
     }
-  }
+}
 
-  // Define the available sensors for each model
+// Define the available sensors for each model
   final Map<SenseBoxBikeModel, List<dynamic>> sensors = {
-    SenseBoxBikeModel.classic: [
-      {
-        "id": "0",
-        "icon": 'osem-thermometer',
-        "title": 'Temperature',
-        "unit": '°C',
-        "sensorType": 'HDC1080'
-      },
-      {
-        "id": "1",
-        "icon": 'osem-humidity',
-        "title": 'Rel. Humidity',
-        "unit": '%',
-        "sensorType": 'HDC1080'
-      },
-      {
-        "id": "2",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM1',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "3",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM2.5',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "4",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM4',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "5",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM10',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "6",
-        "icon": 'osem-signal',
-        "title": 'Overtaking Distance',
-        "unit": 'cm',
-        "sensorType": 'HC-SR04'
-      },
-      {
-        "id": "7",
-        "icon": 'osem-shock',
-        "title": 'Acceleration X',
-        "unit": 'm/s²',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "8",
-        "icon": 'osem-shock',
-        "title": 'Acceleration Y',
-        "unit": 'm/s²',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "9",
-        "icon": 'osem-shock',
-        "title": 'Acceleration Z',
-        "unit": 'm/s²',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "10",
-        "icon": 'osem-dashboard',
-        "title": 'Speed',
-        "unit": 'km/h',
-        "sensorType": 'GPS'
-      }
-    ],
-    SenseBoxBikeModel.atrai: [
-      {
-        "id": "0",
-        "icon": 'osem-thermometer',
-        "title": 'Temperature',
-        "unit": '°C',
-        "sensorType": 'HDC1080'
-      },
-      {
-        "id": "1",
-        "icon": 'osem-humidity',
-        "title": 'Rel. Humidity',
-        "unit": '%',
-        "sensorType": 'HDC1080'
-      },
-      {
-        "id": "2",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM1',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "3",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM2.5',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "4",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM4',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "5",
-        "icon": 'osem-cloud',
-        "title": 'Finedust PM10',
-        "unit": 'µg/m³',
-        "sensorType": 'SPS30'
-      },
-      {
-        "id": "6",
-        "icon": 'osem-shock',
-        "title": 'Overtaking Manoeuvre',
-        "unit": '%',
-        "sensorType": 'VL53L8CX'
-      },
-      {
-        "id": "7",
-        "icon": 'osem-shock',
-        "title": 'Overtaking Distance',
-        "unit": 'cm',
-        "sensorType": 'VL53L8CX'
-      },
-      {
-        "id": "8",
-        "icon": 'osem-shock',
-        "title": 'Surface Asphalt',
-        "unit": '%',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "9",
-        "icon": 'osem-shock',
-        "title": 'Surface Sett',
-        "unit": '%',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "10",
-        "icon": 'osem-shock',
-        "title": 'Surface Compacted',
-        "unit": '%',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "11",
-        "icon": 'osem-shock',
-        "title": 'Surface Paving',
-        "unit": '%',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "12",
-        "icon": 'osem-shock',
-        "title": 'Standing',
-        "unit": '%',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "13",
-        "icon": 'osem-shock',
-        "title": 'Surface Anomaly',
-        "unit": 'Δ',
-        "sensorType": 'MPU-6050'
-      },
-      {
-        "id": "14",
-        "icon": 'osem-dashboard',
-        "title": 'Speed',
-        "unit": 'm/s',
-        "sensorType": 'GPS'
-      }
-    ],
+    SenseBoxBikeModel.classic: classicModelSensors,
+    SenseBoxBikeModel.atrai: atraiModelSensors,
   };
 
 // Factory function
diff --git a/lib/ui/screens/app_home.dart b/lib/ui/screens/app_home.dart
index 7a834d20..a6e11737 100644
--- a/lib/ui/screens/app_home.dart
+++ b/lib/ui/screens/app_home.dart
@@ -12,27 +12,21 @@ class AppHome extends StatefulWidget {
 }
 
 class _AppHomeState extends State<AppHome> {
-  static final List<Widget> _pages = <Widget>[
-    const PopScope(
-      canPop: false,
-      child: HomeScreen(),
-    ),
-    const PopScope(
-      canPop: false,
-      child: TracksScreen(),
-    ),
-    const PopScope(
-      canPop: false,
-      child: SettingsScreen(),
-    ),
-  ];
-
   int _selectedIndex = 0;
 
+  final List<Widget> _pages = const [
+    HomeScreen(),
+    TracksScreen(),
+    SettingsScreen(),
+  ];
+
   @override
   Widget build(BuildContext context) {
     return Scaffold(
-      body: _pages.elementAt(_selectedIndex),
+      body: IndexedStack(
+        index: _selectedIndex,
+        children: _pages,
+      ),
       bottomNavigationBar: Container(
         decoration: const BoxDecoration(
           borderRadius: BorderRadius.only(
diff --git a/lib/ui/utils/common.dart b/lib/ui/utils/common.dart
new file mode 100644
index 00000000..1720b553
--- /dev/null
+++ b/lib/ui/utils/common.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+String? emailValidator(BuildContext context, String? value) {
+  if (value == null || value.isEmpty) {
+    return AppLocalizations.of(context)!.openSenseMapEmailErrorEmpty;
+  }
+  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
+    return AppLocalizations.of(context)!.openSenseMapEmailErrorInvalid;
+  }
+  return null;
+}
+
+String? passwordValidatorSimple(BuildContext context, String? value) {
+  if (value == null || value.isEmpty) {
+    return AppLocalizations.of(context)!.openSenseMapPasswordErrorEmpty;
+  }
+  return null;
+}
+
+String? passwordValidator(BuildContext context, String? value) {
+  if (value == null || value.isEmpty) {
+    return AppLocalizations.of(context)!.openSenseMapPasswordErrorEmpty;
+  }
+  if (value.length < 8) {
+    return AppLocalizations.of(context)!
+        .openSenseMapRegisterPasswordErrorCharacters;
+  }
+  return null;
+}
+
+String? passwordConfirmationValidator(
+    BuildContext context, String? value, String? password) {
+  if (value == null || value.isEmpty) {
+    return AppLocalizations.of(context)!
+        .openSenseMapRegisterPasswordConfirmErrorEmpty;
+  }
+
+  if (value != password) {
+    return AppLocalizations.of(context)!
+        .openSenseMapRegisterPasswordErrorMismatch;
+  }
+  return null;
+}
diff --git a/lib/ui/widgets/common/button_with_loader.dart b/lib/ui/widgets/common/button_with_loader.dart
new file mode 100644
index 00000000..e10ba99b
--- /dev/null
+++ b/lib/ui/widgets/common/button_with_loader.dart
@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+import 'package:sensebox_bike/ui/widgets/common/loader.dart';
+
+class ButtonWithLoader extends StatelessWidget {
+  final bool isLoading;
+  final VoidCallback? onPressed;
+  final String text;
+  final double width;
+
+  const ButtonWithLoader({
+    super.key,
+    required this.isLoading,
+    required this.onPressed,
+    required this.text,
+    required this.width,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    double screenWidth = MediaQuery.of(context).size.width;
+
+    return SizedBox(
+      width: screenWidth * width, // 40% of the screen width
+      child: FilledButton(
+        onPressed: onPressed,
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.center, // Center the content
+          children: [
+            if (isLoading) ...[
+              Loader(),
+              SizedBox(width: 8), // Add some spacing between the loader and text
+            ],
+            Text(text),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/lib/ui/widgets/common/custom_spacer.dart b/lib/ui/widgets/common/custom_spacer.dart
new file mode 100644
index 00000000..3b1a4657
--- /dev/null
+++ b/lib/ui/widgets/common/custom_spacer.dart
@@ -0,0 +1,12 @@
+import 'package:flutter/material.dart';
+
+class CustomSpacer extends StatelessWidget {
+  final double height;
+
+  const CustomSpacer({super.key, this.height = 16.0});
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(height: height);
+  }
+}
diff --git a/lib/ui/widgets/common/email_field.dart b/lib/ui/widgets/common/email_field.dart
new file mode 100644
index 00000000..e6554926
--- /dev/null
+++ b/lib/ui/widgets/common/email_field.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:sensebox_bike/ui/utils/common.dart';
+
+class EmailField extends StatelessWidget {
+  final TextEditingController controller;
+
+  const EmailField({super.key, required this.controller});
+
+  @override
+  Widget build(BuildContext context) {
+    return TextFormField(
+      autofillHints: const [AutofillHints.email],
+      controller: controller,
+      decoration: InputDecoration(
+        labelText: AppLocalizations.of(context)!.openSenseMapEmail,
+      ),
+      keyboardType: TextInputType.emailAddress,
+      validator: (value) => emailValidator(context, value),
+    );
+  }
+}
diff --git a/lib/ui/widgets/common/error_dialog.dart b/lib/ui/widgets/common/error_dialog.dart
new file mode 100644
index 00000000..e399ae88
--- /dev/null
+++ b/lib/ui/widgets/common/error_dialog.dart
@@ -0,0 +1,30 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+class ErrorDialog extends StatelessWidget {
+  final String errorMessage;
+
+  const ErrorDialog({Key? key, required this.errorMessage}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      content: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const Icon(Icons.error, color: Colors.red),
+          const SizedBox(height: 8),
+          Text(
+            AppLocalizations.of(context)!.openSenseMapRegisterFailed,
+            style: Theme.of(context).textTheme.headlineSmall,
+          ),
+          const SizedBox(height: 16),
+          Text(
+            errorMessage,
+            style: Theme.of(context).textTheme.bodyMedium,
+          ),
+        ],
+      ),
+    );
+  }
+}
diff --git a/lib/ui/widgets/common/loader.dart b/lib/ui/widgets/common/loader.dart
new file mode 100644
index 00000000..a32f943a
--- /dev/null
+++ b/lib/ui/widgets/common/loader.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+
+class Loader extends StatelessWidget {
+  const Loader({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: 16.0, // Match text size
+      width: 16.0, // Match text size
+      child: CircularProgressIndicator(
+        strokeWidth: 2.0, // Adjust thickness
+        color: Theme.of(context).colorScheme.onPrimary,
+      ),
+    );
+  }
+  
+}
\ No newline at end of file
diff --git a/lib/ui/widgets/common/password_field.dart b/lib/ui/widgets/common/password_field.dart
new file mode 100644
index 00000000..4a5a9347
--- /dev/null
+++ b/lib/ui/widgets/common/password_field.dart
@@ -0,0 +1,64 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+class PasswordField extends StatefulWidget {
+  final TextEditingController controller;
+  final String? Function(BuildContext, String?)? validator; // Validator function
+  final bool isConfirmationField; // Flag for confirmation field
+  final String? Function(BuildContext, String?, String?)?
+    confirmationValidator; 
+  final TextEditingController? passwordController; 
+
+  const PasswordField({
+    super.key,
+    required this.controller,
+    this.validator,
+    this.isConfirmationField = false, // Default: not a confirmation field
+    this.confirmationValidator, // Optional: validator for confirmation field
+    this.passwordController, // Optional: original password for comparison
+  });
+
+  @override
+  _PasswordFieldState createState() => _PasswordFieldState();
+}
+
+class _PasswordFieldState extends State<PasswordField> {
+  bool _isPasswordVisible = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return TextFormField(
+      autofillHints: const [AutofillHints.password],
+      controller: widget.controller,
+      obscureText: !_isPasswordVisible, // Toggle visibility
+      decoration: InputDecoration(
+        labelText: widget.isConfirmationField
+            ? AppLocalizations.of(context)!.openSenseMapRegisterPasswordConfirm
+            : AppLocalizations.of(context)!.openSenseMapPassword,
+        suffixIcon: IconButton(
+          icon: Icon(
+            _isPasswordVisible ? Icons.visibility : Icons.visibility_off,
+          ),
+          onPressed: () {
+            setState(() {
+              _isPasswordVisible = !_isPasswordVisible; // Toggle visibility state
+            });
+          },
+        ),
+      ),
+      validator: (value) {
+        if (widget.isConfirmationField) {
+          // Use the custom confirmation validator if provided
+          if (widget.confirmationValidator != null) {
+            return widget.confirmationValidator!(
+                context, value, widget.passwordController?.text);
+          }
+        } else if (widget.validator != null) {
+          // Use the custom validator for regular password field
+          return widget.validator!(context, value);
+        }
+        return null;
+      },
+    );
+  }
+}
diff --git a/lib/ui/widgets/home/geolocation_widget.dart b/lib/ui/widgets/home/geolocation_widget.dart
index 9f0a5ea2..f2b4f7d9 100644
--- a/lib/ui/widgets/home/geolocation_widget.dart
+++ b/lib/ui/widgets/home/geolocation_widget.dart
@@ -91,6 +91,12 @@ class _GeolocationMapWidgetState extends State<GeolocationMapWidget> {
           coordinates: Position(location.longitude, location.latitude));
     }).toList();
 
+    // Ensure there are at least two points before creating the LineString
+    if (points.length < 2) {
+      debugPrint('Not enough points to create a LineString.');
+      return;
+    }
+
     PolylineAnnotationOptions polyline = PolylineAnnotationOptions(
       geometry: LineString.fromPoints(points: points),
     );
diff --git a/lib/ui/widgets/opensensemap/login.dart b/lib/ui/widgets/opensensemap/login.dart
index 69f3fec1..ccee06e5 100644
--- a/lib/ui/widgets/opensensemap/login.dart
+++ b/lib/ui/widgets/opensensemap/login.dart
@@ -1,5 +1,11 @@
 import 'package:flutter/material.dart';
 import 'package:sensebox_bike/blocs/opensensemap_bloc.dart';
+import 'package:sensebox_bike/ui/utils/common.dart';
+import 'package:sensebox_bike/ui/widgets/common/button_with_loader.dart';
+import 'package:sensebox_bike/ui/widgets/common/custom_spacer.dart';
+import 'package:sensebox_bike/ui/widgets/common/email_field.dart';
+import 'package:sensebox_bike/ui/widgets/common/error_dialog.dart';
+import 'package:sensebox_bike/ui/widgets/common/password_field.dart';
 import 'package:sensebox_bike/ui/widgets/opensensemap/login_selection_modal.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
@@ -14,7 +20,9 @@ class LoginForm extends StatefulWidget {
 class _LoginFormState extends State<LoginForm> {
   final TextEditingController emailController = TextEditingController();
   final TextEditingController passwordController = TextEditingController();
-  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
+  final GlobalKey<FormState> formKey =
+      GlobalKey<FormState>(); // Track password visibility
+  bool isLoading = false; // Track loading state
 
   @override
   void dispose() {
@@ -36,85 +44,53 @@ class _LoginFormState extends State<LoginForm> {
           child: Column(
             mainAxisSize: MainAxisSize.min,
             children: [
-              TextFormField(
-                autofillHints: const [AutofillHints.email],
-                controller: emailController,
-                decoration: InputDecoration(
-                  labelText: AppLocalizations.of(context)!.openSenseMapEmail,
-                  // No border
-                ),
-                keyboardType: TextInputType.emailAddress,
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapEmailErrorEmpty;
-                  }
-                  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapEmailErrorInvalid;
-                  }
-                  return null;
-                },
-              ),
-              const SizedBox(height: 16),
-              TextFormField(
-                autofillHints: const [AutofillHints.password],
+              EmailField(controller: emailController),
+              const CustomSpacer(),
+              PasswordField(
                 controller: passwordController,
-                decoration: InputDecoration(
-                  labelText: AppLocalizations.of(context)!.openSenseMapPassword,
-                ),
-                obscureText: true,
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapPasswordErrorEmpty;
-                  }
-                  return null;
-                },
+                validator: (context, value) =>
+                    passwordValidatorSimple(context, value),
+              ),
+              const CustomSpacer(),
+              ButtonWithLoader(
+                  isLoading: isLoading,
+                  text: AppLocalizations.of(context)!.openSenseMapLoginShort,
+                  width: 0.4,
+                  onPressed: isLoading
+                      ? null // Disable button when loading
+                      : () async {
+                          if (formKey.currentState?.validate() == true) {
+                            setState(() {
+                              isLoading = true; // Start loading
+                            });
+
+                            try {
+                              await widget.bloc.login(
+                              emailController.value.text,
+                              passwordController.value.text,
+                              );
+
+                            if (context.mounted) {
+                              Navigator.pop(context); // Close after login
+                              showLoginOrSenseBoxSelection(
+                                  context, widget.bloc);
+                            }
+                            } catch (e) {
+                            if (context.mounted) {
+                              showDialog(
+                                context: context,
+                                builder: (context) =>
+                                    ErrorDialog(errorMessage: e.toString()),
+                              );
+                            }
+                            } finally {
+                              setState(() {
+                                isLoading = false; // Stop loading
+                              });
+                            }
+                          }
+                        },
               ),
-              const SizedBox(height: 16),
-              Builder(builder: (BuildContext context) {
-                return FilledButton(
-                  onPressed: () async {
-                    if (formKey.currentState?.validate() == true) {
-                      try {
-                        await widget.bloc.login(
-                          emailController.text,
-                          passwordController.text,
-                        );
-                        Navigator.pop(context); // Close after login
-                        showLoginOrSenseBoxSelection(context, widget.bloc);
-                      } catch (e) {
-                        showDialog(
-                            context: context,
-                            builder: (context) => AlertDialog(
-                                  content: Column(
-                                    mainAxisSize: MainAxisSize.min,
-                                    children: [
-                                      const Icon(Icons.error,
-                                          color: Colors.red),
-                                      const SizedBox(height: 8),
-                                      Text(
-                                          AppLocalizations.of(context)!
-                                              .openSenseMapLoginFailed,
-                                          style: Theme.of(context)
-                                              .textTheme
-                                              .headlineMedium),
-                                      const SizedBox(height: 16),
-                                      Text(e.toString(),
-                                          style: Theme.of(context)
-                                              .textTheme
-                                              .bodyMedium),
-                                    ],
-                                  ),
-                                ));
-                      }
-                    }
-                  },
-                  child: Text(
-                      AppLocalizations.of(context)!.openSenseMapLoginShort),
-                );
-              }),
             ],
           ),
         ),
diff --git a/lib/ui/widgets/opensensemap/register.dart b/lib/ui/widgets/opensensemap/register.dart
index 09c7aac0..a8451cfd 100644
--- a/lib/ui/widgets/opensensemap/register.dart
+++ b/lib/ui/widgets/opensensemap/register.dart
@@ -1,7 +1,16 @@
 import 'package:flutter/material.dart';
+import 'package:flutter/gestures.dart';
 import 'package:sensebox_bike/blocs/opensensemap_bloc.dart';
+import 'package:sensebox_bike/constants.dart';
+import 'package:sensebox_bike/ui/utils/common.dart';
+import 'package:sensebox_bike/ui/widgets/common/button_with_loader.dart';
+import 'package:sensebox_bike/ui/widgets/common/custom_spacer.dart';
+import 'package:sensebox_bike/ui/widgets/common/email_field.dart';
+import 'package:sensebox_bike/ui/widgets/common/error_dialog.dart';
+import 'package:sensebox_bike/ui/widgets/common/password_field.dart';
 import 'package:sensebox_bike/ui/widgets/opensensemap/login_selection_modal.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:url_launcher/url_launcher.dart';
 
 class RegisterForm extends StatefulWidget {
   final OpenSenseMapBloc bloc;
@@ -18,6 +27,9 @@ class _RegisterFormState extends State<RegisterForm> {
   final TextEditingController confirmPasswordController =
       TextEditingController();
   final GlobalKey<FormState> formKey = GlobalKey<FormState>();
+  bool isLoading = false; // Track loading state
+  bool isAccepted = false;
+  String? privacyPolicyError;
 
   @override
   void dispose() {
@@ -28,6 +40,15 @@ class _RegisterFormState extends State<RegisterForm> {
     super.dispose();
   }
 
+  void validatePrivacyPolicy() {
+    setState(() {
+      privacyPolicyError = isAccepted
+          ? null
+          : AppLocalizations.of(context)!
+              .openSenseMapRegisterAcceptTermsError; // Error message
+    });
+  }
+
   @override
   Widget build(BuildContext context) {
     return SingleChildScrollView(
@@ -48,106 +69,132 @@ class _RegisterFormState extends State<RegisterForm> {
                     labelText:
                         AppLocalizations.of(context)!.openSenseMapRegisterName),
               ),
-              const SizedBox(height: 16),
-              TextFormField(
-                autofillHints: const [AutofillHints.email],
-                controller: emailController,
-                decoration: InputDecoration(
-                  labelText: AppLocalizations.of(context)!.openSenseMapEmail,
-                ),
-                keyboardType: TextInputType.emailAddress,
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapEmailErrorEmpty;
-                  }
-                  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapEmailErrorInvalid;
-                  }
-                  return null;
-                },
-              ),
-              const SizedBox(height: 16),
-              TextFormField(
-                autofillHints: const [AutofillHints.password],
+              const CustomSpacer(),
+              EmailField(controller: emailController),
+              const CustomSpacer(),
+              PasswordField(
                 controller: passwordController,
-                decoration: InputDecoration(
-                  labelText: AppLocalizations.of(context)!.openSenseMapPassword,
-                ),
-                obscureText: true,
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapPasswordErrorEmpty;
-                  }
-                  if (value.length < 8) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapRegisterPasswordErrorCharacters;
-                  }
-                  return null;
-                },
+                validator: passwordValidator,
               ),
-              const SizedBox(height: 16),
-              TextFormField(
-                autofillHints: const [AutofillHints.password],
+              const CustomSpacer(),
+              PasswordField(
                 controller: confirmPasswordController,
-                decoration: InputDecoration(
-                    labelText: AppLocalizations.of(context)!
-                        .openSenseMapRegisterPasswordConfirm),
-                obscureText: true,
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapRegisterPasswordConfirmErrorEmpty;
-                  }
-                  if (value != passwordController.text) {
-                    return AppLocalizations.of(context)!
-                        .openSenseMapRegisterPasswordErrorMismatch;
-                  }
-                  return null;
+                isConfirmationField: true,
+                confirmationValidator: passwordConfirmationValidator,
+                passwordController: passwordController,
+              ),
+              const CustomSpacer(),
+              GestureDetector(
+                onTap: () {
+                  setState(() {
+                    isAccepted = !isAccepted;
+                  });
+                  validatePrivacyPolicy();
                 },
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Row(
+                      crossAxisAlignment:
+                          CrossAxisAlignment.center, // Center vertically
+                      children: [
+                        Checkbox(
+                          value: isAccepted,
+                          onChanged: (bool? value) {
+                            setState(() {
+                              isAccepted = value ?? false;
+                            });
+                            validatePrivacyPolicy();
+                          },
+                        ),
+                        Expanded(
+                          child: RichText(
+                            text: TextSpan(
+                              style: Theme.of(context).textTheme.bodyMedium,
+                              children: [
+                                TextSpan(
+                                    text: AppLocalizations.of(context)!
+                                        .openSenesMapRegisterAcceptTermsPrefix),
+                                TextSpan(text: " "),
+                                TextSpan(
+                                  text: AppLocalizations.of(context)!
+                                      .openSenseMapRegisterAcceptTermsPrivacy,
+                                  style: const TextStyle(
+                                    decoration: TextDecoration.underline,
+                                  ),
+                                  recognizer: TapGestureRecognizer()
+                                    ..onTap = () {
+                                      launchUrl(Uri.parse(privacyPolicyUrl));
+                                    },
+                                ),
+                              ],
+                            ),
+                          ),
+                        ),
+                      ],
+                    ),
+                    if (privacyPolicyError != null)
+                      Padding(
+                        padding: const EdgeInsets.only(
+                            left: 10.0), // Add padding to the error message
+                        child: Text(
+                          privacyPolicyError!,
+                          style: TextStyle(
+                            color: Theme.of(context).colorScheme.error,
+                            fontSize: 12.0,
+                          ),
+                        ),
+                      ),
+                  ],
+                ),
               ),
-              const SizedBox(height: 16),
-              FilledButton(
-                onPressed: () async {
-                  if (formKey.currentState?.validate() == true) {
-                    try {
-                      // Registration logic here
-                      await widget.bloc.register(
-                          nameController.value.text,
-                          emailController.value.text,
-                          passwordController.value.text);
+              const CustomSpacer(),
+              ButtonWithLoader(
+                  isLoading: isLoading,
+                  text: AppLocalizations.of(context)!.openSenseMapRegister,
+                  width: 0.7,
+                  onPressed: isLoading
+                      ? null // Disable button when loading
+                      : () async {
+                          // Validate the form and the privacy policy checkbox
+                          final isFormValid =
+                              formKey.currentState?.validate() == true;
+                          validatePrivacyPolicy(); // Validate the checkbox
 
-                      Navigator.pop(context); // Close after registration
-                      showLoginOrSenseBoxSelection(context, widget.bloc);
-                    } catch (e) {
-                      showDialog(
-                          context: context,
-                          builder: (context) => AlertDialog(
-                                content: Column(
-                                  mainAxisSize: MainAxisSize.min,
-                                  children: [
-                                    const Icon(Icons.error, color: Colors.red),
-                                    const SizedBox(height: 8),
-                                    Text(
-                                        AppLocalizations.of(context)!
-                                            .openSenseMapRegisterFailed,
-                                        style: Theme.of(context)
-                                            .textTheme
-                                            .headlineSmall),
-                                    const SizedBox(height: 16),
-                                    Text(e.toString(),
-                                        style: Theme.of(context)
-                                            .textTheme
-                                            .bodyMedium),
-                                  ],
-                                ),
-                              ));
-                    }
-                  }
-                },
-                child: Text(AppLocalizations.of(context)!.openSenseMapRegister),
+                          if (isFormValid && isAccepted) {
+                            setState(() {
+                              isLoading = true; // Start loading
+                            });
+
+                            validatePrivacyPolicy();
+
+                            try {
+                              // Registration logic here
+                              await widget.bloc.register(
+                                  nameController.value.text,
+                                  emailController.value.text,
+                                  passwordController.value.text);
+
+                              if (context.mounted) {
+                                Navigator.pop(
+                                    context); // Close after registration
+                                showLoginOrSenseBoxSelection(
+                                    context, widget.bloc);
+                              }
+                            } catch (e) {
+                              if (context.mounted) {
+                                showDialog(
+                                  context: context,
+                                  builder: (context) =>
+                                      ErrorDialog(errorMessage: e.toString()));
+                              }
+                            } finally {
+                              setState(() {
+                                isLoading = false; // Stop loading
+                              });
+                            }
+                          }
+                        }
               ),
             ],
           ),
diff --git a/lib/ui/widgets/opensensemap/sensebox_selection.dart b/lib/ui/widgets/opensensemap/sensebox_selection.dart
index da139abe..997e1181 100644
--- a/lib/ui/widgets/opensensemap/sensebox_selection.dart
+++ b/lib/ui/widgets/opensensemap/sensebox_selection.dart
@@ -26,7 +26,10 @@ class _SenseBoxSelectionWidgetState extends State<SenseBoxSelectionWidget> {
     bloc = Provider.of<OpenSenseMapBloc>(context, listen: false);
     _scrollController = ScrollController();
     _scrollController.addListener(_onScroll);
-    _fetchSenseBoxes();
+
+    if (bloc.senseBoxes.isEmpty) {
+      _fetchSenseBoxes();
+    }
   }
 
   void _fetchSenseBoxes() {
@@ -36,6 +39,8 @@ class _SenseBoxSelectionWidgetState extends State<SenseBoxSelectionWidget> {
     });
 
     bloc.fetchSenseBoxes(page: page).then((values) {
+      if (!mounted) return; // Ensure the widget is still in the tree
+
       setState(() {
         isLoading = false;
         page++;
@@ -45,6 +50,13 @@ class _SenseBoxSelectionWidgetState extends State<SenseBoxSelectionWidget> {
           hasMore = false;
         }
       });
+    }).catchError((error) {
+      if (!mounted) return; // Ensure the widget is still in the tree
+
+      setState(() {
+        isLoading = false;
+      });
+      debugPrint('Error fetching senseBoxes: $error');
     });
   }
 
@@ -139,9 +151,11 @@ class _SenseBoxSelectionWidgetState extends State<SenseBoxSelectionWidget> {
                   : null,
               enabled: isSenseBoxBikeCompatible,
               onTap: isSenseBoxBikeCompatible
-                  ? () {
-                      bloc.setSelectedSenseBox(senseBox);
-                      Navigator.pop(context); // Go back after selecting
+                  ? () async {
+                      await bloc.setSelectedSenseBox(senseBox);
+                      if (context.mounted) {
+                        Navigator.pop(context); // Go back after selecting
+                      }
                     }
                   : null,
             );
diff --git a/pubspec.lock b/pubspec.lock
index 0add2d59..796042ba 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1106,6 +1106,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.0.0"
+  simplify:
+    dependency: "direct main"
+    description:
+      name: simplify
+      sha256: c78631bac594fa2045ac3cc6839f13828d6c179890d43d4e106bbcb74bc29d6e
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
   sky_engine:
     dependency: transitive
     description: flutter
diff --git a/pubspec.yaml b/pubspec.yaml
index f7c7c7a5..8145fb99 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -70,6 +70,7 @@ dependencies:
   flutter_localizations:
     sdk: flutter
   mocktail: ^1.0.4
+  simplify: ^1.1.0
 
 dev_dependencies:
   flutter_test:
diff --git a/test/services/open_sense_map_service_test.dart b/test/services/open_sense_map_service_test.dart
new file mode 100644
index 00000000..9d9bc42f
--- /dev/null
+++ b/test/services/open_sense_map_service_test.dart
@@ -0,0 +1,218 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:mocktail/mocktail.dart';
+import 'package:sensebox_bike/services/opensensemap_service.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class MockClient extends Mock implements http.Client {}
+
+void main() {
+  late OpenSenseMapService service;
+  late MockClient mockHttpClient;
+  late SharedPreferences prefs;
+  final String accessToken = 'accessToken';
+
+  setUpAll(() {
+    registerFallbackValue(Uri()); // Register fallback Uri
+  });
+
+  setUp(() async {
+    // Initialize in-memory SharedPreferences
+    SharedPreferences.setMockInitialValues({});
+    prefs = await SharedPreferences.getInstance();
+    mockHttpClient = MockClient();
+    service = OpenSenseMapService(client: mockHttpClient,  prefs: SharedPreferences.getInstance());
+  });
+
+  tearDown(() async {
+    // Clear SharedPreferences after each test
+    await prefs.clear();
+  });
+
+  void mockHTTPPOSTResponse(String response, int code) {
+    when(() => mockHttpClient.post(
+      any(),
+      headers: any(named: 'headers'),
+      body: any(named: 'body'),
+    )).thenAnswer((_) async => http.Response(response, code));
+  }
+
+  void mockHTTPGETResponse(String response, int code) {
+    when(() => mockHttpClient.get(
+      any(),
+      headers: any(named: 'headers'),
+    )).thenAnswer((_) async => http.Response(response, code));
+  }
+
+  Future<void> setTokens() async {
+    // Set refreshToken
+    await Future.wait([
+      prefs.setString('refreshToken', 'value'),
+      prefs.setString('accessToken', accessToken)
+    ]);
+  }
+  group('register()', () {
+    test('if gets 201 response, stores tokens in SharedPreferences', () async {
+      mockHTTPPOSTResponse('{"token": "test_token", "refreshToken": "test_refresh"}', 201);
+
+      await service.register('test', 'test@example.com', 'password');
+
+      expect(prefs.getString('accessToken'), 'test_token');
+      expect(prefs.getString('refreshToken'), 'test_refresh');
+    });
+
+    test('if gets error, throws exception and does not store tokens', () async {
+      mockHTTPPOSTResponse('Error', 400);
+      
+      await expectLater(service.register('test', 'test@example.com', 'password'), throwsException);
+
+      expect(prefs.getString('accessToken'), null);
+      expect(prefs.getString('refreshToken'), null);
+    });
+  });
+
+  group('login()', () {
+    test('if gets 201 response, stores tokens in SharedPreferences', () async {
+      mockHTTPPOSTResponse('{"token": "test_token", "refreshToken": "test_refresh"}', 200);
+
+      await service.login('test@example.com', 'password');
+
+      expect(prefs.getString('accessToken'), 'test_token');
+      expect(prefs.getString('refreshToken'), 'test_refresh');
+    });
+
+    test('if gets error, throws exception and does not store tokens', () async {
+      mockHTTPPOSTResponse('Error', 400);
+      
+      await expectLater(service.login('test@example.com', 'password'), throwsException);
+
+      expect(prefs.getString('accessToken'), null);
+      expect(prefs.getString('refreshToken'), null);
+    });
+  });
+
+  group('logout()', () {
+    test('removes keys', () async {
+      await setTokens();
+
+      await service.logout();
+
+      expect(prefs.getString('accessToken'), null);
+      expect(prefs.getString('refreshToken'), null);
+    });
+    test('when no keys stored, does not throw error', () async {
+      // Verifies completion without errors
+      await expectLater(service.logout(), completes); 
+    });
+  });
+
+  group('getAccessToken()', () {
+    test('when token exists, returns it', () async {
+      await setTokens();
+
+      String? token = await service.getAccessToken();
+      
+      expect(token, accessToken);
+    });
+
+    test('when no token, returns empty string', () async {
+      String? token = await service.getAccessToken();
+      
+      expect(token, null);
+    });
+  });
+  group('refreshToken()', () {
+    test('when successful, new access and refresh tokens are stored correctly', () async {
+      await setTokens();
+      mockHTTPPOSTResponse('{"token": "test_token", "refreshToken": "test_refresh"}', 200);
+      
+      await service.refreshToken();
+
+      expect(prefs.getString('accessToken'), 'test_token');
+      expect(prefs.getString('refreshToken'), 'test_refresh');
+    });
+
+    test('when no refresh token, throws exception', () async {
+      mockHTTPPOSTResponse('{"token": "test_token", "refreshToken": "test_refresh"}', 200);
+      
+      await expectLater(service.refreshToken(), throwsException);
+
+      expect(prefs.getString('accessToken'), null);
+      expect(prefs.getString('refreshToken'), null);
+    });
+
+    test('when no receives error, throws exception', () async {
+      mockHTTPPOSTResponse('error', 400);
+      
+      await expectLater(service.refreshToken(), throwsException);
+
+      expect(prefs.getString('accessToken'), null);
+      expect(prefs.getString('refreshToken'), null);
+    });
+  });
+
+  group('createSenseBoxBike()', () {
+    test('when valid params, completes successfully', () async {
+      await setTokens();
+      mockHTTPPOSTResponse('valid box data', 201);
+
+      await expectLater(service.createSenseBoxBike("name", 0, 0,SenseBoxBikeModel.atrai), completes); 
+    });
+
+    test('when no refresh token, throws exception', () async {
+      mockHTTPPOSTResponse('valid box data', 201);
+
+      await expectLater(service.createSenseBoxBike("name", 0, 0,SenseBoxBikeModel.atrai), throwsException);
+    });
+
+    test('when receives error response, throws exception', () async {
+      await setTokens();
+      mockHTTPPOSTResponse('valid box data', 400);
+
+      await expectLater(service.createSenseBoxBike("name", 0, 0,SenseBoxBikeModel.atrai), throwsException);
+    });
+  });
+
+  group('getSenseBoxes()', () {
+    test('when success, retrieves list of boxes', () async {
+      await setTokens();
+      mockHTTPGETResponse('{"data": {"boxes": []}}', 200);
+
+      var boxes = await service.getSenseBoxes();
+
+      expect(boxes, []);
+    });
+
+    test('when no accessToken, throws error', () async {
+      mockHTTPGETResponse('{"data": {"boxes": []}}', 200);
+
+      await expectLater(service.getSenseBoxes(), throwsException);
+    });
+
+    test('when recieves error response, throws error', () async {
+      mockHTTPGETResponse('error', 400);
+
+      await expectLater(service.getSenseBoxes(), throwsException);
+    });
+  });
+  group('uploadData()', () {
+    test('when valid params, finishes successfully', () async {
+      await setTokens();
+      mockHTTPPOSTResponse('valid data', 201);
+
+      await expectLater(service.uploadData('id', { "data": "data" }), completes); 
+    });
+
+    test('when no valid accessToken, throws exception', () async {
+      mockHTTPPOSTResponse('valid data', 201);
+
+      await expectLater(service.uploadData('id', { "data": "data" }), throwsException);
+    });
+
+    test('when receives error response, throws exception', () async {
+      mockHTTPPOSTResponse('valid data', 400);
+
+      await expectLater(service.uploadData('id', { "data": "data" }), throwsException);
+    });
+  });
+}
\ No newline at end of file
diff --git a/test/ui/widgets/opensensemap/register_test.dart b/test/ui/widgets/opensensemap/register_test.dart
new file mode 100644
index 00000000..0d8d9793
--- /dev/null
+++ b/test/ui/widgets/opensensemap/register_test.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:sensebox_bike/blocs/opensensemap_bloc.dart';
+import 'package:sensebox_bike/ui/widgets/opensensemap/register.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import '../../../test_helpers.dart';
+
+class MockOpenSenseMapBloc extends Mock implements OpenSenseMapBloc {}
+void main() {
+  late MockOpenSenseMapBloc mockBloc;
+
+  setUp(() {
+    initializeTestDependencies();
+    mockBloc = MockOpenSenseMapBloc();
+
+    // Mock the isAuthenticated property
+    when(() => mockBloc.isAuthenticated).thenReturn(false);
+  });
+
+  Widget createTestWidget() {
+    return MaterialApp(
+      localizationsDelegates: [
+        AppLocalizations.delegate,
+        GlobalMaterialLocalizations.delegate,
+        GlobalCupertinoLocalizations.delegate,
+      ],
+      supportedLocales: AppLocalizations.supportedLocales,
+      locale: const Locale('en'),
+      home: Scaffold(
+        body: RegisterForm(bloc: mockBloc),
+      ),
+    );
+  }
+
+  Future<void> enterTextData (WidgetTester tester) async {
+    await tester.enterText(find.byType(TextFormField).at(0), 'Test User');
+    await tester.enterText(find.byType(TextFormField).at(1), 'test@example.com');
+    await tester.enterText(find.byType(TextFormField).at(2), 'password123');
+    await tester.enterText(find.byType(TextFormField).at(3), 'password123');
+  }
+  testWidgets('renders RegisterForm with all fields', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+
+    expect(find.text('Name'), findsOneWidget);
+    expect(find.text('Email'), findsOneWidget);
+    expect(find.text('Password'), findsOneWidget);
+    expect(find.text('Confirm password'), findsOneWidget);
+    expect(find.byType(Checkbox), findsOneWidget);
+    expect(find.textContaining('Register'), findsOneWidget);
+  });
+
+  testWidgets('shows error when privacy policy checkbox is not checked', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+    await enterTextData(tester);
+    await tester.tap(find.textContaining('Register'));
+    await tester.pump();
+
+    expect(find.text('You must accept the privacy policy'), findsOneWidget);
+  });
+
+  testWidgets('does not show error when privacy policy checkbox is checked', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+    await enterTextData(tester);
+    await tester.tap(find.byType(Checkbox));
+    await tester.pump();
+
+    await tester.tap(find.textContaining('Register'));
+    await tester.pump();
+
+    expect(find.text('You must accept the privacy policy'), findsNothing);
+  });
+
+  testWidgets('calls register method on valid form submission', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+    await enterTextData(tester);
+    await tester.tap(find.byType(Checkbox));
+    await tester.pump();
+
+    when(() => mockBloc.register(any(), any(), any())).thenAnswer((_) async => {});
+
+    await tester.tap(find.textContaining('Register'));
+    await tester.pump();
+
+    verify(() => mockBloc.register('Test User', 'test@example.com', 'password123')).called(1);
+  });
+
+  testWidgets('displays loading indicator during registration', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+    await enterTextData(tester);
+    await tester.tap(find.byType(Checkbox));
+    await tester.pump();
+
+    when(() => mockBloc.register(any(), any(), any())).thenAnswer((_) async {
+      await Future.delayed(const Duration(seconds: 1));
+    });
+
+    await tester.tap(find.textContaining('Register'));
+    await tester.pump();
+
+    expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+    await tester.pumpAndSettle();
+  });
+
+  testWidgets('shows error dialog on registration failure', (WidgetTester tester) async {
+    await tester.pumpWidget(createTestWidget());
+    await enterTextData(tester);
+    await tester.tap(find.byType(Checkbox));
+    await tester.pump();
+
+    when(() => mockBloc.register(any(), any(), any())).thenThrow(Exception('Registration failed'));
+
+    await tester.tap(find.textContaining('Register'));
+    await tester.pump();
+
+    expect(find.text('Registration failed'), findsOneWidget);
+  });
+}
\ No newline at end of file


Reviews:
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:27:12Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:28:30Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:29:54Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:30:23Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:31:13Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:31:53Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:17:15Z
user: login: mashazyu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:19:27Z
user: login: felixerdy
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-17T08:38:43Z

Review comments:
diff_hunk: @@ -9,48 +9,75 @@ import 'package:shared_preferences/shared_preferences.dart'; // Add for StreamCo
 class OpenSenseMapBloc with ChangeNotifier, WidgetsBindingObserver {
   final OpenSenseMapService _service = OpenSenseMapService();
   bool _isAuthenticated = false;
-
   // make senseboxes a key value store. key is the page number, value is the list of senseboxes
   final Map<int, List<dynamic>> _senseBoxes = {};
-
   final _senseBoxController =
       StreamController<SenseBox?>.broadcast(); // StreamController
-
   Stream<SenseBox?> get senseBoxStream =>
       _senseBoxController.stream; // Expose stream
-
   // get selected sensebox
   SenseBox? _selectedSenseBox;
-
   SenseBox? get selectedSenseBox => _selectedSenseBox;
-
   bool get isAuthenticated => _isAuthenticated;
   List<dynamic> get senseBoxes => _senseBoxes.values.expand((e) => e).toList();
 
   OpenSenseMapBloc() {
-    _service.refreshToken().then(
-        (_) async => {_isAuthenticated = true, await loadSelectedSenseBox()});
+    _initializeAuth();
path: lib/blocs/opensensemap_bloc.dart
user: login: mashazyu
body: **suggestion (non-blocking):**  This was done to allow async call in constructor.
created_at: 2025-04-03T13:27:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,6 +11,11 @@ import 'package:sensebox_bike/services/isar_service/sensor_service.dart';
 import 'package:sensebox_bike/services/isar_service/track_service.dart';
 
 class IsarService {
+  // Use Singleton pattern to avoid creation of multiple instances
path: lib/services/isar_service.dart
user: login: mashazyu
body: **suggestion (non-blocking):** This part is turning IsarService to singleton to avoid creation of multiple instances.
created_at: 2025-04-03T13:28:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +16,10 @@ class LiveUploadService {
 
   final int trackId;
 
+  // vars to handle connectivity issues
+  DateTime? _lastSuccessfulUpload;
path: lib/services/live_upload_service.dart
user: login: mashazyu
body: **suggestion (non-blocking):** Changes in this service are to allow restarting data upload after connectivity issues, but also not to do it a lot, if connectivity issues last for a long time.
created_at: 2025-04-03T13:29:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,22 +4,56 @@ import 'dart:convert';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'dart:async';
 
+import 'package:sensebox_bike/constants.dart';
+
 enum SenseBoxBikeModel { classic, atrai }
 
 class OpenSenseMapService {
-  static const String _baseUrl = 'https://api.opensensemap.org';
+  static const String _baseUrl = openSenseMapUrl;
+
+  // The following class variables and constructor are added to allow testing
+  // No refactoring of other parts of the code should be needed
+  final http.Client client;
+  final Future<SharedPreferences> _prefs;
+
+  OpenSenseMapService({
+    http.Client? client,
+    Future<SharedPreferences>? prefs,
+  })  : client = client ?? http.Client(),
+        _prefs = prefs ?? SharedPreferences.getInstance();
+
+  Future<void> setTokens(http.Response response) async {
path: lib/services/opensensemap_service.dart
user: login: mashazyu
body: **suggestion (non-blocking):** Refactoring
created_at: 2025-04-03T13:30:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
path: lib/ui/widgets/common/button_with_loader.dart
user: login: mashazyu
body: **suggestion (non-blocking):** Just moving some code to separate components. More can be done, but I decided to stop here :)
created_at: 2025-04-03T13:31:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,7 +26,10 @@ class _SenseBoxSelectionWidgetState extends State<SenseBoxSelectionWidget> {
     bloc = Provider.of<OpenSenseMapBloc>(context, listen: false);
     _scrollController = ScrollController();
     _scrollController.addListener(_onScroll);
-    _fetchSenseBoxes();
+
+    if (bloc.senseBoxes.isEmpty) {
path: lib/ui/widgets/opensensemap/sensebox_selection.dart
user: login: mashazyu
body: **suggestion (non-blocking):** To avoid fetching the boxes every time user navigates the page.
created_at: 2025-04-03T13:31:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,6 +94,21 @@ class LiveUploadService {
     });
   }
 
+  Future<void> uploadDataWithRetry(Map<String, dynamic> data) async {
path: lib/services/live_upload_service.dart
user: login: mashazyu
body: **suggestion (non-blocking):** Basically I moved handling on 429 error from OpenSenseMapService to the LiveUploadService.
created_at: 2025-04-15T09:17:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,224 +164,50 @@ class OpenSenseMapService {
     }
   }
 
-  Future<void> uploadData(
+Future<void> uploadData(
       String senseBoxId, Map<String, dynamic> sensorData) async {
     final accessToken = await getAccessToken();
     if (accessToken == null) throw Exception('Not authenticated');
 
     List<dynamic> data = sensorData.values.toList();
 
-    final response = await http.post(
-      Uri.parse('$_baseUrl/boxes/$senseBoxId/data'),
-      body: jsonEncode(data),
-      headers: {
-        'Authorization': 'Bearer $accessToken',
-        'Content-Type': 'application/json',
-      },
-    );
+    try {
path: lib/services/opensensemap_service.dart
user: login: mashazyu
body: **suggestion (non-blocking):** Here I am forcing timeout of POST request because during testing I encountered the situation, when app hanged because I never received response to the POST request and as far as I understand by default requests never time out.
created_at: 2025-04-15T09:19:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: mashazyu
created_at: 2025-04-15T09:25:43Z
body: @felixerdy ,

Thank you very much for the review!

For the documentation purposes:
- Felix found that data upload never restarted after 429 error (only 6 data uploads per minuter are allowed by the backend).
- Felix confirmed that retry logic that I had in mind was acceptable.

I addressed this issue.

Testing done from my side:
- I see that data upload restarts after 429 error. I also see that sometimes it looks like data upload is restarted too early (?) and after 1 upload I get reply 429 with retry-after 1 second. This can happen a couple of times in a row. I don't want to add randomly a couple of seconds to retry-after value from headers to address this. But, if you feel that it would be a good workaround for now, I'm happy to do so.
- I verified that in case of connectivity issues data transfer is restarted after timeout.
- If max timeout is reached, data transfer never restarts automatically.
author_association: CONTRIBUTOR
user: login: felixerdy
created_at: 2025-04-16T09:55:47Z
body: @mashazyu Thank you for the changes. Data upload is now working as expected. I just discovered a minor UX bug:

**Situation**
When opening the app (after closing it completely), it always looks like I have to login again. After clicking on the login button, the drawer opens and loads the senseBox devices from my account. Thus, I was already logged in. Dismissing the drawer shows the icon to select a senseBox device (linking icon), even though I selected a device last time before closing the app

**Expected**
When opening the app (after closing it completely), the app should check if I'm still logged in and remember the selected senseBox device 

**Screenshots**
| After opening app | After clicking login icon | After dismissing drawer (without selecting device) |
| ------- | ------------ | ------- |
| ![IMG_6045](https://github.com/user-attachments/assets/ff8aff5f-6a75-4fbc-b466-a85369f31d17) | ![IMG_6046](https://github.com/user-attachments/assets/c876cecc-6749-415d-894c-567168b63f1f) | ![IMG_6047](https://github.com/user-attachments/assets/d4645cea-5638-4351-a767-3237e4ec3d2e) |
author_association: COLLABORATOR
user: login: mashazyu
created_at: 2025-04-17T07:27:18Z
body: > @mashazyu Thank you for the changes. Data upload is now working as expected. I just discovered a minor UX bug:
> 
> **Situation** When opening the app (after closing it completely), it always looks like I have to login again. After clicking on the login button, the drawer opens and loads the senseBox devices from my account. Thus, I was already logged in. Dismissing the drawer shows the icon to select a senseBox device (linking icon), even though I selected a device last time before closing the app
> 
> **Expected** When opening the app (after closing it completely), the app should check if I'm still logged in and remember the selected senseBox device
> 
> **Screenshots*)
> 

Hello @felixerdy ,

Thank you very much! The issue should be fixed now :)

author_association: CONTRIBUTOR