Compare commits

..

20 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
45 changed files with 545 additions and 109 deletions

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,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'pages/about_page.dart';
import 'pages/flood_station_page.dart';
import 'pages/landing_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(
ChangeNotifierProvider(
@@ -24,14 +26,12 @@ class MyApp extends StatelessWidget {
tz.initializeTimeZones();
return MaterialApp(
title: 'Floodwatch',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
theme: defaultTheme,
initialRoute: MainNavigationScaffold.routeName,
routes: {
MainNavigationScaffold.routeName: (context) => MainNavigationScaffold(),
FloodStationPage.routeName: (context) => FloodStationPage(),
AboutPage.routeName: (context) => AboutPage(),
},
);
}

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];

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

@@ -1,6 +1,7 @@
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';
@@ -16,6 +17,8 @@ class FloodStationPage extends StatefulWidget {
}
class _FloodStationPageState extends State<FloodStationPage> {
bool _tableVisible = false;
@override
void deactivate() {
context.read<FloodStationProvider>().selectedStation = null;
@@ -29,6 +32,14 @@ class _FloodStationPageState extends State<FloodStationPage> {
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 ?? ''),
@@ -36,6 +47,15 @@ class _FloodStationPageState extends State<FloodStationPage> {
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(

View File

@@ -5,7 +5,6 @@ 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/loading_notifier.dart';
import '../widgets/station_filter.dart';
import 'flood_station_page.dart';
@@ -16,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() {
@@ -27,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(
@@ -38,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(
@@ -52,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,
),
);
@@ -62,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;
}
@@ -71,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() {
@@ -86,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

@@ -1,10 +1,12 @@
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
@@ -12,16 +14,26 @@ 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(
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(() {
@@ -32,6 +44,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

@@ -5,7 +5,11 @@ 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});
@@ -16,27 +20,48 @@ class MapPage extends StatefulWidget {
}
class _MapPageState extends State<MapPage> {
final mapController = MapController();
final _mapController = MapController();
late FloodStationProvider _floodStationProvider;
bool _loading = false;
@override
Widget build(BuildContext context) {
final floodStationProvider = context.watch<FloodStationProvider>();
if (floodStationProvider.allStations.isEmpty) {
_floodStationProvider = context.watch<FloodStationProvider>();
if (_loading == true) {
return Center(
child: CircularProgressIndicator(),
);
} else if (_floodStationProvider.allStations.isEmpty) {
return Center(
child: ElevatedButton(
onPressed: floodStationProvider.loadAllStations,
onPressed: () {
setState(() {
_loading = true;
});
_floodStationProvider
.loadAllStations()
.whenComplete(() => setState(() {
_loading = false;
}));
},
child: Text('Load Map'),
),
);
}
return FlutterMap(
mapController: mapController,
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,
_openStreetMapTileLayer,
MarkerClusterLayerWidget(
options: MarkerClusterLayerOptions(
maxClusterRadius: 45,
@@ -44,14 +69,15 @@ class _MapPageState extends State<MapPage> {
alignment: Alignment.center,
padding: EdgeInsets.all(50),
maxZoom: 15,
markers: floodStationProvider.stationsAsMarkers,
builder: _markerBuilder),
markers: _stationsAsMarkers(_floodStationProvider.allStations),
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),
@@ -60,14 +86,41 @@ 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>(
(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(
TileLayer get _openStreetMapTileLayer => TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'dev.fleaflet.flutter_map.example',
tileProvider: CancellableNetworkTileProvider(),

View File

@@ -1,14 +1,17 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:timezone/timezone.dart' as tz;
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'));
@@ -37,7 +40,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,10 +54,12 @@ 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 = [];
final dateTime = _getCurrentUKTime().subtract(Duration(days: 1)).toUtc();
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));
@@ -67,9 +71,4 @@ class Api {
}
return readings.reversed.toList();
}
static DateTime _getCurrentUKTime() {
final london = tz.getLocation('Europe/London');
return tz.TZDateTime.now(london);
}
}

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

@@ -1,38 +1,24 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../model/flood_station.dart';
import '../widgets/custom_marker.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;
List<Marker> get stationsAsMarkers {
return allStations
.map<Marker>(
(e) => Marker(
alignment: Alignment.center,
point: LatLng(e.lat, e.long),
child: CustomMarker(),
),
)
.toList();
}
CancelableOperation? _filteredStationsFuture;
CancelableOperation? get filteredStationsFuture => _filteredStationsFuture;
Future loadAllStationsInBatches({silent = false}) {
/// 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);
@@ -49,7 +35,8 @@ class FloodStationProvider extends ChangeNotifier {
});
}
Future loadAllStations({silent = false}) {
/// loads all flood stations and notifies listeners when done
Future loadAllStations({bool silent = false}) {
return Api.fetchAllStations().then(
(value) {
_allStations = value;
@@ -60,7 +47,8 @@ class FloodStationProvider extends ChangeNotifier {
);
}
Future loadFilteredStations(String filter, {silent = false}) {
/// loads all stations whose label contains [filter]
Future loadFilteredStations(String filter, {bool silent = false}) {
if (_filteredStationsFuture != null) {
_filteredStationsFuture!.cancel();
}
@@ -76,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(

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

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

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

@@ -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

@@ -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

@@ -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

@@ -1,9 +1,9 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'dart:math';
import '../model/reading.dart';
import '../services/date_utility.dart';
class ReadingGraph extends StatelessWidget {
const ReadingGraph({super.key, required List<Reading> readings})
@@ -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),
@@ -41,19 +41,21 @@ class ReadingGraph extends StatelessWidget {
)
],
titlesData: FlTitlesData(
bottomTitles: getBottomTitles(spots),
leftTitles: getLeftTitles(spots),
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)}',
'${touchedSpot.y.toString()}\n${_getLongDate(touchedSpot.x)}',
TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold));
}).toList();
},
@@ -63,7 +65,7 @@ class ReadingGraph extends StatelessWidget {
);
}
AxisTitles getBottomTitles(List<FlSpot> spots) {
AxisTitles _getBottomTitles(List<FlSpot> spots) {
return AxisTitles(
sideTitles: SideTitles(
interval: 90,
@@ -73,13 +75,13 @@ class ReadingGraph extends StatelessWidget {
minIncluded: false,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
child: Text(getDate(value)),
child: Text(DateUtility.formatMinutesToHm(value)),
),
),
);
}
AxisTitles getLeftTitles(List<FlSpot> spots) {
AxisTitles _getLeftTitles(List<FlSpot> spots) {
return AxisTitles(
sideTitles: SideTitles(
reservedSize: 50,
@@ -94,23 +96,17 @@ class ReadingGraph extends StatelessWidget {
);
}
String getDate(double value) {
intl.DateFormat hmFormat = intl.DateFormat('Hm');
return hmFormat.format(
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt()));
}
String getLongDate(double value) {
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 ${intl.DateFormat('Hm').format(date)}';
return 'Today ${DateUtility.formatDateToHm(date)}';
} else if (daysDifference == 1) {
return 'Yesterday ${intl.DateFormat('Hm').format(date)}';
return 'Yesterday ${DateUtility.formatDateToHm(date)}';
}
return intl.DateFormat('yyyy-MM-dd H:m').format(date);
return DateUtility.formatDateToYmdhm(date);
}
}

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),
);
}
}

View File

@@ -30,5 +30,70 @@ dev_dependencies:
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
```