Compare commits

...

37 Commits

Author SHA1 Message Date
ce77237c9c updated readme 2025-01-29 15:07:20 +01:00
3bc325763a added default theme 2025-01-29 14:57:46 +01:00
0567bc61f6 added newline to text 2025-01-29 14:31:01 +01:00
6b8321d850 added fonts 2025-01-29 14:30:39 +01:00
647418748b added table view with color coded severity levels 2025-01-29 14:21:00 +01:00
c4007e2982 undid unwanted changes 2025-01-29 13:47:53 +01:00
7b7e3e9fe0 refactored code to encapsulate date formatting 2025-01-29 13:39:49 +01:00
2655779fac Changed typo in readme 2025-01-28 23:21:58 +01:00
85b4d73f4e changed readme 2025-01-28 23:19:30 +01:00
512a5cc414 Added readme.md 2025-01-28 23:17:57 +01:00
74d17c70ff added an about page 2025-01-28 23:04:37 +01:00
8fc92eab23 Implemented loading indicator for mapview 2025-01-28 22:48:33 +01:00
9175471431 explicitely typed silent variable 2025-01-28 22:44:44 +01:00
698778a7e0 changed methods from public to private 2025-01-28 22:43:02 +01:00
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
59dcaa6cb5 removed unused imports 2025-01-28 22:02:09 +01:00
8f3b3fe05e Implemented proper map popup with navigation 2025-01-28 19:21:17 +01:00
89743886af refactored code to work with simple popup when marker is clicked 2025-01-28 16:51:30 +01:00
562c85079d implemented map view with markers for every floodstation 2025-01-28 16:34:25 +01:00
68b0d5834d refactored overlay loading indicator code 2025-01-28 15:36:14 +01:00
9c9992919d Simple NavigationBar and placeholder map view 2025-01-28 15:23:16 +01:00
36dec2d0de Fixed bug - Loading Indicator now functions correctly 2025-01-27 21:08:41 +01:00
13bd344807 changed appearance of graph label 2025-01-27 19:45:19 +01:00
9323921754 using UK time instead of german time for requests 2025-01-27 19:36:28 +01:00
9e37ebbc22 Implemented way to fetch stations in batches 2025-01-27 19:14:58 +01:00
5df67920ea Implemented Loading Overlay 2025-01-27 17:10:45 +01:00
4765342ad1 Added Station Filter without function and refactored 2025-01-27 16:27:25 +01:00
81f5924df5 Used Provider-Package to encapsulate state management 2025-01-27 16:10:28 +01:00
c75d905dd8 Changed Graph Tooltip text 2025-01-27 14:47:18 +01:00
537c231253 Added ProgressIndicator and Error Message 2025-01-27 14:07:37 +01:00
fd47053834 improved graph appearance to be more clear 2025-01-27 13:57:35 +01:00
e799c3349b added more info on stations in UI 2025-01-27 12:59:20 +01:00
85e3471c87 simple graph view with sorted data 2025-01-25 02:06:25 +01:00
dfb9b59560 simple detail view of flood station and readings 2025-01-24 23:47:46 +01:00
08c266bc95 more detailed flood station info 2025-01-24 23:06:53 +01:00
47 changed files with 1225 additions and 38 deletions

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'pages/landing_page.dart'; import 'pages/about_page.dart';
import 'pages/flood_station_page.dart';
import 'pages/main_navigation_scaffold.dart';
import 'services/flood_station_provider.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'theme.dart';
void main() { void main() {
runApp(const MyApp()); runApp(
ChangeNotifierProvider(
create: (context) => FloodStationProvider(),
child: const MyApp(),
),
);
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
@@ -11,15 +23,15 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
tz.initializeTimeZones();
return MaterialApp( return MaterialApp(
title: 'Floodwatch', title: 'Floodwatch',
theme: ThemeData( theme: defaultTheme,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), initialRoute: MainNavigationScaffold.routeName,
useMaterial3: true,
),
initialRoute: LandingPage.routeName,
routes: { routes: {
LandingPage.routeName: (context) => LandingPage(), MainNavigationScaffold.routeName: (context) => MainNavigationScaffold(),
FloodStationPage.routeName: (context) => FloodStationPage(),
AboutPage.routeName: (context) => AboutPage(),
}, },
); );
} }

View File

@@ -1,17 +1,47 @@
class FloodStation { class FloodStation {
final String id; final String id;
final String town; final String town;
final double? latestReading; final double lat;
final double long;
final DateTime? dateOpened;
final String catchmentName;
final String label;
final String riverName;
FloodStation({ FloodStation({
required this.id, required this.id,
required this.town, required this.town,
this.latestReading, required this.lat,
required this.long,
this.dateOpened,
required this.catchmentName,
required this.label,
required this.riverName,
}); });
factory FloodStation.fromMap(Map<String, dynamic> json) => FloodStation( factory FloodStation.fromMap(Map<String, dynamic> json) => FloodStation(
id: json['@id'] ?? '', id: json['wiskiID'] ?? '',
town: json['town'] ?? '', town: json['town'] ?? '',
latestReading: double.tryParse(json['latestReading']?.toString() ?? ''), lat: parseDoubleValue(json['lat']),
long: parseDoubleValue(json['long']),
dateOpened: DateTime.tryParse(json['dateOpened'] ?? ''),
catchmentName: parseStringValue(json['catchmentName']),
label: parseStringValue(json['label']),
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];
return '';
}
} }

11
lib/model/reading.dart Normal file
View File

@@ -0,0 +1,11 @@
class Reading {
final DateTime dateTime;
final double value;
factory Reading.fromMap(Map<String, dynamic> json) => Reading(
dateTime: DateTime.parse(json['dateTime']),
value: json['value'],
);
Reading({required this.dateTime, required this.value});
}

30
lib/pages/about_page.dart Normal file
View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class AboutPage extends StatelessWidget {
const AboutPage({super.key});
static const String aboutText = '''
This App was build for demonstration purposes.
It requests data from the Environment Agency Real Time flood-monitoring API
and displays a list and map of all flood measurement stations,
as well as a graph showing the last 24 hours of measurements.
The source code can be found at https://git.skup.in/.
''';
static const String routeName = '/about';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('About'),
),
body: Center(
child: Text(
aboutText,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../widgets/flood_station_table.dart';
import '../widgets/reading_graph.dart';
import '../model/flood_station.dart';
import '../model/reading.dart';
import '../services/api.dart';
import '../services/flood_station_provider.dart';
class FloodStationPage extends StatefulWidget {
const FloodStationPage({super.key});
static const String routeName = '/station';
@override
State<FloodStationPage> createState() => _FloodStationPageState();
}
class _FloodStationPageState extends State<FloodStationPage> {
bool _tableVisible = false;
@override
void deactivate() {
context.read<FloodStationProvider>().selectedStation = null;
super.deactivate();
}
@override
Widget build(BuildContext context) {
final FloodStation? station =
context.read<FloodStationProvider>().selectedStation;
return Scaffold(
appBar: AppBar(
title: Text(station?.label ?? ''),
actions: [
TextButton(
onPressed: () => setState(() {
_tableVisible = !_tableVisible;
}),
child: _tableVisible ? Text('Show Graph') : Text('Show Table'),
),
],
),
body: FutureBuilder<List<Reading>>(
future: Api.fetchReadingsFromStation(station?.id ?? ''),
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return Center(child: Text('No readings on record.'));
} else if (_tableVisible) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: FloodStationTable(
readings: snapshot.data!,
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: ReadingGraph(
readings: snapshot.data!,
),
),
);
} else if (snapshot.hasError) {
return Center(child: Text('An unknown Error occured'));
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}
}

View File

@@ -1,38 +1,114 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../model/flood_station.dart'; import '../model/flood_station.dart';
import '../services/api.dart'; import '../services/overlay_service.dart';
import '../widgets/flood_station_list_view.dart';
import '../services/flood_station_provider.dart';
import '../widgets/station_filter.dart';
import 'flood_station_page.dart';
class LandingPage extends StatelessWidget { class LandingPage extends StatefulWidget {
const LandingPage({super.key}); const LandingPage({super.key});
static const routeName = '/'; @override
State<LandingPage> createState() => _LandingPageState();
}
Widget builder( // uses mixin OverlayService to show loading overlay
BuildContext context, AsyncSnapshot<List<FloodStation>> snapshot) { class _LandingPageState extends State<LandingPage> with OverlayService {
if (!snapshot.hasData) { late FloodStationProvider _floodStationProvider;
return CircularProgressIndicator();
} else if (snapshot.hasData) { @override
return ListView.builder( void dispose() {
itemBuilder: (context, index) { super.dispose();
return ListTile( removeLoadingNotifier(); // Clean up overlay if needed
title: Text(snapshot.data!.elementAt(index).town),
);
},
itemCount: snapshot.data!.length,
);
} else {
return Placeholder();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( _floodStationProvider = context.watch<FloodStationProvider>();
body: FutureBuilder<List<FloodStation>>( return Column(
future: Api.fetchStations(), children: [
builder: builder, StationFilter(
onChanged: (filterText) => _handleFilterChange(filterText),
),
_buildStationList(),
],
);
}
// 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) {
return Expanded(
child: Center(
child: ElevatedButton(
onPressed: _handleLoadAllStations,
child: const Text('Load all Stations'),
),
),
);
}
return Expanded(
child: FloodStationListView(
stations: _floodStationProvider.filtered
? _floodStationProvider.filteredStations
: _floodStationProvider.allStations,
onItemTapped: _navigateToStationDetail,
), ),
); );
} }
void _handleFilterChange(String filterText) {
if (filterText.isEmpty) {
_floodStationProvider.filtered = false;
setState(() {});
return;
}
showLoadingNotifier(
context: context,
message: 'Loading',
onDismiss: () {
_floodStationProvider.cancelFilterLoading();
},
);
_floodStationProvider.loadFilteredStations(filterText);
_floodStationProvider.filteredStationsFuture
?.then((_) => removeLoadingNotifier());
_floodStationProvider.filtered = true;
}
void _handleLoadAllStations() {
showLoadingNotifier(
context: context,
message: 'Loading',
onDismiss: () {
_floodStationProvider.cancelFilterLoading();
},
);
_floodStationProvider
.loadAllStations()
.whenComplete(() => removeLoadingNotifier());
}
void _navigateToStationDetail(FloodStation station) {
_floodStationProvider.selectedStation = station;
Navigator.of(context).pushNamed(FloodStationPage.routeName);
}
// 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;
}
} }

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'about_page.dart';
import 'landing_page.dart';
import 'map_page.dart';
class MainNavigationScaffold extends StatefulWidget {
const MainNavigationScaffold({super.key});
static const routeName = '/';
@override
State<MainNavigationScaffold> createState() => _MainNavigationScaffoldState();
}
class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
final List<Widget> _pages = [
const LandingPage(),
const MapPage(),
];
int _selectedPageIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Floodwatch'),
actions: [
IconButton(
onPressed: () =>
Navigator.of(context).pushNamed(AboutPage.routeName),
icon: Icon(Icons.info_outline),
)
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedPageIndex,
onDestinationSelected: (value) => setState(() {
_selectedPageIndex = value;
}),
destinations: [
NavigationDestination(icon: Icon(Icons.list), label: 'List'),
NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Map'),
],
),
// Used IndexedStack to save the page state while navigating
body: IndexedStack(
index: _selectedPageIndex,
children: _pages,
),
);
}
}

127
lib/pages/map_page.dart Normal file
View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart';
import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../model/flood_station.dart';
import '../services/flood_station_provider.dart';
import '../widgets/custom_marker.dart';
import '../widgets/map_popup.dart';
import 'flood_station_page.dart';
class MapPage extends StatefulWidget {
const MapPage({super.key});
static const routeName = '/map';
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final _mapController = MapController();
late FloodStationProvider _floodStationProvider;
bool _loading = false;
@override
Widget build(BuildContext context) {
_floodStationProvider = context.watch<FloodStationProvider>();
if (_loading == true) {
return Center(
child: CircularProgressIndicator(),
);
} else if (_floodStationProvider.allStations.isEmpty) {
return Center(
child: ElevatedButton(
onPressed: () {
setState(() {
_loading = true;
});
_floodStationProvider
.loadAllStations()
.whenComplete(() => setState(() {
_loading = false;
}));
},
child: Text('Load Map'),
),
);
}
return FlutterMap(
mapController: _mapController,
options: MapOptions(
cameraConstraint: CameraConstraint.containCenter(
bounds: LatLngBounds.fromPoints(_floodStationProvider.allStations
.map<LatLng>(
(e) => LatLng(e.lat, e.long),
)
.toList())),
initialCenter: LatLng(54.81, -4.42),
initialZoom: 6,
),
children: [
_openStreetMapTileLayer,
MarkerClusterLayerWidget(
options: MarkerClusterLayerOptions(
maxClusterRadius: 45,
size: Size(30, 30),
alignment: Alignment.center,
padding: EdgeInsets.all(50),
maxZoom: 15,
markers: _stationsAsMarkers(_floodStationProvider.allStations),
builder: _clusterMarkerBuilder),
)
],
);
}
// builds the clustered marker
Widget _clusterMarkerBuilder(BuildContext context, List<Marker> markers) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.tertiary,
),
child: Center(
child: Text(
markers.length.toString(),
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>(
(station) => Marker(
alignment: Alignment.center,
point: LatLng(station.lat, station.long),
child: CustomMarker(
onTap: () => _markerTapped(station),
),
),
)
.toList();
}
void _markerTapped(FloodStation station) {
showDialog(
context: context,
builder: (context) => MapPopup(
station: station,
onShowTapped: () {
_floodStationProvider.selectedStation = station;
Navigator.of(context).pushNamed(FloodStationPage.routeName);
}),
);
}
}
TileLayer get _openStreetMapTileLayer => TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'dev.fleaflet.flutter_map.example',
tileProvider: CancellableNetworkTileProvider(),
);

View File

@@ -2,12 +2,17 @@ import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import '../model/flood_station.dart'; import '../model/flood_station.dart';
import '../model/reading.dart';
import 'date_utility.dart';
class Api { class Api {
Api._();
static const String _rootUrl = static const String _rootUrl =
'https://environment.data.gov.uk/flood-monitoring'; 'https://environment.data.gov.uk/flood-monitoring';
static Future<List<FloodStation>> fetchStations() async { /// Fetches all stationszt
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'));
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -18,4 +23,52 @@ class Api {
} }
return stations; return stations;
} }
/// Fetches all stations whose label contain [label]
static Future<List<FloodStation>> fetchFilteredStations(String label) async {
List<FloodStation> stations = [];
final response =
await http.get(Uri.parse('$_rootUrl/id/stations?search=$label'));
if (response.statusCode == 200) {
final Map<String, dynamic> jsonStr = jsonDecode(response.body);
for (final str in jsonStr['items']) {
stations.add(FloodStation.fromMap(str));
}
}
return stations;
}
/// [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 = [];
final response = await http
.get(Uri.parse('$_rootUrl/id/stations?_limit=$limit&_offset=$offset'));
if (response.statusCode == 200) {
final Map<String, dynamic> jsonStr = jsonDecode(response.body);
for (final str in jsonStr['items']) {
stations.add(FloodStation.fromMap(str));
}
}
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 = [];
final dateTime =
DateUtility.currentUKTimeUtc.subtract(Duration(days: 1)).toUtc();
final url =
'$_rootUrl/id/stations/$stationId/readings?since=${dateTime.toIso8601String()}&_sorted';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final Map<String, dynamic> jsonStr = jsonDecode(response.body);
for (final str in jsonStr['items']) {
readings.add(Reading.fromMap(str));
}
}
return readings.reversed.toList();
}
} }

View File

@@ -0,0 +1,37 @@
import 'package:timezone/timezone.dart' as tz;
import 'package:intl/intl.dart' as intl;
class DateUtility {
static final intl.DateFormat _hmFormat = intl.DateFormat('Hm');
static final intl.DateFormat _ymdhmFormat =
intl.DateFormat('yyyy-MM-dd HH:mm');
static final intl.DateFormat _ymdFormat = intl.DateFormat('yyyy-MM-dd');
// private default contructor so class can't be instanciated
DateUtility._();
static DateTime get currentUKTimeUtc {
final london = tz.getLocation('Europe/London');
return tz.TZDateTime.now(london).toUtc();
}
/// Formats a date in minutesSinceEpoch to a formatted String of HH:mm
static String formatMinutesToHm(double value) {
return _hmFormat.format(
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt()));
}
static String formatDateToHm(DateTime date) {
return _hmFormat.format(date);
}
/// Formats a date to yyyy-MM-dd HH:mm
static String formatDateToYmdhm(DateTime date) {
return _ymdhmFormat.format(date);
}
/// Formats a date to yyyy-MM-dd
static String formatDateToYmd(DateTime date) {
return _ymdFormat.format(date);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
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? 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({bool silent = false}) {
int offset = 0;
return Future.doWhile(() async {
final stations = await Api.fetchStationsByRange(500, offset);
if (stations.isNotEmpty) {
_allStations.addAll(stations);
if (!silent) {
notifyListeners();
}
offset += 500;
return true;
} else {
return false;
}
});
}
/// loads all flood stations and notifies listeners when done
Future loadAllStations({bool silent = false}) {
return Api.fetchAllStations().then(
(value) {
_allStations = value;
if (!silent) {
notifyListeners();
}
},
);
}
/// loads all stations whose label contains [filter]
Future loadFilteredStations(String filter, {bool silent = false}) {
if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel();
}
final future = Api.fetchFilteredStations(filter);
_filteredStationsFuture = CancelableOperation.fromFuture(future).then(
(value) {
_filteredStations = value;
if (!silent) {
notifyListeners();
}
},
);
return future;
}
/// cancels loading of filtered results.
void cancelFilterLoading() {
if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel();
}
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../widgets/loading_notifier.dart';
mixin OverlayService {
OverlayEntry? _overlayEntry;
Future<void> showLoadingNotifier({
required BuildContext context,
required String message,
required VoidCallback onDismiss,
}) async {
if (_overlayEntry != null) return;
final overlayState = Overlay.of(context);
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
bottom: 85, // Positioned above the NavigationBar
left: 0,
right: 0,
child: Center(
child: LoadingNotifier(
message: message,
onDismissed: () {
onDismiss();
removeLoadingNotifier();
},
),
),
),
);
overlayState.insert(_overlayEntry!);
// overlayState.setState(() {});
}
void removeLoadingNotifier() {
if (_overlayEntry != null) {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
void dispose() {
removeLoadingNotifier();
}
}

35
lib/theme.dart Normal file
View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
ThemeData get defaultTheme {
return ThemeData(
fontFamily: 'UbuntuSansMono',
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.amber,
brightness: Brightness.light,
),
textTheme: _defaultTextTheme,
);
}
ThemeData get defaultDarkTheme {
return ThemeData(
fontFamily: 'UbuntuSansMono',
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.lightGreen,
brightness: Brightness.dark,
),
textTheme: _defaultTextTheme,
);
}
TextTheme get _defaultTextTheme => TextTheme(
titleLarge: TextStyle(
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
fontWeight: FontWeight.w800,
),
titleSmall: TextStyle(
fontWeight: FontWeight.w800,
),
);

View File

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

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../model/flood_station.dart';
class FloodStationListView extends StatelessWidget {
const FloodStationListView(
{super.key,
required List<FloodStation> stations,
required void Function(FloodStation) onItemTapped})
: _onItemTapped = onItemTapped,
_stations = stations;
final List<FloodStation> _stations;
final void Function(FloodStation) _onItemTapped;
@override
Widget build(BuildContext context) {
return ListView.separated(
separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: Divider(),
),
itemBuilder: (context, index) {
final item = _stations.elementAt(index);
return ListTile(
isThreeLine: true,
onTap: () => _onItemTapped(item),
title: Text(item.label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.home_outlined),
Padding(padding: EdgeInsets.only(left: 8)),
Text(item.town),
],
),
Row(
children: [
Icon(Icons.water),
Padding(padding: EdgeInsets.only(left: 8)),
Text(item.riverName),
],
)
],
),
);
},
itemCount: _stations.length,
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../model/reading.dart';
import '../services/date_utility.dart';
class FloodStationTable extends StatelessWidget {
const FloodStationTable({super.key, required List<Reading> readings})
: _readings = readings;
final List<Reading> _readings;
List<TableRow> get _children {
return _readings.map<TableRow>((e) => _getTableRow(e)).toList();
}
TableRow _getTableRow(Reading reading) {
String date = DateUtility.formatDateToYmd(reading.dateTime);
String time = DateUtility.formatDateToHm(reading.dateTime);
return TableRow(
decoration: BoxDecoration(),
children: [
Row(
children: [
Text(
date,
style: TextStyle(
fontSize: 16,
),
),
Padding(padding: EdgeInsets.only(right: 12)),
Text(
time,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
Text(
reading.value.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
backgroundColor: _getColorFromSeverity(reading.value),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Table(
children: _children,
columnWidths: {
0: FixedColumnWidth(160),
1: FlexColumnWidth(),
},
);
}
Color _getColorFromSeverity(double value) {
if (value < 1.0) return Color.fromARGB(0, 0, 0, 0);
if (value < 2.0) return Colors.yellow;
if (value < 3.0) return Colors.orange;
return Colors.red;
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class LoadingNotifier extends StatelessWidget {
const LoadingNotifier(
{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) {
return Card(
color: Theme.of(context).colorScheme.primaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(45)),
child: SizedBox(
width: 500,
height: 60,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
SizedBox(
width: 25,
height: 25,
child: CircularProgressIndicator(),
),
Text(
_message,
style: Theme.of(context).textTheme.titleSmall,
),
IconButton(
onPressed: () => _onDismissed(),
icon: Icon(Icons.close),
)
],
),
),
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../model/flood_station.dart';
import '../services/date_utility.dart';
class MapPopup extends StatelessWidget {
const MapPopup(
{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),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.home_outlined),
Padding(padding: EdgeInsets.only(left: 8)),
Text(_station.town.isEmpty ? '-' : _station.town),
],
),
Row(
children: [
Icon(Icons.water),
Padding(padding: EdgeInsets.only(left: 8)),
Text(_station.riverName.isEmpty ? '-' : _station.riverName),
],
),
Row(
children: [
Icon(Icons.calendar_month_outlined),
Padding(padding: EdgeInsets.only(left: 8)),
Text(
_station.dateOpened != null
? DateUtility.formatDateToYmd(_station.dateOpened!)
: '-',
),
],
)
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('dismiss'),
),
TextButton(
onPressed: _onShowTapped,
child: Text('show'),
),
],
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'dart:math';
import '../model/reading.dart';
import '../services/date_utility.dart';
class ReadingGraph extends StatelessWidget {
const ReadingGraph({super.key, required List<Reading> readings})
: _readings = readings;
final List<Reading> _readings;
@override
Widget build(BuildContext context) {
final spots = _readings
.map<FlSpot>(
(e) => FlSpot(
e.dateTime.millisecondsSinceEpoch.toDouble() / 1000 / 60,
e.value),
)
.toList();
return LineChart(
LineChartData(
maxY: _readings.map<double>((e) => e.value).reduce(max) + 0.05,
minY: _readings.map<double>((e) => e.value).reduce(min) - 0.05,
lineBarsData: [
LineChartBarData(
isCurved: true,
color: Theme.of(context).colorScheme.primary,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
spots: _readings
.map<FlSpot>(
(e) => FlSpot(
e.dateTime.millisecondsSinceEpoch.toDouble() / 1000 / 60,
e.value),
)
.toList(),
)
],
titlesData: FlTitlesData(
bottomTitles: _getBottomTitles(spots),
leftTitles: _getLeftTitles(spots),
topTitles: const AxisTitles(sideTitles: SideTitles()),
rightTitles: const AxisTitles(sideTitles: SideTitles()),
),
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) =>
Theme.of(context).colorScheme.primaryContainer,
getTooltipItems: (touchedSpots) {
return touchedSpots.map((touchedSpot) {
return LineTooltipItem(
'${touchedSpot.y.toString()}\n${_getLongDate(touchedSpot.x)}',
TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold));
}).toList();
},
),
),
),
);
}
AxisTitles _getBottomTitles(List<FlSpot> spots) {
return AxisTitles(
sideTitles: SideTitles(
interval: 90,
reservedSize: 40,
showTitles: true,
maxIncluded: false,
minIncluded: false,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
child: Text(DateUtility.formatMinutesToHm(value)),
),
),
);
}
AxisTitles _getLeftTitles(List<FlSpot> spots) {
return AxisTitles(
sideTitles: SideTitles(
reservedSize: 50,
showTitles: true,
maxIncluded: false,
minIncluded: false,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
child: Text(value.toStringAsFixed(2)),
),
),
);
}
String _getLongDate(double value) {
DateTime date =
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt());
int daysDifference = (DateTime.now().weekday - date.weekday + 7) % 7;
if (daysDifference == 0) {
return 'Today ${DateUtility.formatDateToHm(date)}';
} else if (daysDifference == 1) {
return 'Yesterday ${DateUtility.formatDateToHm(date)}';
}
return DateUtility.formatDateToYmdhm(date);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class StationFilter extends StatefulWidget {
const StationFilter({super.key, required this.onChanged});
final void Function(String filterText) onChanged;
@override
State<StationFilter> createState() => StationFilterState();
}
class StationFilterState extends State<StationFilter> {
final TextEditingController _filterController = TextEditingController();
@override
Widget build(BuildContext context) {
return TextField(
controller: _filterController,
decoration: InputDecoration(
prefixIcon: Icon(Icons.search),
suffixIcon: Opacity(
opacity: _filterController.text.isEmpty ? 0 : 1,
child: IconButton(
onPressed: () {
_filterController.clear();
widget.onChanged(_filterController.text);
},
icon: Icon(Icons.delete),
),
),
label: Text('Filter'),
),
onChanged: (_) => widget.onChanged(_filterController.text),
);
}
}

View File

@@ -13,6 +13,14 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
fl_chart: ^0.70.2 fl_chart: ^0.70.2
http: ^1.3.0 http: ^1.3.0
intl: ^0.20.2
provider: ^6.1.2
timezone: ^0.10.0
async: ^2.11.0
flutter_map: ^7.0.2
flutter_map_cancellable_tile_provider: ^3.0.2
latlong2: ^0.9.1
flutter_map_marker_cluster: ^1.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -22,5 +30,70 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
fonts:
- family: UbuntuSans
fonts:
- asset: fonts/ubuntu_sans/UbuntuSans-Thin.ttf
weight: 100
- asset: fonts/ubuntu_sans/UbuntuSans-ThinItalic.ttf
weight: 100
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-ExtraLight.ttf
weight: 200
- asset: fonts/ubuntu_sans/UbuntuSans-ExtraLightItalic.ttf
weight: 200
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-Light.ttf
weight: 300
- asset: fonts/ubuntu_sans/UbuntuSans-LightItalic.ttf
weight: 300
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-Regular.ttf
weight: 400
- asset: fonts/ubuntu_sans/UbuntuSans-Italic.ttf
weight: 400
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-Medium.ttf
weight: 500
- asset: fonts/ubuntu_sans/UbuntuSans-MediumItalic.ttf
weight: 500
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-SemiBold.ttf
weight: 600
- asset: fonts/ubuntu_sans/UbuntuSans-SemiBoldItalic.ttf
weight: 600
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-Bold.ttf
weight: 700
- asset: fonts/ubuntu_sans/UbuntuSans-BoldItalic.ttf
weight: 700
style: italic
- asset: fonts/ubuntu_sans/UbuntuSans-ExtraBold.ttf
weight: 800
- asset: fonts/ubuntu_sans/UbuntuSans-ExtraBoldItalic.ttf
weight: 800
style: italic
- family: UbuntuSansMono
fonts:
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-Regular.ttf
weight: 400
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-Italic.ttf
weight: 400
style: italic
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-Medium.ttf
weight: 500
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-MediumItalic.ttf
weight: 500
style: italic
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBold.ttf
weight: 600
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBoldItalic.ttf
weight: 600
style: italic
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-Bold.ttf
weight: 700
- asset: fonts/ubuntu_sans_mono/UbuntuSansMono-BoldItalic.ttf
weight: 700
style: italic

47
readme.md Normal file
View File

@@ -0,0 +1,47 @@
# Floodwatch
This App was build for demonstration purposes.
It requests data from the Environment Agency Real Time flood-monitoring API and displays a list and map of all flood measurement stations,
as well as a graph showing the last 24 hours of measurements.
## Features
### Interactive Map View
- Uses OpenStreetMap to display all flood monitoring stations
### Station List
- Complete list of all flood monitoring stations
- Filtering by station label
### Detailed Station View
- Line graphs showing water levels over the past 24 hours
- Table showing all values with colour-coding to show severity
## Third-Party Packages used
- **provider**: State management solution for handling station data
- **flutter_map**: Integration with OpenStreetMap for the map view
- **fl_chart**: Creating line graphs for water levels
## Installation
1. Clone the repository:
```bash
git clone https://git.skup.in/marco/floodwatch
```
2. Install dependencies:
```bash
flutter pub get
```
3. Run the app:
```bash
flutter run
```
The App has been tested on Linux and Web and can be tested on android, too.
## Building release version
```bash
flutter build
```