Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions lib/bloc/sensors_cubit.dart
Original file line number Diff line number Diff line change
@@ -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<Sensor> sensors;
final List<Sensor> 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<Sensor>? sensors,
List<Sensor>? 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<SensorsState> {
final SensorRepository _sensorRepository;
StreamSubscription<List<Sensor>>? _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<Sensor> 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<void> close() {
_sensorSubscription?.cancel();
return super.close();
}
}
8 changes: 5 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -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());
Expand All @@ -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(),
);
}
}
6 changes: 6 additions & 0 deletions lib/model/sensor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
186 changes: 186 additions & 0 deletions lib/screens/sensor_list_screen.dart/sensors_list_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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.

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 {
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<SensorsCubit, SensorsState>(
builder: (context, state) => switch (state.status) {
StateStatus.initial => getInitialWidget(),
StateStatus.loading => getLoadingWidget(),
StateStatus.loaded => getLoadedWidget(
state,
context.read<SensorsCubit>().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: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
decoration: const InputDecoration(
hintText: 'Search sensors...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: searchSensors,
),
),
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<SensorsCubit>().updateSensorDetails(
sensor.copyWith(
name: nameController.text.trim(),
description: descController.text.trim(),
),
);
Navigator.of(dialogContext).pop();
},
child: const Text('Save'),
),
],
),
);
}
}