Compare commits

...

3 Commits

Author SHA1 Message Date
8f5ed07be9 added comments and refactored code 2025-01-28 22:36:23 +01:00
6828a9a14b changed graph line color to primary 2025-01-28 22:27:12 +01:00
7a45bab7c1 Added comments and refactored code 2025-01-28 22:09:42 +01:00
13 changed files with 91 additions and 61 deletions

View File

@@ -30,12 +30,15 @@ class FloodStation {
riverName: parseStringValue(json['riverName']), riverName: parseStringValue(json['riverName']),
); );
// sometimes the API returns a String instead of a double
static double parseDoubleValue(dynamic value) { static double parseDoubleValue(dynamic value) {
if (value is double) return value; if (value is double) return value;
if (value is String) return double.parse(value); if (value is String) return double.parse(value);
return 0; return 0;
} }
// sometimes the API returns a list of labels that are basically identical
/// if [value] is a List, the method return the first item
static String parseStringValue(dynamic value) { static String parseStringValue(dynamic value) {
if (value is String) return value; if (value is String) return value;
if (value is List<dynamic>) return value[0]; if (value is List<dynamic>) return value[0];

View File

@@ -15,8 +15,9 @@ class LandingPage extends StatefulWidget {
State<LandingPage> createState() => _LandingPageState(); State<LandingPage> createState() => _LandingPageState();
} }
// uses mixin OverlayService to show loading overlay
class _LandingPageState extends State<LandingPage> with OverlayService { class _LandingPageState extends State<LandingPage> with OverlayService {
late FloodStationProvider floodStationProvider; late FloodStationProvider _floodStationProvider;
@override @override
void dispose() { void dispose() {
@@ -26,7 +27,7 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
floodStationProvider = context.watch<FloodStationProvider>(); _floodStationProvider = context.watch<FloodStationProvider>();
return Column( return Column(
children: [ children: [
StationFilter( StationFilter(
@@ -37,8 +38,10 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
); );
} }
// if the list of all Stations is empty the method returns a button to load them
// else returns a list of FloodStations
Widget _buildStationList() { Widget _buildStationList() {
if (!_shouldShowList()) { if (!_shouldShowList) {
return Expanded( return Expanded(
child: Center( child: Center(
child: ElevatedButton( child: ElevatedButton(
@@ -51,9 +54,9 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
return Expanded( return Expanded(
child: FloodStationListView( child: FloodStationListView(
stations: floodStationProvider.filtered stations: _floodStationProvider.filtered
? floodStationProvider.filteredStations ? _floodStationProvider.filteredStations
: floodStationProvider.allStations, : _floodStationProvider.allStations,
onItemTapped: _navigateToStationDetail, onItemTapped: _navigateToStationDetail,
), ),
); );
@@ -61,7 +64,7 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
void _handleFilterChange(String filterText) { void _handleFilterChange(String filterText) {
if (filterText.isEmpty) { if (filterText.isEmpty) {
floodStationProvider.filtered = false; _floodStationProvider.filtered = false;
setState(() {}); setState(() {});
return; return;
} }
@@ -70,14 +73,14 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
context: context, context: context,
message: 'Loading', message: 'Loading',
onDismiss: () { onDismiss: () {
floodStationProvider.cancelFilterLoading(); _floodStationProvider.cancelFilterLoading();
}, },
); );
floodStationProvider.loadFilteredStations(filterText); _floodStationProvider.loadFilteredStations(filterText);
floodStationProvider.filteredStationsFuture _floodStationProvider.filteredStationsFuture
?.then((_) => removeLoadingNotifier()); ?.then((_) => removeLoadingNotifier());
floodStationProvider.filtered = true; _floodStationProvider.filtered = true;
} }
void _handleLoadAllStations() { void _handleLoadAllStations() {
@@ -85,25 +88,27 @@ class _LandingPageState extends State<LandingPage> with OverlayService {
context: context, context: context,
message: 'Loading', message: 'Loading',
onDismiss: () { onDismiss: () {
floodStationProvider.cancelFilterLoading(); _floodStationProvider.cancelFilterLoading();
}, },
); );
floodStationProvider _floodStationProvider
.loadAllStations() .loadAllStations()
.whenComplete(() => removeLoadingNotifier()); .whenComplete(() => removeLoadingNotifier());
} }
void _navigateToStationDetail(FloodStation station) { void _navigateToStationDetail(FloodStation station) {
floodStationProvider.selectedStation = station; _floodStationProvider.selectedStation = station;
Navigator.of(context).pushNamed(FloodStationPage.routeName); Navigator.of(context).pushNamed(FloodStationPage.routeName);
} }
bool _shouldShowList() { // returns boolean to decide whether the list of stations should be shown
if (!floodStationProvider.filtered && // if the list of stations is empty and is not filtered either, the function returns false
floodStationProvider.allStations.isNotEmpty) { bool get _shouldShowList {
if (!_floodStationProvider.filtered &&
_floodStationProvider.allStations.isNotEmpty) {
return true; return true;
} }
return floodStationProvider.filtered; return _floodStationProvider.filtered;
} }
} }

View File

@@ -5,6 +5,7 @@ import 'map_page.dart';
class MainNavigationScaffold extends StatefulWidget { class MainNavigationScaffold extends StatefulWidget {
const MainNavigationScaffold({super.key}); const MainNavigationScaffold({super.key});
static const routeName = '/'; static const routeName = '/';
@override @override
@@ -12,13 +13,13 @@ class MainNavigationScaffold extends StatefulWidget {
} }
class _MainNavigationScaffoldState extends State<MainNavigationScaffold> { class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
int _selectedPageIndex = 0;
final List<Widget> _pages = [ final List<Widget> _pages = [
const LandingPage(), const LandingPage(),
const MapPage(), const MapPage(),
]; ];
int _selectedPageIndex = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -32,6 +33,7 @@ class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Map'), NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Map'),
], ],
), ),
// Used IndexedStack to save the page state while navigating
body: IndexedStack( body: IndexedStack(
index: _selectedPageIndex, index: _selectedPageIndex,
children: _pages, children: _pages,

View File

@@ -20,7 +20,7 @@ class MapPage extends StatefulWidget {
} }
class _MapPageState extends State<MapPage> { class _MapPageState extends State<MapPage> {
final mapController = MapController(); final _mapController = MapController();
late FloodStationProvider _floodStationProvider; late FloodStationProvider _floodStationProvider;
@override @override
@@ -35,7 +35,7 @@ class _MapPageState extends State<MapPage> {
); );
} }
return FlutterMap( return FlutterMap(
mapController: mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
cameraConstraint: CameraConstraint.containCenter( cameraConstraint: CameraConstraint.containCenter(
bounds: LatLngBounds.fromPoints(_floodStationProvider.allStations bounds: LatLngBounds.fromPoints(_floodStationProvider.allStations
@@ -47,7 +47,7 @@ class _MapPageState extends State<MapPage> {
initialZoom: 6, initialZoom: 6,
), ),
children: [ children: [
openStreetMapTileLayer, _openStreetMapTileLayer,
MarkerClusterLayerWidget( MarkerClusterLayerWidget(
options: MarkerClusterLayerOptions( options: MarkerClusterLayerOptions(
maxClusterRadius: 45, maxClusterRadius: 45,
@@ -56,13 +56,14 @@ class _MapPageState extends State<MapPage> {
padding: EdgeInsets.all(50), padding: EdgeInsets.all(50),
maxZoom: 15, maxZoom: 15,
markers: _stationsAsMarkers(_floodStationProvider.allStations), markers: _stationsAsMarkers(_floodStationProvider.allStations),
builder: _markerBuilder), builder: _clusterMarkerBuilder),
) )
], ],
); );
} }
Widget _markerBuilder(BuildContext context, List<Marker> markers) { // builds the clustered marker
Widget _clusterMarkerBuilder(BuildContext context, List<Marker> markers) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -71,12 +72,13 @@ class _MapPageState extends State<MapPage> {
child: Center( child: Center(
child: Text( child: Text(
markers.length.toString(), markers.length.toString(),
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
), ),
), ),
); );
} }
// gets a list of markers from the list of all stations
List<Marker> _stationsAsMarkers(List<FloodStation> stations) { List<Marker> _stationsAsMarkers(List<FloodStation> stations) {
return stations return stations
.map<Marker>( .map<Marker>(
@@ -91,7 +93,7 @@ class _MapPageState extends State<MapPage> {
.toList(); .toList();
} }
_markerTapped(FloodStation station) { void _markerTapped(FloodStation station) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => MapPopup( builder: (context) => MapPopup(
@@ -104,7 +106,7 @@ class _MapPageState extends State<MapPage> {
} }
} }
TileLayer get openStreetMapTileLayer => TileLayer( TileLayer get _openStreetMapTileLayer => TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'dev.fleaflet.flutter_map.example', userAgentPackageName: 'dev.fleaflet.flutter_map.example',
tileProvider: CancellableNetworkTileProvider(), tileProvider: CancellableNetworkTileProvider(),

View File

@@ -9,6 +9,7 @@ class Api {
static const String _rootUrl = static const String _rootUrl =
'https://environment.data.gov.uk/flood-monitoring'; 'https://environment.data.gov.uk/flood-monitoring';
/// Fetches all stationszt
static Future<List<FloodStation>> fetchAllStations() async { static Future<List<FloodStation>> fetchAllStations() async {
List<FloodStation> stations = []; List<FloodStation> stations = [];
final response = await http.get(Uri.parse('$_rootUrl/id/stations')); final response = await http.get(Uri.parse('$_rootUrl/id/stations'));
@@ -37,7 +38,6 @@ class Api {
/// [limit] limits the number of entries that are requested from the API and [offset] returns the /// [limit] limits the number of entries that are requested from the API and [offset] returns the
/// list starting from the specified number /// list starting from the specified number
static Future<List<FloodStation>> fetchStationsByRange( static Future<List<FloodStation>> fetchStationsByRange(
int limit, int offset) async { int limit, int offset) async {
List<FloodStation> stations = []; List<FloodStation> stations = [];
@@ -52,6 +52,7 @@ class Api {
return stations; return stations;
} }
/// Fetches all readings from the station with the specified [stationId] from the last 24h
static Future<List<Reading>> fetchReadingsFromStation( static Future<List<Reading>> fetchReadingsFromStation(
String stationId) async { String stationId) async {
List<Reading> readings = []; List<Reading> readings = [];

View File

@@ -4,19 +4,20 @@ import '../model/flood_station.dart';
import 'api.dart'; import 'api.dart';
class FloodStationProvider extends ChangeNotifier { class FloodStationProvider extends ChangeNotifier {
// since the getter and setter for the following two fields would be empty, they are publicly accessible
FloodStation? selectedStation; FloodStation? selectedStation;
bool filtered = false; bool filtered = false;
List<FloodStation> _allStations = []; List<FloodStation> _allStations = [];
List<FloodStation> _filteredStations = []; List<FloodStation> _filteredStations = [];
CancelableOperation? _filteredStationsFuture;
List<FloodStation> get allStations => _allStations; List<FloodStation> get allStations => _allStations;
List<FloodStation> get filteredStations => _filteredStations; List<FloodStation> get filteredStations => _filteredStations;
CancelableOperation? _filteredStationsFuture;
CancelableOperation? get filteredStationsFuture => _filteredStationsFuture; CancelableOperation? get filteredStationsFuture => _filteredStationsFuture;
/// loads all stations in batches of 500 and notifies listeners with every loop except if [silent] = true
/// this has lower performance than loading them all at once an shouldn't be used
Future loadAllStationsInBatches({silent = false}) { Future loadAllStationsInBatches({silent = false}) {
int offset = 0; int offset = 0;
return Future.doWhile(() async { return Future.doWhile(() async {
@@ -34,6 +35,7 @@ class FloodStationProvider extends ChangeNotifier {
}); });
} }
/// loads all flood stations and notifies listeners when done
Future loadAllStations({silent = false}) { Future loadAllStations({silent = false}) {
return Api.fetchAllStations().then( return Api.fetchAllStations().then(
(value) { (value) {
@@ -45,6 +47,7 @@ class FloodStationProvider extends ChangeNotifier {
); );
} }
/// loads all stations whose label contains [filter]
Future loadFilteredStations(String filter, {silent = false}) { Future loadFilteredStations(String filter, {silent = false}) {
if (_filteredStationsFuture != null) { if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel(); _filteredStationsFuture!.cancel();
@@ -61,6 +64,7 @@ class FloodStationProvider extends ChangeNotifier {
return future; return future;
} }
/// cancels loading of filtered results.
void cancelFilterLoading() { void cancelFilterLoading() {
if (_filteredStationsFuture != null) { if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel(); _filteredStationsFuture!.cancel();

View File

@@ -15,7 +15,7 @@ mixin OverlayService {
final overlayState = Overlay.of(context); final overlayState = Overlay.of(context);
_overlayEntry = OverlayEntry( _overlayEntry = OverlayEntry(
builder: (context) => Positioned( builder: (context) => Positioned(
bottom: 85, bottom: 85, // Positioned above the NavigationBar
left: 0, left: 0,
right: 0, right: 0,
child: Center( child: Center(

View File

@@ -1,13 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CustomMarker extends StatelessWidget { class CustomMarker extends StatelessWidget {
const CustomMarker({super.key, required this.onTap}); const CustomMarker({super.key, required void Function() onTap})
final void Function() onTap; : _onTap = onTap;
final void Function() _onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: _onTap,
child: Icon( child: Icon(
Icons.location_on_sharp, Icons.location_on_sharp,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,

View File

@@ -6,11 +6,12 @@ class FloodStationListView extends StatelessWidget {
const FloodStationListView( const FloodStationListView(
{super.key, {super.key,
required List<FloodStation> stations, required List<FloodStation> stations,
required this.onItemTapped}) required void Function(FloodStation) onItemTapped})
: _stations = stations; : _onItemTapped = onItemTapped,
_stations = stations;
final List<FloodStation> _stations; final List<FloodStation> _stations;
final void Function(FloodStation) onItemTapped; final void Function(FloodStation) _onItemTapped;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -23,7 +24,7 @@ class FloodStationListView extends StatelessWidget {
final item = _stations.elementAt(index); final item = _stations.elementAt(index);
return ListTile( return ListTile(
isThreeLine: true, isThreeLine: true,
onTap: () => onItemTapped(item), onTap: () => _onItemTapped(item),
title: Text(item.label), title: Text(item.label),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -2,9 +2,14 @@ import 'package:flutter/material.dart';
class LoadingNotifier extends StatelessWidget { class LoadingNotifier extends StatelessWidget {
const LoadingNotifier( const LoadingNotifier(
{super.key, required this.onDismissed, required this.message}); {super.key,
final void Function() onDismissed; required void Function() onDismissed,
final String message; required String message})
: _onDismissed = onDismissed,
_message = message;
final void Function() _onDismissed;
final String _message;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -26,11 +31,11 @@ class LoadingNotifier extends StatelessWidget {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
Text( Text(
message, _message,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
IconButton( IconButton(
onPressed: () => onDismissed(), onPressed: () => _onDismissed(),
icon: Icon(Icons.close), icon: Icon(Icons.close),
) )
], ],

View File

@@ -5,14 +5,19 @@ import '../model/flood_station.dart';
class MapPopup extends StatelessWidget { class MapPopup extends StatelessWidget {
const MapPopup( const MapPopup(
{super.key, required this.station, required this.onShowTapped}); {super.key,
final FloodStation station; required FloodStation station,
final Function() onShowTapped; required dynamic Function() onShowTapped})
: _onShowTapped = onShowTapped,
_station = station;
final FloodStation _station;
final Function() _onShowTapped;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(station.label), title: Text(_station.label),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -22,14 +27,14 @@ class MapPopup extends StatelessWidget {
children: [ children: [
Icon(Icons.home_outlined), Icon(Icons.home_outlined),
Padding(padding: EdgeInsets.only(left: 8)), Padding(padding: EdgeInsets.only(left: 8)),
Text(station.town.isEmpty ? '-' : station.town), Text(_station.town.isEmpty ? '-' : _station.town),
], ],
), ),
Row( Row(
children: [ children: [
Icon(Icons.water), Icon(Icons.water),
Padding(padding: EdgeInsets.only(left: 8)), Padding(padding: EdgeInsets.only(left: 8)),
Text(station.riverName.isEmpty ? '-' : station.riverName), Text(_station.riverName.isEmpty ? '-' : _station.riverName),
], ],
), ),
Row( Row(
@@ -37,8 +42,8 @@ class MapPopup extends StatelessWidget {
Icon(Icons.calendar_month_outlined), Icon(Icons.calendar_month_outlined),
Padding(padding: EdgeInsets.only(left: 8)), Padding(padding: EdgeInsets.only(left: 8)),
Text( Text(
station.dateOpened != null _station.dateOpened != null
? intl.DateFormat.yMd().format(station.dateOpened!) ? intl.DateFormat.yMd().format(_station.dateOpened!)
: '-', : '-',
), ),
], ],
@@ -51,7 +56,7 @@ class MapPopup extends StatelessWidget {
child: Text('dismiss'), child: Text('dismiss'),
), ),
TextButton( TextButton(
onPressed: onShowTapped, onPressed: _onShowTapped,
child: Text('show'), child: Text('show'),
), ),
], ],

View File

@@ -27,7 +27,7 @@ class ReadingGraph extends StatelessWidget {
lineBarsData: [ lineBarsData: [
LineChartBarData( LineChartBarData(
isCurved: true, isCurved: true,
color: Colors.cyan, color: Theme.of(context).colorScheme.primary,
barWidth: 2, barWidth: 2,
isStrokeCapRound: true, isStrokeCapRound: true,
dotData: const FlDotData(show: false), dotData: const FlDotData(show: false),

View File

@@ -9,28 +9,28 @@ class StationFilter extends StatefulWidget {
} }
class StationFilterState extends State<StationFilter> { class StationFilterState extends State<StationFilter> {
TextEditingController filterController = TextEditingController(); final TextEditingController _filterController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: filterController, controller: _filterController,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: Icon(Icons.search), prefixIcon: Icon(Icons.search),
suffixIcon: Opacity( suffixIcon: Opacity(
opacity: filterController.text.isEmpty ? 0 : 1, opacity: _filterController.text.isEmpty ? 0 : 1,
child: IconButton( child: IconButton(
onPressed: () { onPressed: () {
filterController.clear(); _filterController.clear();
widget.onChanged(filterController.text); widget.onChanged(_filterController.text);
}, },
icon: Icon(Icons.delete), icon: Icon(Icons.delete),
), ),
), ),
label: Text('Filter'), label: Text('Filter'),
), ),
onChanged: (_) => widget.onChanged(filterController.text), onChanged: (_) => widget.onChanged(_filterController.text),
); );
} }
} }