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']),
);
// sometimes the API returns a String instead of a double
static double parseDoubleValue(dynamic value) {
if (value is double) return value;
if (value is String) return double.parse(value);
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) {
if (value is String) return value;
if (value is List<dynamic>) return value[0];

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ class MapPage extends StatefulWidget {
}
class _MapPageState extends State<MapPage> {
final mapController = MapController();
final _mapController = MapController();
late FloodStationProvider _floodStationProvider;
@override
@@ -35,7 +35,7 @@ class _MapPageState extends State<MapPage> {
);
}
return FlutterMap(
mapController: mapController,
mapController: _mapController,
options: MapOptions(
cameraConstraint: CameraConstraint.containCenter(
bounds: LatLngBounds.fromPoints(_floodStationProvider.allStations
@@ -47,7 +47,7 @@ class _MapPageState extends State<MapPage> {
initialZoom: 6,
),
children: [
openStreetMapTileLayer,
_openStreetMapTileLayer,
MarkerClusterLayerWidget(
options: MarkerClusterLayerOptions(
maxClusterRadius: 45,
@@ -56,13 +56,14 @@ class _MapPageState extends State<MapPage> {
padding: EdgeInsets.all(50),
maxZoom: 15,
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(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
@@ -71,12 +72,13 @@ class _MapPageState extends State<MapPage> {
child: Center(
child: Text(
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) {
return stations
.map<Marker>(
@@ -91,7 +93,7 @@ class _MapPageState extends State<MapPage> {
.toList();
}
_markerTapped(FloodStation station) {
void _markerTapped(FloodStation station) {
showDialog(
context: context,
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',
userAgentPackageName: 'dev.fleaflet.flutter_map.example',
tileProvider: CancellableNetworkTileProvider(),

View File

@@ -9,6 +9,7 @@ class Api {
static const String _rootUrl =
'https://environment.data.gov.uk/flood-monitoring';
/// Fetches all stationszt
static Future<List<FloodStation>> fetchAllStations() async {
List<FloodStation> 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
/// list starting from the specified number
static Future<List<FloodStation>> fetchStationsByRange(
int limit, int offset) async {
List<FloodStation> stations = [];
@@ -52,6 +52,7 @@ class Api {
return stations;
}
/// Fetches all readings from the station with the specified [stationId] from the last 24h
static Future<List<Reading>> fetchReadingsFromStation(
String stationId) async {
List<Reading> readings = [];

View File

@@ -4,19 +4,20 @@ import '../model/flood_station.dart';
import 'api.dart';
class FloodStationProvider extends ChangeNotifier {
// since the getter and setter for the following two fields would be empty, they are publicly accessible
FloodStation? selectedStation;
bool filtered = false;
List<FloodStation> _allStations = [];
List<FloodStation> _filteredStations = [];
CancelableOperation? _filteredStationsFuture;
List<FloodStation> get allStations => _allStations;
List<FloodStation> get filteredStations => _filteredStations;
CancelableOperation? _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}) {
int offset = 0;
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}) {
return Api.fetchAllStations().then(
(value) {
@@ -45,6 +47,7 @@ class FloodStationProvider extends ChangeNotifier {
);
}
/// loads all stations whose label contains [filter]
Future loadFilteredStations(String filter, {silent = false}) {
if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel();
@@ -61,6 +64,7 @@ class FloodStationProvider extends ChangeNotifier {
return future;
}
/// cancels loading of filtered results.
void cancelFilterLoading() {
if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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