Compare commits

...

40 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
bedd9c7d88 simple view of town names 2025-01-24 17:58:36 +01:00
86496005e0 Very simple api request 2025-01-24 17:41:24 +01:00
123136f265 cleaned up files initially created by flutter 2025-01-24 16:59:00 +01:00
48 changed files with 1280 additions and 178 deletions

View File

@@ -22,7 +22,8 @@ linter:
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
prefer_relative_imports: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

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,125 +1,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.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() {
runApp(const MyApp());
runApp(
ChangeNotifierProvider(
create: (context) => FloodStationProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
tz.initializeTimeZones();
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
title: 'Floodwatch',
theme: defaultTheme,
initialRoute: MainNavigationScaffold.routeName,
routes: {
MainNavigationScaffold.routeName: (context) => MainNavigationScaffold(),
FloodStationPage.routeName: (context) => FloodStationPage(),
AboutPage.routeName: (context) => AboutPage(),
},
);
}
}

View File

@@ -0,0 +1,47 @@
class FloodStation {
final String id;
final String town;
final double lat;
final double long;
final DateTime? dateOpened;
final String catchmentName;
final String label;
final String riverName;
FloodStation({
required this.id,
required this.town,
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(
id: json['wiskiID'] ?? '',
town: json['town'] ?? '',
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());
}
}),
);
}
}

114
lib/pages/landing_page.dart Normal file
View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../model/flood_station.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 StatefulWidget {
const LandingPage({super.key});
@override
State<LandingPage> createState() => _LandingPageState();
}
// uses mixin OverlayService to show loading overlay
class _LandingPageState extends State<LandingPage> with OverlayService {
late FloodStationProvider _floodStationProvider;
@override
void dispose() {
super.dispose();
removeLoadingNotifier(); // Clean up overlay if needed
}
@override
Widget build(BuildContext context) {
_floodStationProvider = context.watch<FloodStationProvider>();
return Column(
children: [
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(),
);

74
lib/services/api.dart Normal file
View File

@@ -0,0 +1,74 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../model/flood_station.dart';
import '../model/reading.dart';
import 'date_utility.dart';
class Api {
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'));
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 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

@@ -1,89 +1,99 @@
name: floodwatch
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.6.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
fl_chart: ^0.70.2
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:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
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
```