Compare commits
25 Commits
9323921754
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce77237c9c | |||
| 3bc325763a | |||
| 0567bc61f6 | |||
| 6b8321d850 | |||
| 647418748b | |||
| c4007e2982 | |||
| 7b7e3e9fe0 | |||
| 2655779fac | |||
| 85b4d73f4e | |||
| 512a5cc414 | |||
| 74d17c70ff | |||
| 8fc92eab23 | |||
| 9175471431 | |||
| 698778a7e0 | |||
| 8f5ed07be9 | |||
| 6828a9a14b | |||
| 7a45bab7c1 | |||
| 59dcaa6cb5 | |||
| 8f3b3fe05e | |||
| 89743886af | |||
| 562c85079d | |||
| 68b0d5834d | |||
| 9c9992919d | |||
| 36dec2d0de | |||
| 13bd344807 |
BIN
fonts/ubuntu_sans/UbuntuSans-Bold.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-BoldItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraBold.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraBoldItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraLight.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraLightItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-Italic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-Light.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Light.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-LightItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-LightItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-Medium.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Medium.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-MediumItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-Regular.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-SemiBold.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-SemiBoldItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-Thin.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-Thin.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans/UbuntuSans-ThinItalic.ttf
Normal file
BIN
fonts/ubuntu_sans/UbuntuSans-ThinItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Bold.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Bold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-BoldItalic.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Italic.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Medium.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Medium.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-MediumItalic.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Regular.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBold.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBoldItalic.ttf
Normal file
BIN
fonts/ubuntu_sans_mono/UbuntuSansMono-SemiBoldItalic.ttf
Normal file
Binary file not shown.
@@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'pages/about_page.dart';
|
||||||
import 'pages/flood_station_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 'services/flood_station_provider.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tz;
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
|
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
@@ -23,14 +26,12 @@ class MyApp extends StatelessWidget {
|
|||||||
tz.initializeTimeZones();
|
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(),
|
FloodStationPage.routeName: (context) => FloodStationPage(),
|
||||||
|
AboutPage.routeName: (context) => AboutPage(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ class FloodStation {
|
|||||||
riverName: parseStringValue(json['riverName']),
|
riverName: parseStringValue(json['riverName']),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// sometimes the API returns a String instead of a double
|
||||||
static double parseDoubleValue(dynamic value) {
|
static double parseDoubleValue(dynamic value) {
|
||||||
if (value is double) return value;
|
if (value is double) return value;
|
||||||
if (value is String) return double.parse(value);
|
if (value is String) return double.parse(value);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sometimes the API returns a list of labels that are basically identical
|
||||||
|
/// if [value] is a List, the method return the first item
|
||||||
static String parseStringValue(dynamic value) {
|
static String parseStringValue(dynamic value) {
|
||||||
if (value is String) return value;
|
if (value is String) return value;
|
||||||
if (value is List<dynamic>) return value[0];
|
if (value is List<dynamic>) return value[0];
|
||||||
|
|||||||
30
lib/pages/about_page.dart
Normal file
30
lib/pages/about_page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../widgets/flood_station_table.dart';
|
||||||
import '../widgets/reading_graph.dart';
|
import '../widgets/reading_graph.dart';
|
||||||
import '../model/flood_station.dart';
|
import '../model/flood_station.dart';
|
||||||
import '../model/reading.dart';
|
import '../model/reading.dart';
|
||||||
@@ -16,6 +17,8 @@ class FloodStationPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FloodStationPageState extends State<FloodStationPage> {
|
class _FloodStationPageState extends State<FloodStationPage> {
|
||||||
|
bool _tableVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void deactivate() {
|
void deactivate() {
|
||||||
context.read<FloodStationProvider>().selectedStation = null;
|
context.read<FloodStationProvider>().selectedStation = null;
|
||||||
@@ -29,6 +32,14 @@ class _FloodStationPageState extends State<FloodStationPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(station?.label ?? ''),
|
title: Text(station?.label ?? ''),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_tableVisible = !_tableVisible;
|
||||||
|
}),
|
||||||
|
child: _tableVisible ? Text('Show Graph') : Text('Show Table'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: FutureBuilder<List<Reading>>(
|
body: FutureBuilder<List<Reading>>(
|
||||||
future: Api.fetchReadingsFromStation(station?.id ?? ''),
|
future: Api.fetchReadingsFromStation(station?.id ?? ''),
|
||||||
@@ -36,6 +47,15 @@ class _FloodStationPageState extends State<FloodStationPage> {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
if (snapshot.data!.isEmpty) {
|
if (snapshot.data!.isEmpty) {
|
||||||
return Center(child: Text('No readings on record.'));
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -1,89 +1,114 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../model/flood_station.dart';
|
||||||
|
import '../services/overlay_service.dart';
|
||||||
import '../widgets/flood_station_list_view.dart';
|
import '../widgets/flood_station_list_view.dart';
|
||||||
import '../services/flood_station_provider.dart';
|
import '../services/flood_station_provider.dart';
|
||||||
import '../widgets/loading_notifier.dart';
|
|
||||||
import '../widgets/station_filter.dart';
|
import '../widgets/station_filter.dart';
|
||||||
import 'flood_station_page.dart';
|
import 'flood_station_page.dart';
|
||||||
|
|
||||||
class LandingPage extends StatefulWidget {
|
class LandingPage extends StatefulWidget {
|
||||||
const LandingPage({super.key});
|
const LandingPage({super.key});
|
||||||
|
|
||||||
static const routeName = '/';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LandingPage> createState() => _LandingPageState();
|
State<LandingPage> createState() => _LandingPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LandingPageState extends State<LandingPage> {
|
// uses mixin OverlayService to show loading overlay
|
||||||
late FloodStationProvider floodStationProvider;
|
class _LandingPageState extends State<LandingPage> with OverlayService {
|
||||||
bool _isLoading = false;
|
late FloodStationProvider _floodStationProvider;
|
||||||
OverlayEntry? _overlayEntry;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
initState() {
|
void dispose() {
|
||||||
super.initState();
|
super.dispose();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
removeLoadingNotifier(); // Clean up overlay if needed
|
||||||
_isLoading = true;
|
|
||||||
showLoadingNotifier(context: context, message: 'Loading');
|
|
||||||
floodStationProvider
|
|
||||||
.loadAllStations()
|
|
||||||
.whenComplete(() => removeLoadingNotifier());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
floodStationProvider = context.watch<FloodStationProvider>();
|
_floodStationProvider = context.watch<FloodStationProvider>();
|
||||||
return Scaffold(
|
return Column(
|
||||||
body: Column(
|
|
||||||
children: [
|
children: [
|
||||||
StationFilter(
|
StationFilter(
|
||||||
onEditingComplete: (filterText) {},
|
onChanged: (filterText) => _handleFilterChange(filterText),
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: FloodStationListView(
|
|
||||||
stations: floodStationProvider.allStations,
|
|
||||||
onItemTapped: (station) {
|
|
||||||
floodStationProvider.selectedStation = station;
|
|
||||||
Navigator.of(context).pushNamed(FloodStationPage.routeName);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
_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'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showLoadingNotifier({
|
return Expanded(
|
||||||
required BuildContext context,
|
child: FloodStationListView(
|
||||||
required String message,
|
stations: _floodStationProvider.filtered
|
||||||
}) async {
|
? _floodStationProvider.filteredStations
|
||||||
OverlayState? overlayState = Overlay.of(context);
|
: _floodStationProvider.allStations,
|
||||||
_overlayEntry = OverlayEntry(
|
onItemTapped: _navigateToStationDetail,
|
||||||
builder: (c) {
|
|
||||||
return Positioned(
|
|
||||||
bottom: 16,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(
|
|
||||||
child: LoadingNotifier(
|
|
||||||
message: 'Loading',
|
|
||||||
onDismissed: () => removeLoadingNotifier(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFilterChange(String filterText) {
|
||||||
|
if (filterText.isEmpty) {
|
||||||
|
_floodStationProvider.filtered = false;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoadingNotifier(
|
||||||
|
context: context,
|
||||||
|
message: 'Loading',
|
||||||
|
onDismiss: () {
|
||||||
|
_floodStationProvider.cancelFilterLoading();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
overlayState.insert(_overlayEntry!);
|
|
||||||
overlayState.setState(() {});
|
_floodStationProvider.loadFilteredStations(filterText);
|
||||||
|
_floodStationProvider.filteredStationsFuture
|
||||||
|
?.then((_) => removeLoadingNotifier());
|
||||||
|
_floodStationProvider.filtered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeLoadingNotifier() {
|
void _handleLoadAllStations() {
|
||||||
if (_overlayEntry != null) {
|
showLoadingNotifier(
|
||||||
_overlayEntry?.remove();
|
context: context,
|
||||||
_overlayEntry = null;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
lib/pages/main_navigation_scaffold.dart
Normal file
54
lib/pages/main_navigation_scaffold.dart
Normal 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
127
lib/pages/map_page.dart
Normal 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(),
|
||||||
|
);
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
|
||||||
|
|
||||||
import '../model/flood_station.dart';
|
import '../model/flood_station.dart';
|
||||||
import '../model/reading.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';
|
||||||
|
|
||||||
|
/// Fetches all stationszt
|
||||||
static Future<List<FloodStation>> fetchAllStations() async {
|
static Future<List<FloodStation>> fetchAllStations() async {
|
||||||
List<FloodStation> stations = [];
|
List<FloodStation> stations = [];
|
||||||
final response = await http.get(Uri.parse('$_rootUrl/id/stations'));
|
final response = await http.get(Uri.parse('$_rootUrl/id/stations'));
|
||||||
@@ -21,9 +24,22 @@ 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
|
/// [limit] limits the number of entries that are requested from the API and [offset] returns the
|
||||||
/// list starting from the specified number
|
/// list starting from the specified number
|
||||||
|
|
||||||
static Future<List<FloodStation>> fetchStationsByRange(
|
static Future<List<FloodStation>> fetchStationsByRange(
|
||||||
int limit, int offset) async {
|
int limit, int offset) async {
|
||||||
List<FloodStation> stations = [];
|
List<FloodStation> stations = [];
|
||||||
@@ -38,10 +54,12 @@ class Api {
|
|||||||
return stations;
|
return stations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches all readings from the station with the specified [stationId] from the last 24h
|
||||||
static Future<List<Reading>> fetchReadingsFromStation(
|
static Future<List<Reading>> fetchReadingsFromStation(
|
||||||
String stationId) async {
|
String stationId) async {
|
||||||
List<Reading> readings = [];
|
List<Reading> readings = [];
|
||||||
final dateTime = _getCurrentUKTime().subtract(Duration(days: 1)).toUtc();
|
final dateTime =
|
||||||
|
DateUtility.currentUKTimeUtc.subtract(Duration(days: 1)).toUtc();
|
||||||
final url =
|
final url =
|
||||||
'$_rootUrl/id/stations/$stationId/readings?since=${dateTime.toIso8601String()}&_sorted';
|
'$_rootUrl/id/stations/$stationId/readings?since=${dateTime.toIso8601String()}&_sorted';
|
||||||
final response = await http.get(Uri.parse(url));
|
final response = await http.get(Uri.parse(url));
|
||||||
@@ -53,9 +71,4 @@ class Api {
|
|||||||
}
|
}
|
||||||
return readings.reversed.toList();
|
return readings.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static DateTime _getCurrentUKTime() {
|
|
||||||
final london = tz.getLocation('Europe/London');
|
|
||||||
return tz.TZDateTime.now(london);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
lib/services/date_utility.dart
Normal file
37
lib/services/date_utility.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:async/async.dart';
|
||||||
import '../model/flood_station.dart';
|
import '../model/flood_station.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
|
||||||
class FloodStationProvider extends ChangeNotifier {
|
class FloodStationProvider extends ChangeNotifier {
|
||||||
|
// since the getter and setter for the following two fields would be empty, they are publicly accessible
|
||||||
FloodStation? selectedStation;
|
FloodStation? selectedStation;
|
||||||
|
bool filtered = false;
|
||||||
|
|
||||||
List<FloodStation> _allStations = [];
|
List<FloodStation> _allStations = [];
|
||||||
|
List<FloodStation> _filteredStations = [];
|
||||||
|
CancelableOperation? _filteredStationsFuture;
|
||||||
|
|
||||||
List<FloodStation> get allStations => _allStations;
|
List<FloodStation> get allStations => _allStations;
|
||||||
|
List<FloodStation> get filteredStations => _filteredStations;
|
||||||
|
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;
|
int offset = 0;
|
||||||
return Future.doWhile(() async {
|
return Future.doWhile(() async {
|
||||||
final stations = await Api.fetchStationsByRange(500, offset);
|
final stations = await Api.fetchStationsByRange(500, offset);
|
||||||
@@ -27,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(
|
return Api.fetchAllStations().then(
|
||||||
(value) {
|
(value) {
|
||||||
_allStations = value;
|
_allStations = value;
|
||||||
@@ -37,4 +46,28 @@ class FloodStationProvider extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
lib/services/overlay_service.dart
Normal file
47
lib/services/overlay_service.dart
Normal 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
35
lib/theme.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
20
lib/widgets/custom_marker.dart
Normal file
20
lib/widgets/custom_marker.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@ class FloodStationListView extends StatelessWidget {
|
|||||||
const FloodStationListView(
|
const FloodStationListView(
|
||||||
{super.key,
|
{super.key,
|
||||||
required List<FloodStation> stations,
|
required List<FloodStation> stations,
|
||||||
required this.onItemTapped})
|
required void Function(FloodStation) onItemTapped})
|
||||||
: _stations = stations;
|
: _onItemTapped = onItemTapped,
|
||||||
|
_stations = stations;
|
||||||
|
|
||||||
final List<FloodStation> _stations;
|
final List<FloodStation> _stations;
|
||||||
final void Function(FloodStation) onItemTapped;
|
final void Function(FloodStation) _onItemTapped;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -23,7 +24,7 @@ class FloodStationListView extends StatelessWidget {
|
|||||||
final item = _stations.elementAt(index);
|
final item = _stations.elementAt(index);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
onTap: () => onItemTapped(item),
|
onTap: () => _onItemTapped(item),
|
||||||
title: Text(item.label),
|
title: Text(item.label),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
69
lib/widgets/flood_station_table.dart
Normal file
69
lib/widgets/flood_station_table.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class LoadingNotifier extends StatelessWidget {
|
class LoadingNotifier extends StatelessWidget {
|
||||||
const LoadingNotifier(
|
const LoadingNotifier(
|
||||||
{super.key, required this.onDismissed, required this.message});
|
{super.key,
|
||||||
final void Function() onDismissed;
|
required void Function() onDismissed,
|
||||||
final String message;
|
required String message})
|
||||||
|
: _onDismissed = onDismissed,
|
||||||
|
_message = message;
|
||||||
|
|
||||||
|
final void Function() _onDismissed;
|
||||||
|
final String _message;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -26,11 +31,11 @@ class LoadingNotifier extends StatelessWidget {
|
|||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
message,
|
_message,
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => onDismissed(),
|
onPressed: () => _onDismissed(),
|
||||||
icon: Icon(Icons.close),
|
icon: Icon(Icons.close),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
65
lib/widgets/map_popup.dart
Normal file
65
lib/widgets/map_popup.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart' as intl;
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import '../model/reading.dart';
|
import '../model/reading.dart';
|
||||||
|
import '../services/date_utility.dart';
|
||||||
|
|
||||||
class ReadingGraph extends StatelessWidget {
|
class ReadingGraph extends StatelessWidget {
|
||||||
const ReadingGraph({super.key, required List<Reading> readings})
|
const ReadingGraph({super.key, required List<Reading> readings})
|
||||||
@@ -27,7 +27,7 @@ class ReadingGraph extends StatelessWidget {
|
|||||||
lineBarsData: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
isCurved: true,
|
isCurved: true,
|
||||||
color: Colors.cyan,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
isStrokeCapRound: true,
|
isStrokeCapRound: true,
|
||||||
dotData: const FlDotData(show: false),
|
dotData: const FlDotData(show: false),
|
||||||
@@ -41,19 +41,21 @@ class ReadingGraph extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
bottomTitles: getBottomTitles(spots),
|
bottomTitles: _getBottomTitles(spots),
|
||||||
leftTitles: getLeftTitles(spots),
|
leftTitles: _getLeftTitles(spots),
|
||||||
topTitles: const AxisTitles(sideTitles: SideTitles()),
|
topTitles: const AxisTitles(sideTitles: SideTitles()),
|
||||||
rightTitles: const AxisTitles(sideTitles: SideTitles()),
|
rightTitles: const AxisTitles(sideTitles: SideTitles()),
|
||||||
),
|
),
|
||||||
lineTouchData: LineTouchData(
|
lineTouchData: LineTouchData(
|
||||||
touchTooltipData: LineTouchTooltipData(
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (touchedSpot) =>
|
||||||
|
Theme.of(context).colorScheme.primaryContainer,
|
||||||
getTooltipItems: (touchedSpots) {
|
getTooltipItems: (touchedSpots) {
|
||||||
return touchedSpots.map((touchedSpot) {
|
return touchedSpots.map((touchedSpot) {
|
||||||
return LineTooltipItem(
|
return LineTooltipItem(
|
||||||
'${touchedSpot.y.toString()}\n${getLongDate(touchedSpot.x)}',
|
'${touchedSpot.y.toString()}\n${_getLongDate(touchedSpot.x)}',
|
||||||
TextStyle(
|
TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
fontWeight: FontWeight.bold));
|
fontWeight: FontWeight.bold));
|
||||||
}).toList();
|
}).toList();
|
||||||
},
|
},
|
||||||
@@ -63,22 +65,23 @@ class ReadingGraph extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AxisTitles getBottomTitles(List<FlSpot> spots) {
|
AxisTitles _getBottomTitles(List<FlSpot> spots) {
|
||||||
return AxisTitles(
|
return AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
interval: 90,
|
interval: 90,
|
||||||
reservedSize: 40,
|
reservedSize: 40,
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
maxIncluded: false,
|
maxIncluded: false,
|
||||||
|
minIncluded: false,
|
||||||
getTitlesWidget: (value, meta) => SideTitleWidget(
|
getTitlesWidget: (value, meta) => SideTitleWidget(
|
||||||
meta: meta,
|
meta: meta,
|
||||||
child: Text(getDate(value)),
|
child: Text(DateUtility.formatMinutesToHm(value)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AxisTitles getLeftTitles(List<FlSpot> spots) {
|
AxisTitles _getLeftTitles(List<FlSpot> spots) {
|
||||||
return AxisTitles(
|
return AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
reservedSize: 50,
|
reservedSize: 50,
|
||||||
@@ -93,23 +96,17 @@ class ReadingGraph extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getDate(double value) {
|
String _getLongDate(double value) {
|
||||||
intl.DateFormat hmFormat = intl.DateFormat('Hm');
|
|
||||||
return hmFormat.format(
|
|
||||||
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt()));
|
|
||||||
}
|
|
||||||
|
|
||||||
String getLongDate(double value) {
|
|
||||||
DateTime date =
|
DateTime date =
|
||||||
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt());
|
DateTime.fromMillisecondsSinceEpoch((value * 1000 * 60).toInt());
|
||||||
int daysDifference = (DateTime.now().weekday - date.weekday + 7) % 7;
|
int daysDifference = (DateTime.now().weekday - date.weekday + 7) % 7;
|
||||||
|
|
||||||
if (daysDifference == 0) {
|
if (daysDifference == 0) {
|
||||||
return 'Today ${intl.DateFormat('Hm').format(date)}';
|
return 'Today ${DateUtility.formatDateToHm(date)}';
|
||||||
} else if (daysDifference == 1) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class StationFilter extends StatefulWidget {
|
class StationFilter extends StatefulWidget {
|
||||||
const StationFilter({super.key, required this.onEditingComplete});
|
const StationFilter({super.key, required this.onChanged});
|
||||||
final void Function(String filterText) onEditingComplete;
|
final void Function(String filterText) onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StationFilter> createState() => StationFilterState();
|
State<StationFilter> createState() => StationFilterState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class StationFilterState extends State<StationFilter> {
|
class StationFilterState extends State<StationFilter> {
|
||||||
TextEditingController filterController = TextEditingController();
|
final TextEditingController _filterController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: filterController,
|
controller: _filterController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: Icon(Icons.search),
|
prefixIcon: Icon(Icons.search),
|
||||||
suffixIcon: Opacity(
|
suffixIcon: Opacity(
|
||||||
opacity: filterController.text.isEmpty ? 0 : 1,
|
opacity: _filterController.text.isEmpty ? 0 : 1,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
filterController.clear();
|
_filterController.clear();
|
||||||
setState(() {});
|
|
||||||
|
widget.onChanged(_filterController.text);
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.delete),
|
icon: Icon(Icons.delete),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: Text('Filter'),
|
label: Text('Filter'),
|
||||||
),
|
),
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => widget.onChanged(_filterController.text),
|
||||||
onEditingComplete: () => widget.onEditingComplete(filterController.text),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
pubspec.yaml
72
pubspec.yaml
@@ -16,6 +16,11 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
timezone: ^0.10.0
|
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:
|
||||||
@@ -25,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
47
readme.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user