Skip to content
Snippets Groups Projects
Commit fbaffeb2 authored by Hoa Ben The Nguyen's avatar Hoa Ben The Nguyen
Browse files

Merge branch 'main' of gitlab.stud.idi.ntnu.no:sarasdj/prog2900 into connect_data_to_map

parents 874970fd c2c2fa97
No related branches found
No related tags found
No related merge requests found
Showing
with 407 additions and 97 deletions
.coverage 0 → 100644
File added
# PROG2900
## Dependencies
# IceMap PROG2900
This software was developed at NTNU as part of a Bachelor thesis for the municipality of Gjøvik.
## Table of contents
* [Server](#server)
* [Endpoints](#endpoints)
* [Update map](#update-map)
* [Get relation](#get-relation)
* [Get lake names](#get-lake-names)
* [Update measurements](#update-measurements)
* [Add new lake](#add-new-lake)
* [Add test data](#add-test-data)
* [New lidar data](#new-lidar-data)
* [Database](#database)
* [Server dependencies](#server-dependencies)
* [Known bugs](#known-bugs)
* [Application](#application)
* [Application dependencies](#application-dependencies)
* [Developers](#devleopers)
# Server
The server currently only accepts HTTPS requests, not HTTP.
It listens on ip address 127.0.0.1 and port 8443.
URL: ```https://127.0.0.1:8443```
## Endpoints
This server consists of 7 endpoints. Each endpoint requires the desired lake name
to be provided as an url parameter. Of the 7 endpoints, the first 5 only accept GET requests, while
```new_lidar_data``` only accepts POST requests. The first three endpoints expose the processed
data to the application.
Application endpoints:
```
\
\update_map
\get_relation
\add_new_lake?lake=name
.../update_map?lake=*
.../get_relation?lake=*
.../get_lake_names
```
### Server
To run the server...
Other endpoints:
```
.../update_measurements?lake=*
.../add_new_lake?lake=*&cell_size=*
.../add_test_data?lake=*
.../new_lidar_data?lake=*
```
### Application
Request examples:
```
https://127.0.0.1:8443/update_map?lake=Mjøsa
https://127.0.0.1:8443/get_relation?lake=Mjøsa
https://127.0.0.1:8443/get_lake_names
https://127.0.0.1:8443/update_measurements?lake=Mjøsa
https://127.0.0.1:8443/add_new_lake?lake=Skumsjøen&cell_size=0.8
https://127.0.0.1:8443/add_test_data?lake=Mjøsa
https://127.0.0.1:8443/new_lidar_data?lake=Mjøsa
```
### Update map
```
Method: GET
Path: update_map?lake=*
Paramters:
- lake (required)
```
### Database
This project requires SQLite3. Download the precompiled binary for your operating system.
Precompiled binaries can be found on https://www.sqlite.org/download.html. Extract the downloaded
binary in a folder and note its path. Add the path to your system environment variables. Now you can
manage the SQLite database.
Returns the contents of the measurement file for the selected lake.
The file which is read is called ```*_measurements.json``` and can be found in
```server/map_handler/lake_relations```. This endpoint makes no changes to any files.
This endpoint exposes data for the application.
### Get relation
```
Method: GET
Path: get_relation?lake=*
Paramters:
- lake (required)
```
Returns the contents of the relation file for the selected lake.
The file which is read is called ```*_div.json``` and can be found in
```server/map_handler/lake_relations```. This endpoint makes no changes to any files.
This endpoint exposes data for the application.
### Get lake names
```
Method: GET
Path: get_lake_names
Paramters: none
```
```get_lake_names``` returns the contents of the file ```all_lake_names.json```. The file
contains the names of all the lakes in the system. This list is utilized by the applications search
functionality. This endpoint makes no changes to any files.
## Adding new lakes
To add a new lake to the system, go to https://overpass-turbo.eu/.
Once you have navigated to Overpass API, enter
the Overpass query below in the left field, but swap 'lakeName' out
with the name of the lake you want to add. Once the query has been adjusted,
press the 'Run' button.
### Update measurements
```
Method: GET
Path: update_measurements?lake=*
Paramters:
- lake (required)
```
Updates the ```*_measurements.json``` for the selected lake.
The endpoint will retrieve the latest LiDar data from ```_lidar_data.json```, which
is also located in ```map_handler/lake_relations/```. The endpoint will also update the
ice statistics form the NVE model. This endpoint exposes data for the application.
### Add new lake
```
Method: GET
Path: add_new_lake?lake=*&cell_size=*
Paramters:
- lake (required)
- cell_size (optional)
```
Is used when a new lake is added to the system. This process
requires manual intervention. To add a new lake to the system, first navigate to
https://overpass-turbo.eu/. Once you have navigated to th OverpassTurbo API, copy the
query below, and enter it into the white field at the left-hand side at the website
. Swap 'lakeName' out with the name of the lake you want to add. Once the query has been
adjusted, press the 'Run' button.
```
[out:json];
......@@ -45,18 +140,68 @@ out body;
If a text box saying "This query returned quite a lot of data (approx. x MB). Your browser may have a hard time trying to render this. Do you really want to continue?
" appears, press 'continue anyway'. Double check that you have
the correct lake, then press 'Export'. In the 'Export' menu, download the shape data as
GeoJson. Once downloaded, name the file the *lakeName.json, and move the file into
IceMap/server/lake_relations. Once you have added the file, run map division...
GeoJson. Once downloaded, name the file the ```*lakeName.json```, and move the file into
```/server/map_handler/lake_relations```. Once you have added the file, make a get request to the endpoint.
The endpoint requires that the lake name is provided as a parameter. The ```cell_size``` determines
the dimensions of the subdivisions in kilometers. This parameter is optional, but if a value is not provided
in the requests, the default size will be 0.5km.
![img.png](images/geojson-file.png)
The result will be two new files named lakeName_centers.txt and lakeName_div.json. The original
lakeName.geojson file should also remain in the system. Additionally, the file named all_lake_names.json
The endpoint will create the file ```lakeName_div.json```. The original GeoJSON file from OverpassTurbo
file should also remain in the system. Do not remove this file. Additionally, the file named ```all_lake_names.json```
should be updated to contain the newly added lake name.
![img.png](images/resulting-files.png)
## Known bugs
### Add test data
```
Method: GET
Path: add_test_data?lake=*
Paramters:
- lake (required)
```
As the name implies, this endpoint adds test data to the desired lake. The endpoint will
overwrite the file ```*_lidar_data.json``` with measurement objects with random thicknesses.
This endpoint is only for production, and is not implemented in the application.
## Developers
### New lidar data
```
Method: POST
Path: new_lidar_data?lake=*
Paramters:
- lake (required)
```
## Database
This project requires SQLite3. Download the precompiled binary for your operating system.
Precompiled binaries can be found on https://www.sqlite.org/download.html. Extract the downloaded
binary in a folder and note its path. Add the path to your system environment variables. Now you can
manage the SQLite database.
## Server dependencies
## Known bugs
```add_new_lake.py``` does all the processing of new lake data. The current implementation of the
shape file processing fails to include internal coordinates. This results in fully enclosed shapes, such
as islands, to not be included. Simply including internal coordinates without further accommodations will cause
the server to throw errors.
# Application
In order to run the application in an IDE like Android Studio, the port randomization of Dart must
first be bypassed. To do this, run the adb command ```reverse tcp:8443 tcp:8443 ```. This must be
repeated every time the IDE in which the application is running is restarted. A simpler alternative
is to add the command to the run configuration as an external tool. If the port randomization
is not bypassed, the application will not be able to communicate with the server.
## Application dependencies
The application depends on multiple external libraries. A list of these can be found in the
pubspec.yaml file, each with a comment explaining the dependencies usage.
To install all the dependencies, simply run ```flutter pub get``` inside the app directory.
# Developers
This software was developed by Joakim Aleksandersen, Sara Savanovic Djordjevic,
and Hoa Ben The Nguyen.
\ No newline at end of file
......@@ -11,12 +11,11 @@ const String mapEndpoint = "update_map";
// Map variables
String selectedLake = 'Mjøsa'; // NB should be initialised to last selected lake
Uint8List selectedRelation = Uint8List(0);
Uint8List selectedRelation = Uint8List(0); // Initialised in init_state.dart
List<Measurement> selectedMeasurements = [];
List<SubDiv> selectedSubdivisions = [];
SubDiv? selectedSubDiv;
LatLng mapCenter = LatLng(60.8000, 10.8471); // NB may not be necessary
DateTime ?lastUpdate; // Last time data was fetched from server
List<String> lakeSearchOptions = [];
bool internetConnection = true;
......@@ -33,6 +32,8 @@ final titleStyle = GoogleFonts.chakraPetch(
color: Colors.white70,
fontWeight: FontWeight.bold,
);
final smallTextStyle = GoogleFonts.chakraPetch(fontSize: 13, color: textColor);
final regTextStyle = GoogleFonts.chakraPetch(fontSize: 16, color: textColor);
final regTextStyleBig = GoogleFonts.chakraPetch(fontSize: 20, color: textColor);
final chartTextStyle = GoogleFonts.chakraPetch(fontSize: 12, color: textColor);
......
......@@ -49,8 +49,8 @@ class SubDiv {
double minThickness;
double avgThickness;
LatLng center;
double accuracy;
Color color;
int accuracy;
int color;
List<IceStats> iceStats;
SubDiv({
......@@ -69,14 +69,14 @@ class SubDiv {
return SubDiv(
sub_div_id: json['SubdivID'].toString(),
groupID: json['GroupID'] ?? 0,
minThickness: json['MinThickness'] ?? 0,
avgThickness: json['AvgThickness'] ?? 0,
minThickness: (json['MinThickness'] as num?)?.toDouble() ?? 0,
avgThickness: (json['AvgThickness'] as num?)?.toDouble() ?? 0,
center: json['CenLatitude'] != null && json['CenLongitude'] != null
? LatLng(json['CenLatitude'], json['CenLongitude'])
: LatLng(0.0, 0.0),
accuracy: json['Accuracy'] ?? 0.0,
accuracy: json['Accuracy'] ?? 0,
// Set grey as default color
color: json['Color'] != null ? Color(json['Color']) : Colors.grey,
color: json['Color'] ?? 0,
iceStats: (json['IceStats'] as List<dynamic>?)
?.map((data) => IceStats.fromJson(data))
.toList() ?? [],
......
......@@ -86,6 +86,7 @@ class _DefaultPageState extends State<DefaultPage> {
child: ListView(
children: [
MapContainerWidget(
subdivisions: selectedSubdivisions,
measurements: selectedMeasurements,
relation: selectedRelation,
serverConnection: serverConnection,
......
......@@ -26,13 +26,35 @@ Future<void> initialiseState(bool fetchSearchOptions) async {
List<Measurement> measurements = fetchResult.measurements;
selectedMeasurements = measurements;
// Extract all _subdivisions from list of measurements
for (Measurement measurement in measurements) {
for (SubDiv subdivision in measurement.subDivs) {
selectedSubdivisions.add(subdivision);
}
}
// Sort the list of SubDiv objects based on each subdivision id
selectedSubdivisions.sort((a, b) => a.sub_div_id.compareTo(b.sub_div_id));
print("Loaded from files: Meas.len: ${selectedMeasurements.length}, rel.len: ${selectedRelation.length}");
} else { // Try to fetch measurement data from server
markerListFuture = fetchMeasurements().then((fetchResult) {
List<Measurement> measurements = fetchResult.measurements;
selectedMeasurements = measurements;
// Extract all _subdivisions from list of measurements
for (Measurement measurement in measurements) {
for (SubDiv subdivision in measurement.subDivs) {
selectedSubdivisions.add(subdivision);
}
}
// Sort the list of SubDiv objects based on each subdivision id
selectedSubdivisions.sort((a, b) => a.sub_div_id.compareTo(b.sub_div_id));
serverConnection = fetchResult.connected;
setLastLake();
setLastLake(); // Update persistent value for latest fetched lake
// Return measurements
return measurements;
......@@ -52,8 +74,12 @@ Future<void> initialiseState(bool fetchSearchOptions) async {
initSearchOptions();
}
//selectedRelation = await relationFuture;
selectedRelation = await relationFuture; // NB update once fixed
// Last lake initialised to last persistent variable, or Mjøsa if the variable is not found
final prefs = await SharedPreferences.getInstance();
selectedLake = prefs.getString('lasLake') ?? "Mjøsa";
// Set the selected relation
selectedRelation = await relationFuture;
selectedMeasurements = await markerListFuture;
}
} catch (e) {
......
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math';
import '../../consts.dart';
import '../../utils/format_month.dart';
import '../consts.dart';
import '../utils/format_month.dart';
class BarData extends StatefulWidget {
const BarData({super.key});
......@@ -17,7 +18,7 @@ class _BarDataState extends State<BarData> {
// Allocate bar data dynamically from selected subdivision
var barData = <int, List<double>>{};
double totalHeight = 0;
double totalHeight = 0.5; // Set minimum total height
int touchedIndex = -1;
......@@ -30,8 +31,8 @@ class _BarDataState extends State<BarData> {
var entry = selectedSubDiv?.iceStats[i];
if (entry != null) {
barData[i] = [
entry.slushIce,
entry.blackIce,
entry.slushIce,
entry.snowDepth,
];
......
......@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:syncfusion_flutter_maps/maps.dart';
import '../consts.dart';
import '../data_classes.dart';
/// ChoroplethMap is a stateful widget that contains a choropleth map.
......@@ -13,11 +14,13 @@ class ChoroplethMap extends StatefulWidget {
Key? key,
required this.relation,
required this.measurements,
required this.subdivisions,
required this.onSelectionChanged,
}) : super(key: key);
final Uint8List relation;
final List<Measurement> measurements;
final List<SubDiv> subdivisions;
final void Function(int selectedIndex) onSelectionChanged;
@override
......@@ -28,7 +31,6 @@ class ChoroplethMapState extends State<ChoroplethMap> {
int selectedIndex = -1; // Subdivision/map tile index
late MapShapeSource dataSource;
late final MapZoomPanBehavior _zoomPanBehavior = MapZoomPanBehavior();
final List<SubDiv> _subdivisions = <SubDiv>[];
void updateDataSource() {
_initDataSource();
......@@ -41,47 +43,33 @@ class ChoroplethMapState extends State<ChoroplethMap> {
}
void _initDataSource() {
_subdivisions.clear();
// Extract all _subdivisions from list of measurements
for (Measurement measurement in widget.measurements) {
for (SubDiv subdivision in measurement.subDivs) {
_subdivisions.add(subdivision);
}
}
dataSource = MapShapeSource.memory(
widget.relation,
shapeDataField: 'sub_div_id',
dataCount: _subdivisions.length,
primaryValueMapper: (int index) => _subdivisions[index].sub_div_id,
shapeColorValueMapper: (int index) => _subdivisions[index].avgThickness, // NB will later be minThickness
dataCount: widget.subdivisions.length,
primaryValueMapper: (int index) => widget.subdivisions[index].sub_div_id,
shapeColorValueMapper: (int index) => widget.subdivisions[index].avgThickness, // NB will later be minThickness
shapeColorMappers: const [
MapColorMapper(
from: -2,
to: -1,
color: Color(0xFF8C8C8C),
text: '>8'),
MapColorMapper(
from: 0,
to: 4,
color: Color(0xFFff0000),
text: '{0},{4}'),
MapColorMapper(
from: 4,
to: 6,
color: Color(0xffff6a00),
text: '6'),
MapColorMapper(
from: 6,
to: 8,
color: Color(0xFFb1ff00),
text: '8'),
MapColorMapper(
from: 8,
to: 400,
color: Color(0xFF00d6ff),
text: '>8'),
from: 0,
to: 4,
color: Color(0xffff0000),
text: '{0},{1}'),
MapColorMapper(
from: 4,
to: 8,
color: Color(0xffff6a00),
text: '2'),
MapColorMapper(
from: 8,
to: 12,
color: Color(0xFFb1ff00),
text: '3'),
MapColorMapper(
from: 12,
to: 400,
color: Color(0xFF00d6ff),
text: '4'),
],
);
}
......@@ -101,6 +89,12 @@ class ChoroplethMapState extends State<ChoroplethMap> {
layers: [
MapShapeLayer(
source: dataSource,
legend: MapLegend.bar(
MapElement.shape,
position: MapLegendPosition.bottom,
segmentSize: const Size(70.0, 7.0),
textStyle: smallTextStyle,
),
zoomPanBehavior: _zoomPanBehavior,
strokeColor: Colors.blue.shade50,
strokeWidth: 1,
......
import 'package:flutter/material.dart';
import '../../consts.dart';
class InfoLayer extends StatefulWidget {
const InfoLayer({
Key? key,
}) : super(key: key);
@override
InfoLayerState createState() => InfoLayerState();
}
class InfoLayerState extends State<InfoLayer> {
@override
void initState() {
super.initState();
}
// _buildLegendItem renders a colored circle and text to form a legend
Widget _legendItem(Color color, String text) {
return Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
),
],
);
}
/// Builds an additional legend to explain the colors
Widget _buildLegend() {
return Column(
children: [
_legendItem(const Color(0xffff0000), "Very unsafe"),
const SizedBox(height: 10),
_legendItem(const Color(0xffff6a00), "Unsafe"),
const SizedBox(height: 10),
_legendItem(const Color(0xFFb1ff00), "Safe"),
const SizedBox(height: 10),
_legendItem(const Color(0xFF00d6ff), "Very safe"),
const SizedBox(height: 10),
],
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(40),
color: Colors.black.withOpacity(0.8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center, // Align contents vertically centered
children: [
Text(
'Color categorization',
style: subHeadingStyle,
),
const SizedBox(height: 20),
Text(
'The map shows the safety of applying x kg per y m^2',
style: regTextStyle,
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
_buildLegend(),
const SizedBox(height: 30),
Text(
'Placeholder for other information...',
style: smallTextStyle,
textAlign: TextAlign.center,
),
],
),
);
}
}
......@@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'osm_layer.dart';
import 'stat_charts.dart';
import 'info_layer.dart';
import '../../consts.dart';
import 'choropleth_map.dart';
import '../data_classes.dart';
......@@ -14,11 +15,13 @@ import '../utils/format_month.dart';
/// MapContainerWidget is the main widget that contains the map with all
/// its layers, polygons and markers.
class MapContainerWidget extends StatefulWidget {
final List<SubDiv> subdivisions;
final List<Measurement> measurements;
final Uint8List relation;
final bool serverConnection;
const MapContainerWidget({Key? key,
required this.subdivisions,
required this.measurements,
required this.relation,
required this.serverConnection,
......@@ -37,6 +40,8 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
bool isSatTapped = false; // Satellite button tap state tracker
bool isMapTapped = false; // OSM button tap state tracker
bool infoLayer = false; // Additional color legend visibility
Measurement? selectedMeasurement = selectedMeasurements[0];
// Initialise lastUpdate variable from persistent storage if server fetch fails
......@@ -54,14 +59,15 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
}
}
// Tile selection handler
/// Tile selection handler
void handleSelection(int index) {
String indexString = index.toString();
setState(() {
selectedSubDiv = widget.subdivisions[index];
for (Measurement measurement in widget.measurements) {
for (SubDiv subdivision in measurement.subDivs) {
if (subdivision.sub_div_id == indexString) {
selectedSubDiv = subdivision;
selectedMeasurement = measurement;
break;
}
......@@ -105,6 +111,7 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
child: ChoroplethMap(
relation: widget.relation,
measurements: widget.measurements,
subdivisions: widget.subdivisions,
onSelectionChanged: handleSelection,
),
),
......@@ -117,6 +124,14 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
child: OSM(markerList: widget.measurements),
),
),
SizedBox(
width: screenWidth * boxWidth,
height: screenWidth * boxHeight,
child: Visibility(
visible: infoLayer,
child: const InfoLayer(),
),
),
Positioned( // Satellite button
top: 10,
right: 10,
......@@ -212,6 +227,28 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
),
),
),
Positioned( // Color info button
top: 130,
right: 10,
child: GestureDetector(
onTap: () {
setState(() {
infoLayer = !infoLayer; // Toggle satellite layer state on press
});
},
child: Container(
padding: const EdgeInsets.all(8),
decoration: infoLayer ? const BoxDecoration( // Add decoration only when pressed
shape: BoxShape.circle,
color: Colors.grey,
) : null,
child: const Icon(
Icons.info,
color: Colors.white54,
),
),
),
),
],
),
),
......@@ -242,7 +279,7 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(top: 20, left: 30), // Updated padding
padding: const EdgeInsets.only(top: 20, left: 30), // Custom padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
......@@ -251,7 +288,12 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
style: titleStyle,
),
const Divider(),
const SizedBox(height: 10), // Reduced padding
const SizedBox(height: 10),
Text(
'Tile ID: ${selectedSubDiv?.sub_div_id}',
style: regTextStyle,
),
const SizedBox(height: 20),
Text(
'Measured at ',
style: subHeadingStyle,
......@@ -266,9 +308,14 @@ class _MapContainerWidgetState extends State<MapContainerWidget> {
),
const SizedBox(height: contPadding),
Text(
'Measuring point: (${selectedMeasurement?.measurementID}, ${selectedMeasurement?.measurementID})',
'Center coordinate: (${selectedSubDiv?.center.latitude}, ${selectedSubDiv?.center.longitude})',
style: regTextStyle,
),
const SizedBox(height: contPadding/3),
Text(
'Data certainty: ${selectedSubDiv?.accuracy}/4',
style: subHeadingStyle,
),
],
),
),
......
......@@ -4,7 +4,7 @@ import 'package:flutter_map/flutter_map.dart';
import '../data_classes.dart';
class OSM extends StatelessWidget {
class OSM extends StatefulWidget {
final List<Measurement> markerList;
const OSM({
......@@ -13,10 +13,17 @@ class OSM extends StatelessWidget {
}) : super(key: key);
@override
Widget build(BuildContext context) {
// Init list of polygons
List<Polygon> polygons = [];
OSMState createState() => OSMState();
}
class OSMState extends State<OSM> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return FlutterMap(
options: MapOptions(
bounds: LatLngBounds(
......@@ -26,12 +33,9 @@ class OSM extends StatelessWidget {
),
children: [
TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z/{x}/{y}.png",
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
),
PolygonLayer(
polygons: polygons, // Return map with list of polygons included
),
],
);
}
......
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'graph_data/bar_data.dart';
import 'bar_data.dart';
import '../../consts.dart';
class StatCharts extends StatelessWidget {
......
......@@ -12,17 +12,17 @@ dependencies:
flutter_map: ^4.0.0 # Maps ans map customization
http: ^0.13.3 # HTTPS requests
latlong2: ^0.8.2 # LatLng object
provider: ^5.0.0
provider: ^5.0.0 # Ice layer bar chart configuration
fl_chart: ^0.20.0-nullsafety1 # Charts and diagrams
google_fonts: any # Fonts
syncfusion_flutter_maps: ^20.4.41 # Choropleth map_handler
syncfusion_flutter_core: any # Choropleth shape selection
path_provider: ^2.0.8
path_provider: ^2.0.8 # Persistent variables
shared_preferences: any # Persistent data storage
fuzzy: any # Search algorithm
connectivity_plus: ^3.0.3 # Check internet connection
get: ^4.6.5
liquid_pull_to_refresh: ^3.0.0 # Pull to refresh
get: ^4.6.5 # HTTP get requests
liquid_pull_to_refresh: ^3.0.0 # Pull to refresh animation
dev_dependencies:
flutter_test:
......
images/resulting-files.png

19.4 KiB | W: | H:

images/resulting-files.png

25.6 KiB | W: | H:

images/resulting-files.png
images/resulting-files.png
images/resulting-files.png
images/resulting-files.png
  • 2-up
  • Swipe
  • Onion skin
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment