From 6a35c3b652389f90927bf659e07f0c1e36cb5065 Mon Sep 17 00:00:00 2001 From: jonboncovey <154102355+jonboncovey@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:18:08 -0700 Subject: [PATCH 1/2] added cubit with targeted state management, realtime sensor updates(5s interval), added reactive search functionality, added edit dialog for updating sensors --- lib/bloc/sensors_cubit.dart | 120 ++++++++++++ lib/main.dart | 8 +- lib/model/sensor.dart | 6 + .../sensors_list_screen.dart | 185 ++++++++++++++++++ 4 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 lib/bloc/sensors_cubit.dart create mode 100644 lib/screens/sensor_list_screen.dart/sensors_list_screen.dart diff --git a/lib/bloc/sensors_cubit.dart b/lib/bloc/sensors_cubit.dart new file mode 100644 index 0000000..426b7f6 --- /dev/null +++ b/lib/bloc/sensors_cubit.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:interview_project/model/sensor.dart'; +import 'package:interview_project/repository/sensor_repository.dart'; + + +/* Architerual/Design choices: +- I picked cubit over bloc primarily for speed of development. +- I went with the enum route for State Status instead of individual + states also for speed of development, personally i tend to prefer individual states typically. +- Dropped the use of Equatables, using status enum made it not really + necessary, although typically Equatable and "state is LoadedState"/etc is how I check states +*/ + +enum StateStatus { initial, loading, loaded, error } + +class SensorsState { + final StateStatus status; + final List sensors; + final List filteredSensors; + final String searchQuery; + final String? errorMessage; + + const SensorsState({ + required this.status, + required this.sensors, + required this.filteredSensors, + this.searchQuery = '', + this.errorMessage, + }); + + SensorsState copyWith({ + StateStatus? status, + List? sensors, + List? filteredSensors, + String? searchQuery, + String? errorMessage, + }) { + return SensorsState( + status: status ?? this.status, + sensors: sensors ?? this.sensors, + filteredSensors: filteredSensors ?? this.filteredSensors, + searchQuery: searchQuery ?? this.searchQuery, + errorMessage: errorMessage, + ); + } + +} + +class SensorsCubit extends Cubit { + final SensorRepository _sensorRepository; + StreamSubscription>? _sensorSubscription; + + SensorsCubit(this._sensorRepository) + : super( + const SensorsState( + status: StateStatus.initial, + sensors: [], + filteredSensors: [], + ), + ); + + void initialize() { + emit(state.copyWith(status: StateStatus.loading)); + + try { + _sensorRepository.initializeSensors(); + _sensorSubscription = _sensorRepository.sensorsStream.listen( + (sensors) => _updateSensorsWithSearch(sensors, state.searchQuery), + onError: (error) => emit(state.copyWith( + status: StateStatus.error, + errorMessage: error.toString(), + )), + ); + } catch (error) { + emit(state.copyWith( + status: StateStatus.error, + errorMessage: error.toString(), + )); + } + } + + void searchSensors(String query) { + if (state.status == StateStatus.loaded) { + _updateSensorsWithSearch(state.sensors, query); + } + } + + void _updateSensorsWithSearch(List sensors, String query) { + final filteredSensors = query.isEmpty + ? sensors + : sensors.where((sensor) => sensor.matchesSearchQuery(query)).toList(); + + emit(state.copyWith( + status: StateStatus.loaded, + sensors: sensors, + filteredSensors: filteredSensors, + searchQuery: query, + )); + } + + void updateSensorDetails(Sensor newSensor) { + try { + _sensorRepository.updateSensor(newSensor); + } catch (error) { + emit(state.copyWith( + status: StateStatus.error, + errorMessage: error.toString(), + )); + } + } + + @override + Future close() { + _sensorSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 927a1e0..f220723 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:interview_project/screens/sensor_list_screen.dart/sensors_list_screen.dart'; void main() { runApp(const MyApp()); @@ -10,11 +11,12 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Sensor Management', theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, ), - home: Placeholder(), + home: const SensorsListScreen(), ); } } diff --git a/lib/model/sensor.dart b/lib/model/sensor.dart index 45335ca..ef6e082 100644 --- a/lib/model/sensor.dart +++ b/lib/model/sensor.dart @@ -25,6 +25,12 @@ class Sensor { value: Random().nextDouble() * 100, ); } + // i was shown this early on and its become a habit for me for easy searchability + bool matchesSearchQuery(String query) { + String lowerQuery = query.toLowerCase(); + return name.toLowerCase().contains(lowerQuery) || + description.toLowerCase().contains(lowerQuery); + } Sensor copyWith({double? value, String? name, String? description}) { return Sensor( diff --git a/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart b/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart new file mode 100644 index 0000000..242a58e --- /dev/null +++ b/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:interview_project/bloc/sensors_cubit.dart'; +import 'package:interview_project/model/sensor.dart'; +import 'package:interview_project/repository/sensor_repository.dart'; + +/* +I mentioned in the interview, but I like using pure stateless widgets +for UI where possible, leaving allll state management to the bloc. +I use this Screen/Content combo so the Bloc is only available on the screen, +even though its technically overkill for a one-page app. Typically I'd break +out certain components into their own widgets/components dir, but I tried to +stay true to the spirit of the task and not over-engineer just because i have +extra time, and the files under 200 lines +*/ + +class SensorsListScreen extends StatelessWidget { + const SensorsListScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SensorsCubit(SensorRepository())..initialize(), + child: SensorsListScreenContent(), + ); + } +} + +class SensorsListScreenContent extends StatelessWidget { + const SensorsListScreenContent({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sensor Management'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: BlocBuilder( + builder: (context, state) => switch (state.status) { + StateStatus.initial => getInitialWidget(), + StateStatus.loading => getLoadingWidget(), + StateStatus.loaded => getLoadedWidget( + state, + context.read().searchSensors, + ), + StateStatus.error => getErrorWidget( + state.errorMessage ?? 'Unknown error', + ), + }, + ), + ); + } + + Widget getLoadingWidget() { + return const Center(child: CircularProgressIndicator()); + } + + Widget getErrorWidget(String errorMessage) { + return Center(child: Text(errorMessage)); + } + + Widget getInitialWidget() { + return const Center(child: Text("Welcome! App should be loading shortly")); + } + + Widget getLoadedWidget(SensorsState state, Function(String) searchSensors) { + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + decoration: const InputDecoration( + hintText: 'Search sensors...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: searchSensors, + ), + ), + // Sensor list + Expanded( + child: state.filteredSensors.isEmpty + ? const Center( + child: Text( + 'No sensors found', + style: TextStyle(fontSize: 18), + ), + ) + : ListView.builder( + itemCount: state.filteredSensors.length, + itemBuilder: (context, index) { + final sensor = state.filteredSensors[index]; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + child: Text( + '#${sensor.id}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + title: Text( + sensor.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(sensor.description), + trailing: Text( + '${sensor.value.toStringAsFixed(1)}°F', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + onTap: () => _showEditDialog(context, sensor), + ), + ); + }, + ), + ), + ], + ); + } + + void _showEditDialog(BuildContext context, Sensor sensor) { + final nameController = TextEditingController(text: sensor.name); + final descController = TextEditingController(text: sensor.description); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Edit Sensor'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: descController, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + context.read().updateSensorDetails( + sensor.copyWith( + name: nameController.text.trim(), + description: descController.text.trim(), + ), + ); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Save'), + ), + ], + ), + ); + } +} From e58bb8481270d520db862f25a56edc492f0d75bc Mon Sep 17 00:00:00 2001 From: jonboncovey <154102355+jonboncovey@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:42:14 -0700 Subject: [PATCH 2/2] added a note --- .../sensor_list_screen.dart/sensors_list_screen.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart b/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart index 242a58e..44e050f 100644 --- a/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart +++ b/lib/screens/sensor_list_screen.dart/sensors_list_screen.dart @@ -11,7 +11,10 @@ I use this Screen/Content combo so the Bloc is only available on the screen, even though its technically overkill for a one-page app. Typically I'd break out certain components into their own widgets/components dir, but I tried to stay true to the spirit of the task and not over-engineer just because i have -extra time, and the files under 200 lines +extra time, and the files under 200 lines. + +Typically Id also extrapolate a lot of these hardcoded numbers for +padding/fontsize/etc, but for time i just put what looked good */ class SensorsListScreen extends StatelessWidget { @@ -67,7 +70,6 @@ class SensorsListScreenContent extends StatelessWidget { Widget getLoadedWidget(SensorsState state, Function(String) searchSensors) { return Column( children: [ - // Search bar Padding( padding: const EdgeInsets.all(16.0), child: TextField( @@ -79,7 +81,6 @@ class SensorsListScreenContent extends StatelessWidget { onChanged: searchSensors, ), ), - // Sensor list Expanded( child: state.filteredSensors.isEmpty ? const Center(