Compare commits
40 Commits
7762c489e1
...
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 | |||
| 9323921754 | |||
| 9e37ebbc22 | |||
| 5df67920ea | |||
| 4765342ad1 | |||
| 81f5924df5 | |||
| c75d905dd8 | |||
| 537c231253 | |||
| fd47053834 | |||
| e799c3349b | |||
| 85e3471c87 | |||
| dfb9b59560 | |||
| 08c266bc95 | |||
| bedd9c7d88 | |||
| 86496005e0 | |||
| 123136f265 |
@@ -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
3
devtools_options.yaml
Normal 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:
|
||||
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.
135
lib/main.dart
135
lib/main.dart
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
lib/model/flood_station.dart
Normal file
47
lib/model/flood_station.dart
Normal 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
11
lib/model/reading.dart
Normal 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
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/pages/flood_station_page.dart
Normal file
76
lib/pages/flood_station_page.dart
Normal 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
114
lib/pages/landing_page.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
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(),
|
||||
);
|
||||
74
lib/services/api.dart
Normal file
74
lib/services/api.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
73
lib/services/flood_station_provider.dart
Normal file
73
lib/services/flood_station_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/widgets/flood_station_list_view.dart
Normal file
54
lib/widgets/flood_station_list_view.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
47
lib/widgets/loading_notifier.dart
Normal file
47
lib/widgets/loading_notifier.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/widgets/reading_graph.dart
Normal file
112
lib/widgets/reading_graph.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
36
lib/widgets/station_filter.dart
Normal file
36
lib/widgets/station_filter.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
pubspec.yaml
142
pubspec.yaml
@@ -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
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