diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9407069 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +/tmp +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/pubspec.lock +/build/ + +# Web related + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/README.md b/README.md index 7f2b23e..cc7ed58 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ -# gps +# Flutter Demo GPS-App -This is an sample application which demonstrates some features that can be found in many flutter apps, e.g. +This is a sample application that demonstrates some of the features which can be found in many Flutter apps, e.g. - Provider and BLoC pattern - Accessing the GPS sensor -- Using Streams +- Streams as data provider - Localization - Own Widgets +- Storing files +- ... ## Getting Started `gps_bloc_app.dart` and `gps_provider_app.dart` are two implementations of the GPS app. -The first uses the BLoc pattern, the latte the provider pattern. -Both can be executed from the IDE and the command line. +The first uses the BLoC pattern, the latter the provider pattern. +Both can be executed from the IDE and the command line, e.g. + + `flutter run lib/gps_provider_app.dart` + +To build an app for Android or iOS, one of these files hast to be linked or renamed to `main.dart`. + + +Tested with Flutter (Channel stable, 3.3.4, on Ubuntu 22.04.1) and + +- Android +- Linux +- Web \ + Note: ensure that browser provides GPS coordinates + - Google Chrome works + - Ungoogled Chromium does not -To build an app for Android or iOS, one these files hast to be renamed to `main.dart`. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # 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 + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..97d8424 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,59 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "de.hsma.mars.flutter_demo_gps" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d94892a --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7de3302 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/de/hsma/mars/flutter_demo_gps/MainActivity.java b/android/app/src/main/java/de/hsma/mars/flutter_demo_gps/MainActivity.java new file mode 100644 index 0000000..9e75836 --- /dev/null +++ b/android/app/src/main/java/de/hsma/mars/flutter_demo_gps/MainActivity.java @@ -0,0 +1,6 @@ +package de.hsma.mars.flutter_demo_gps; + +import io.flutter.embedding.android.FlutterActivity; + +public class MainActivity extends FlutterActivity { +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..d94892a --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..83ae220 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.1.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cb24abd --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..15338f2 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/lib/blocs/bloc_provider.dart b/lib/blocs/bloc_provider.dart new file mode 100644 index 0000000..c56e54b --- /dev/null +++ b/lib/blocs/bloc_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +// +// Base for all BLoCs. +// This class was adapted from https://github.com/boeledi/Streams-Block-Reactive-Programming-in-Flutter +// + +abstract class BlocBase { + void dispose(); +} + +class BlocProvider extends StatefulWidget { + const BlocProvider({ + Key? key, + required this.child, + required this.bloc, + }): super(key: key); + + final T bloc; + final Widget child; + + @override + BlocProviderState createState() => BlocProviderState(); + + static T of(BuildContext context){ + BlocProvider? provider = context.findAncestorWidgetOfExactType>(); + return provider!.bloc; + } +} + +class BlocProviderState extends State>{ + @override + void dispose(){ + widget.bloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context){ + return widget.child; + } +} \ No newline at end of file diff --git a/lib/blocs/gps_bloc.dart b/lib/blocs/gps_bloc.dart new file mode 100644 index 0000000..8240f03 --- /dev/null +++ b/lib/blocs/gps_bloc.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'package:location/location.dart'; +import 'package:gps/models/gps_point.dart'; +import 'package:gps/utils/gps_persister.dart'; +import 'bloc_provider.dart'; + +// An action which is triggert by the UI +enum GpsBlocAction { toggleRecording, save } + +// A state change triggert by the Business Logic +enum GpsBlocState { recording, notRecording } + +class GpsBloc implements BlocBase { + final GpsPersister _persister; + bool _recording = false; + GpsPoint _currentLocation = GpsPoint(); + List _recordedLocations = []; + + final StreamController _gpsOutController = + StreamController.broadcast(); + Stream get gpsPoint => _gpsOutController.stream; + + final StreamController _gpsInController = + StreamController.broadcast(); + StreamSink get gpsIn => _gpsInController.sink; + + final StreamController _stateOutController = + StreamController.broadcast(); + Stream get stateOut => _stateOutController.stream; + + final StreamController _actionInController = + StreamController.broadcast(); + StreamSink get actionIn => _actionInController.sink; + + GpsBloc(this._persister) { + _gpsInController.stream.listen(_handleNewPosition); + _actionInController.stream.listen(_handleActions); + } + + void _handleNewPosition(LocationData event) { + _currentLocation = GpsPoint( + latitude: event.latitude ?? 0.0, + longitude: event.longitude ?? 0.0, + accuracy: event.accuracy ?? 0.0, + speed: event.speed ?? 0.0, + heading: event.heading ?? 0.0, + time: event.time ?? 0.0, + satelliteNumber: event.satelliteNumber ?? 0, + provider: event.provider ?? "none"); + if (_recording) { + _recordedLocations.add(_currentLocation); + } + _gpsOutController.sink.add(_currentLocation); + } + + void _handleActions(GpsBlocAction event) { + switch (event) { + case GpsBlocAction.toggleRecording: + _recording = !_recording; + _stateOutController.sink.add( + _recording ? GpsBlocState.recording : GpsBlocState.notRecording); + break; + case GpsBlocAction.save: + _persister.save(_recordedLocations); + _recordedLocations = []; + break; + } + } + + @override + void dispose() { + _gpsOutController.close(); + _gpsInController.close(); + _stateOutController.close(); + _actionInController.close(); + } +} diff --git a/lib/gps_bloc_app.dart b/lib/gps_bloc_app.dart new file mode 100644 index 0000000..229a356 --- /dev/null +++ b/lib/gps_bloc_app.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:gps/blocs/bloc_provider.dart'; +import 'package:gps/blocs/gps_bloc.dart'; +import 'package:gps/utils/environment.dart'; +import 'package:gps/utils/gps_persister.dart'; +import 'package:gps/utils/gps_utils.dart'; +import 'package:gps/widgets/gps_buttons_widget_bloc.dart'; +import 'package:gps/widgets/gps_position_widget_bloc.dart'; +import 'package:gps/widgets/gps_title_widget_bloc.dart'; +import 'package:location/location.dart'; + +void main() { + runApp(const GpsBlocApp()); +} + +class GpsBlocApp extends StatefulWidget { + const GpsBlocApp({Key? key}) : super(key: key); + + @override + GpsBlocAppState createState() => GpsBlocAppState(); +} + +class GpsBlocAppState extends State { + Locale locale = AppLocalizations.supportedLocales.first; + StreamSubscription? gps; + + @override + void initState() { + super.initState(); + } + + @override + void deactivate() { + gps?.cancel(); + super.deactivate(); + } + + void _nextLocale() { + const locales = AppLocalizations.supportedLocales; + final nextIdx = (locales.indexOf(locale) + 1) % locales.length; + + setState(() { + locale = locales[nextIdx]; + }); + } + + @override + Widget build(BuildContext context) { + GpsBloc myBloc = GpsBloc(CsvGpsPersister(Environment.storageDir())); + gps = GpsUtils().stream.listen((event) { + myBloc.gpsIn.add(event); + }); + + return MaterialApp( + onGenerateTitle: (BuildContext context) => + AppLocalizations.of(context)!.appName, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider( + bloc: myBloc, + child: buildScaffold(context), + ), + ); + } + + Widget buildScaffold(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const GpsTitleWidgetBloc(), + ), + body: Center( + child: Column(children: const [ + GpsPositionWidget(), + GpsButtonsWidgetBloc(), + ])), + floatingActionButton: FloatingActionButton( + onPressed: _nextLocale, + child: const Icon(Icons.update), + )); + } +} diff --git a/lib/gps_provider_app.dart b/lib/gps_provider_app.dart new file mode 100644 index 0000000..312a8d7 --- /dev/null +++ b/lib/gps_provider_app.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:gps/provider/gps_model.dart'; +import 'package:gps/utils/environment.dart'; +import 'package:gps/utils/gps_persister.dart'; +import 'package:gps/utils/gps_utils.dart'; +import 'package:gps/widgets/gps_position_widget_provider.dart'; +import 'package:provider/provider.dart'; + +void main() { + runApp(const GpsProviderApp()); +} + +class GpsProviderApp extends StatelessWidget { + const GpsProviderApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'GPS Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: GpsDemo(), + ); + } +} + +class GpsDemo extends StatelessWidget { + GpsDemo({Key? key}) : super(key: key); + + final GpsPersister _storage = CsvGpsPersister(Environment.storageDir()); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('GPS Demo'), + ), + body: ChangeNotifierProvider( + create: (_) => GpsModel(GpsUtils().stream), + child: Center( + child: Column( + children: [ + const GpsPositionWidget(), + Consumer( + builder: (context, model, child) => Row( + children: [ + IconButton( + onPressed: () => model.toggleRecording(), + icon: Icon(model.isRecording + ? Icons.stop + : Icons.fiber_manual_record), + tooltip: model.isRecording ? "Stop Recording" : "Record", + ), + IconButton( + onPressed: () => model.save(_storage), + icon: const Icon(Icons.save), + ) + ], + ), + ), + ], + )), + ), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..6821524 --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,14 @@ +{ + "@@locale": "de", + "appName": "GPS Block", + "noData": "Keine Daten vorhanden", + "date": "Datum", + "lat": "Breite", + "lon": "Länge", + "heading": "Richtung", + "accuracy": "Genauigkeit", + "speed": "Geschwindigkeit", + "satNo": "Satelliten", + "provider": "Provider" +} + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..f3c0ab2 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,45 @@ +{ + "@@locale": "en", + + "appName": "GPS BLoC", + "@appName": { + "description": "App Name" + }, + "noData": "No Data", + "@noData": { + "description": "Message when no GPS data are available" + }, + "date": "Date", + "@date": { + "description": "The current timestamp" + }, + "lat": "Latitude", + "@lat": { + "description": "Current latitude" + }, + "lon": "Longitude", + "@lon": { + "description": "Current longitude" + }, + "heading": "Heading", + "@heading": { + "description": "Orientation in degree" + }, + "accuracy": "Accuracy", + "@accuracy": { + "description": "Accuracy of GPS signal" + }, + "speed": "Speed", + "@speed": { + "description": "Current speed in m/s" + }, + "satNo": "Satellite number", + "@satNo": { + "description": "Number of satellites from which the position was computed" + }, + "provider": "Provider", + "@provider": { + "description": "Data provider, e.g. GPS, Glonass, ..." + } +} + diff --git a/lib/l10n/app_he.arb b/lib/l10n/app_he.arb new file mode 100644 index 0000000..4ab867f --- /dev/null +++ b/lib/l10n/app_he.arb @@ -0,0 +1,14 @@ +{ + "@@locale": "he", + "appName": "הג\"י פי אס", + "noData": "אין נתונים", + "date": "תאריכים", + "lat": "רוחב", + "lon": "אורך", + "heading": "כיוון", + "accuracy": "מדויקים", + "speed": "מהירות", + "satNo": "לווינים", + "provider": "הספק" +} + diff --git a/lib/models/gps_point.dart b/lib/models/gps_point.dart new file mode 100644 index 0000000..8ec2365 --- /dev/null +++ b/lib/models/gps_point.dart @@ -0,0 +1,38 @@ +class GpsPoint { + GpsPoint( + {this.latitude = 0.0, + this.longitude = 0.0, + this.accuracy = 0.0, + this.speed = 0.0, + this.heading = 0.0, + this.time = 0.0, + this.satelliteNumber = 0, + this.provider = "none"}); + + /// Timestamp + final double time; + + /// Latitude in degrees + final double latitude; + + /// Longitude, in degrees + final double longitude; + + /// Estimated horizontal accuracy of this location, radial, in meters + final double accuracy; + + /// In meters/second + final double speed; + + /// Heading is the horizontal direction of travel of this device, in degrees + final double heading; + + /// Number of satellites used to derive the fix. + final int satelliteNumber; + + /// Name of the provider that generated this fix. + final String provider; + + @override + String toString() => 'GpsPoint<$latitude, $longitude>'; +} diff --git a/lib/provider/gps_model.dart b/lib/provider/gps_model.dart new file mode 100644 index 0000000..266da0c --- /dev/null +++ b/lib/provider/gps_model.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:location/location.dart'; +import 'package:gps/models/gps_point.dart'; +import 'package:gps/utils/gps_persister.dart'; + +class GpsModel with ChangeNotifier { + bool _recording = false; + GpsPoint _currentLocation = GpsPoint(); + List _recordedLocations = []; + + bool get isRecording => _recording; + GpsPoint get currentLocation => _currentLocation; + List get locations => List.unmodifiable(_recordedLocations); + + GpsModel(Stream stream) { + stream.listen((event) { + _currentLocation = GpsPoint( + latitude: event.latitude ?? 0.0, + longitude: event.longitude ?? 0.0, + accuracy: event.accuracy ?? 0.0, + speed: event.speed ?? 0.0, + heading: event.heading ?? 0.0, + time: event.time ?? 0.0, + satelliteNumber: event.satelliteNumber ?? 0, + provider: event.provider ?? "none"); + + if (_recording) { + _recordedLocations.add(_currentLocation); + } + + notifyListeners(); + }); + } + + void toggleRecording() { + _recording = !_recording; + notifyListeners(); + } + + void save(GpsPersister s) { + s.save(locations); + _recordedLocations = []; + } +} diff --git a/lib/utils/environment.dart b/lib/utils/environment.dart new file mode 100644 index 0000000..9bc675a --- /dev/null +++ b/lib/utils/environment.dart @@ -0,0 +1,28 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; + +class Environment { + static bool get hasRealGps => + kIsWeb || Platform.isAndroid || Platform.isIOS; + // !kIsWeb && (Platform.isAndroid || Platform.isIOS); + static bool get isAndroid => !kIsWeb && Platform.isAndroid; + static bool get isIOS => !kIsWeb && Platform.isIOS; + static bool get isDesktop => !kIsWeb && ( + Platform.isLinux || + Platform.isMacOS || + Platform.isWindows || + Platform.isFuchsia); + + static String storageDir() { + if (kIsWeb) { + return "."; + } else if (isAndroid) { + return '/storage/emulated/0/Download/'; + } else if (isIOS) { + return 'Documents'; + } else { + return "."; + } + } +} diff --git a/lib/utils/gps_persister.dart b/lib/utils/gps_persister.dart new file mode 100644 index 0000000..c0d1124 --- /dev/null +++ b/lib/utils/gps_persister.dart @@ -0,0 +1,35 @@ +import 'dart:io' show File, FileMode; +import 'package:intl/intl.dart' show DateFormat; +import 'package:gps/models/gps_point.dart'; + +abstract class GpsPersister { + void save(Iterable data); +} + +class CsvGpsPersister implements GpsPersister { + static const String _csvHeader = + "#Time, Latitude, Longitude, Accuracy, Speed, Heading"; + + /// Format of DateTime part of file name + static final DateFormat _formatter = DateFormat('yyyyMMdd_HHmmss'); + + /// Path where the CSV will be stored + final String _rootDir; + + const CsvGpsPersister(this._rootDir); + + @override + void save(Iterable points) { + File file = File("$_rootDir/geo${_formatter.format(DateTime.now())}.csv"); + + file.writeAsStringSync(_csvHeader); + for (var point in points) { + file.writeAsStringSync("\n${_toCsv(point)}", + mode: FileMode.writeOnlyAppend); + } + file.writeAsString("\n", mode: FileMode.writeOnlyAppend, flush: true); + } + + String _toCsv(GpsPoint p) => + "${p.time}, ${p.latitude}, ${p.longitude}, ${p.accuracy}, ${p.speed}, ${p.heading}"; +} diff --git a/lib/utils/gps_utils.dart b/lib/utils/gps_utils.dart new file mode 100644 index 0000000..bcb2479 --- /dev/null +++ b/lib/utils/gps_utils.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:gps/utils/environment.dart'; +import 'package:location/location.dart'; +import 'package:wakelock/wakelock.dart'; + +class GpsUtils { + _Stream s; + + GpsUtils() + : s = Environment.hasRealGps + ? _RealGpsStream() + : _FakeGpsStream(49.4833, 8.4667, Duration(milliseconds: 100)); + + Stream get stream => s.getStream(); +} + +mixin _Stream { + Stream getStream(); +} + +class _RealGpsStream implements _Stream { + // Really, really ugly + // - Location package should have been encapsulated + // - Permissions do not belong here + Stream getStream() { + final Location location = Location(); + + location.serviceEnabled().then((_serviceEnabled) => { + if (!_serviceEnabled) {location.requestService()} + }); + location.hasPermission().then((_permissionGranted) => { + if (_permissionGranted == PermissionStatus.denied) + {location.requestPermission()} + }); + location.changeSettings( + accuracy: LocationAccuracy.high, interval: 100, distanceFilter: 0.0); + Wakelock.enable(); + return location.onLocationChanged; + } +} + +class _FakeGpsStream implements _Stream { + double lat; + double lon; + Duration dur; + + _FakeGpsStream(this.lat, this.lon, [this.dur = const Duration(seconds: 1)]); + + /// Generator which creates GPS coordinates around the given location. + Stream getStream() async* { + Map gpsPoint = { + 'accuracy': 20.0, + 'altitude': 100.0, + 'speed': 1.0, + 'speed_accuracy': 1.0, + 'isMock': 1, + 'verticalAccuracy': 20.0, + 'headingAccuracy': 1.0, + 'elapsedRealtimeNanos': 1000.0, + 'elapsedRealtimeUncertaintyNanos': 2000.0, + 'satelliteNumber': 0, + 'provider': "dummy" + }; + + double degree = 0.0; + while (true) { + await Future.delayed(dur); + gpsPoint['time'] = DateTime.now().millisecondsSinceEpoch + 0.0; + double rad = degree * pi / 180; + gpsPoint['latitude'] = lat + cos(rad); + gpsPoint['longitude'] = lon + sin(rad); + gpsPoint['heading'] = degree; + gpsPoint['altitude'] = degree; + degree = (degree + 1) % 360; + yield LocationData.fromMap(gpsPoint); + } + } +} diff --git a/lib/widgets/compass.dart b/lib/widgets/compass.dart new file mode 100644 index 0000000..41b0f33 --- /dev/null +++ b/lib/widgets/compass.dart @@ -0,0 +1,151 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class Compass extends StatelessWidget { + static const double fallbackSize = 100.0; + final double degree; + final Color pointerColor; + final double pointerThickness; + final double relativeCircleSize; + final double relativePointerLength; + final double? height; + final double? width; + + const Compass( + {this.degree = 0.0, + this.pointerColor = Colors.red, + this.pointerThickness = 0.2, + this.relativeCircleSize = 0.8, + this.relativePointerLength = 1.0, + this.width, + this.height, + Key? key}) + : assert(pointerThickness > 0, "Pointer must be thicker than zero"), + assert(relativeCircleSize >= 0 && relativeCircleSize <= 1.0, + "Relative circle size must be between 0.0 and 1.0"), + assert(relativePointerLength >= 0 && relativePointerLength <= 1.0, + "Relative circle size must be between 0.0 and 1.0"), + super(key: key); + + /// Determines widget's width and height + /// Both will be equal to the smallest length which is either + /// set as [width], [height] or as [constraints] maxWidth/maxHeight. + /// [fallbackSize] will be used if none of them is set. + double calcSideLength( + double? setWidth, double? setHeight, BoxConstraints constraints) { + List sizes = [ + setWidth ?? double.infinity, + setHeight ?? double.infinity, + constraints.maxWidth, + constraints.maxHeight, + ]; + var tmp = sizes.where((s) => s != double.infinity); + return tmp.isNotEmpty ? tmp.reduce(min) : fallbackSize; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + var sideLength = calcSideLength(width, height, constraints); + return SizedBox( + height: sideLength, + width: sideLength, + child: CustomPaint( + painter: _Compass( + degree: degree, + pointerColor: pointerColor, + pointerThickness: pointerThickness, + relativeCircleSize: relativeCircleSize, + relativePointerLength: relativePointerLength), + ), + ); + }, + ); + } + + /// Creates a 6x6 grid with Compass widgets. + /// The look of each widget differs from its predecessor + /// - Orientation: 0 - 350° + /// - Color: from green over brown to red + /// - Size of circle: 0-87,5% of widgets width/height + /// - Pointers thickness: 5-35% of widgets width/height + static Widget demo() { + return Container( + color: Colors.grey, + width: 300, + height: 300, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 50, + childAspectRatio: 1, + crossAxisSpacing: 0, + mainAxisSpacing: 0), + itemCount: 36, + itemBuilder: (BuildContext ctxt, int index) => CustomPaint( + painter: _Compass( + degree: index * 10, + relativeCircleSize: (index * 0.025), + pointerThickness: index / 120 + 0.05, + pointerColor: Color.fromRGBO((index * 255 / 36).floor(), + 255 - (index * 255 / 36).floor(), 20, 1), + ), + ), + ), + ); + } +} + +class _Compass extends CustomPainter { + static const Offset origin = Offset(0, 0); + static final Paint black = Paint()..color = Colors.black; + static final Paint white = Paint()..color = Colors.white; + + final double degree; + final Color pointerColor; + final double pointerThickness; + final double relativeCircleSize; + final double relativePointerLength; + + _Compass({ + this.degree = 0.0, + this.pointerColor = Colors.red, + this.pointerThickness = 0.2, + this.relativeCircleSize = 0.8, + this.relativePointerLength = 1.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint pointerPaint = Paint()..color = pointerColor; + + double minSide = size.width < size.height ? size.width : size.height; + double circleRadius = minSide * relativeCircleSize / 2; + double pointerLength = minSide * relativePointerLength / 2; + double pointerRadius = minSide * pointerThickness / 2; + + canvas.save(); + canvas.translate(size.width / 2, size.height / 2); + + canvas.drawCircle(origin, circleRadius, black); + canvas.drawCircle(origin, circleRadius * 0.9, white); + canvas.drawCircle(origin, pointerRadius, pointerPaint); + + canvas.rotate(degree * pi / 180); + canvas.drawPath(makeTriangle(pointerRadius, pointerLength), pointerPaint); + canvas.restore(); + } + + Path makeTriangle(double halfBase, double height) { + var path = Path(); + path.moveTo(-halfBase, 0); + path.lineTo(0, -height); + path.lineTo(halfBase, 0); + path.close(); + return path; + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/widgets/demo_align_widget.dart b/lib/widgets/demo_align_widget.dart new file mode 100644 index 0000000..adebe13 --- /dev/null +++ b/lib/widgets/demo_align_widget.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class DemoAlignWidget extends StatelessWidget { + const DemoAlignWidget({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 300, + child: DefaultTextStyle( + style: const TextStyle(fontSize: 20.0, color: Colors.black), + child: Stack( + fit: StackFit.passthrough, + children: const [ + Align( + alignment: Alignment(-0.5, -0.5), + child: Text("-0.5, -0.5"), + ), + Align( + alignment: Alignment(-0.5, 0.5), + child: Text("-0.5, 0.5"), + ), + Align( + alignment: Alignment(0.5, -0.5), + child: Text("0.5, -0.5"), + ), + Align(alignment: Alignment.topLeft, child: Text("topLeft")), + Align( + alignment: Alignment(0.5, 0.5), + child: Text("0.5, 0.5"), + ), + Align(alignment: Alignment.topCenter, child: Text("topCenter")), + Align(alignment: Alignment.topRight, child: Text("topRight")), + Align(alignment: Alignment.centerLeft, child: Text("centerLeft")), + Align(alignment: Alignment.center, child: Text("center")), + Align( + alignment: Alignment.centerRight, child: Text("centerRight")), + Align(alignment: Alignment.bottomLeft, child: Text("bottomLeft")), + Align( + alignment: Alignment.bottomCenter, + child: Text("bottomCenter")), + Align( + alignment: Alignment.bottomRight, child: Text("bottomRight")), + ], + ), + )); + } +} diff --git a/lib/widgets/gps_buttons_widget_bloc.dart b/lib/widgets/gps_buttons_widget_bloc.dart new file mode 100644 index 0000000..2f5f892 --- /dev/null +++ b/lib/widgets/gps_buttons_widget_bloc.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:gps/blocs/bloc_provider.dart'; +import 'package:gps/blocs/gps_bloc.dart'; + +class GpsButtonsWidgetBloc extends StatelessWidget { + const GpsButtonsWidgetBloc({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final GpsBloc bloc = BlocProvider.of(context); + return Row( + children: [ + StreamBuilder( + stream: bloc.stateOut, + initialData: GpsBlocState.notRecording, + builder: (BuildContext context, + AsyncSnapshot snapshot) { + return IconButton( + onPressed: () => bloc.actionIn.add(GpsBlocAction.toggleRecording), + icon: Icon(snapshot.data == GpsBlocState.recording + ? Icons.stop + : Icons.fiber_manual_record), + ); + }), + IconButton( + onPressed: () => bloc.actionIn.add(GpsBlocAction.save), + icon: const Icon(Icons.save), + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/gps_position_widget_bloc.dart b/lib/widgets/gps_position_widget_bloc.dart new file mode 100644 index 0000000..f0e1293 --- /dev/null +++ b/lib/widgets/gps_position_widget_bloc.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:gps/blocs/bloc_provider.dart'; +import 'package:gps/blocs/gps_bloc.dart'; +import 'package:gps/models/gps_point.dart'; +import 'package:gps/widgets/two_parts.dart'; + +import 'compass.dart'; + +class GpsPositionWidget extends StatelessWidget { + const GpsPositionWidget({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final GpsBloc bloc = BlocProvider.of(context); + + return StreamBuilder( + stream: bloc.gpsPoint, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (!snapshot.hasData) { + return Text(AppLocalizations.of(context)!.noData); + } else { + final GpsPoint currentLocation = snapshot.data!; + final AppLocalizations l = AppLocalizations.of(context)!; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TwoParts.fromMillisecondDateTime( + l.date, currentLocation.time.toInt() * 1000), + TwoParts.fromDouble(l.lat, currentLocation.latitude), + TwoParts.fromDouble(l.lon, currentLocation.longitude), + TwoParts.fromDouble(l.heading, currentLocation.heading), + TwoParts.fromDouble(l.accuracy, currentLocation.accuracy), + TwoParts.fromDouble(l.speed, currentLocation.speed), + TwoParts.fromInt(l.satNo, currentLocation.satelliteNumber), + TwoParts.fromString(l.provider, currentLocation.provider), + Compass( + degree: currentLocation.heading, + width: 200, + ), + ], + ); + } + }); + } +} diff --git a/lib/widgets/gps_position_widget_provider.dart b/lib/widgets/gps_position_widget_provider.dart new file mode 100644 index 0000000..e16e754 --- /dev/null +++ b/lib/widgets/gps_position_widget_provider.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:gps/provider/gps_model.dart'; +import 'package:provider/provider.dart'; + +class GpsPositionWidget extends StatelessWidget { + const GpsPositionWidget({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, model, child) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Breite: ${model.currentLocation.latitude}', + ), + Text( + 'Länge: ${model.currentLocation.longitude}', + ), + Text( + 'Genauigkeit: ${model.currentLocation.accuracy}', + ), + Text( + 'Geschwindigkeit: ${model.currentLocation.speed}', + ), + Text( + 'Richtung: ${model.currentLocation.heading}', + ), + Text( + 'Satelliten: ${model.currentLocation.satelliteNumber}', + ), + Text( + 'Provider: ${model.currentLocation.provider}', + ), + ], + ), + ); + } +} diff --git a/lib/widgets/gps_title_widget_bloc.dart b/lib/widgets/gps_title_widget_bloc.dart new file mode 100644 index 0000000..0a7a9dc --- /dev/null +++ b/lib/widgets/gps_title_widget_bloc.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:gps/blocs/bloc_provider.dart'; +import 'package:gps/blocs/gps_bloc.dart'; + +class GpsTitleWidgetBloc extends StatelessWidget { + const GpsTitleWidgetBloc({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final GpsBloc bloc = BlocProvider.of(context); + + return StreamBuilder( + stream: bloc.stateOut, + initialData: GpsBlocState.notRecording, + builder: (BuildContext context, AsyncSnapshot snapshot) { + String title = AppLocalizations.of(context)!.appName; + final state = + (snapshot.data == GpsBlocState.recording) ? "- recording" : ""; + return Text("$title $state"); + }); + } +} diff --git a/lib/widgets/two_parts.dart b/lib/widgets/two_parts.dart new file mode 100644 index 0000000..d7a4996 --- /dev/null +++ b/lib/widgets/two_parts.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class TwoParts extends StatelessWidget { + final String title; + final Localizable value; + + factory TwoParts.fromInt(String title, int value) => + TwoParts(title: title, value: _IntHolder(value)); + factory TwoParts.fromDouble(String title, double value) => + TwoParts(title: title, value: _DoubleHolder(value)); + factory TwoParts.fromString(String title, String value) => + TwoParts(title: title, value: _StringHolder(value)); + factory TwoParts.fromMillisecondDateTime(String title, int value) => + TwoParts(title: title, value: _DateHolder(value)); + + const TwoParts({ + required this.title, + required this.value, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + child: Row(children: [ + Expanded(flex: 1, child: Text(title)), + Expanded( + flex: 1, child: Text(value.format(Localizations.localeOf(context)))) + ])); + } +} + +mixin Localizable { + String format(Locale locale); +} + +class _StringHolder implements Localizable { + final String _string; + _StringHolder(this._string); + @override + String format(Locale locale) => _string; +} + +class _IntHolder implements Localizable { + final int _int; + _IntHolder(this._int); + @override + String format(Locale locale) => + NumberFormat("#", locale.toString()).format(_int); +} + +class _DoubleHolder implements Localizable { + final double _double; + _DoubleHolder(this._double); + @override + String format(Locale locale) => + NumberFormat("##0.0#", locale.toString()).format(_double); +} + +class _DateHolder implements Localizable { + final DateTime _timestamp; + _DateHolder(int i) : _timestamp = DateTime.fromMicrosecondsSinceEpoch(i); + @override + String format(Locale locale) => + "${DateFormat.yMMMMEEEEd(locale.toString()).format(_timestamp)}\n${DateFormat.jms(locale.toString()).format(_timestamp)}"; +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..93d8a6e --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "flutter_demo_gps") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "de.hsma.mars.flutter_demo_gps") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000..cc3623c --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "flutter_demo_gps"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "flutter_demo_gps"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7b81bb5 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,35 @@ +name: gps +description: Demo Application + +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ">=2.18.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + flutter_localizations: + sdk: flutter + + location: ^4.3.0 + + cupertino_icons: ^1.0.2 + wakelock: ^0.6.2 + provider: ^6.0.1 + intl: ^0.17.0 + +dev_dependencies: + test: + flutter_test: + sdk: flutter + + flutter_lints: ^2.0.1 + +flutter: + uses-material-design: true + generate: true + diff --git a/test/localized_parsing_test.dart b/test/localized_parsing_test.dart new file mode 100644 index 0000000..4348f6a --- /dev/null +++ b/test/localized_parsing_test.dart @@ -0,0 +1,60 @@ +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:test/test.dart'; + +void main() { + group('double', () { + test('default', () { + expect(NumberFormat().parse("1.234"), 1.234); + }); + group('decimalPattern', () { + test('default', () { + expect(NumberFormat.decimalPattern().parse("1.234"), 1.234); + }); + test('en', () { + expect(NumberFormat.decimalPattern("en").parse("1.234,567"), 1.234567); + }); + test('de', () { + expect(NumberFormat.decimalPattern("de").parse("1.234,567"), 1234.567); + }); + }); + group('scientific', () { + const double expected = 1.2345; + test('default', () { + expect(NumberFormat.scientificPattern().parse("123.45E-2"), expected); + }); + test('en', () { + expect( + NumberFormat.scientificPattern("en").parse("123.45E-2"), expected); + }); + test('de', () { + expect( + NumberFormat.scientificPattern("de").parse("123,45E-2"), expected); + }); + }); + }); + + group('DateTime', () { + final expectedDateTime = DateTime(2021, 12, 24, 11, 30, 0); + + test('parse', () { + expect(DateTime.parse("2021-12-24 11:30:00"), expectedDateTime); + }); + group('DateFormat', () { + test('with pattern', () { + expect(DateFormat("dd.MM.yyyy hh:mm:ss").parse("24.12.2021 11:30:00"), + expectedDateTime); + }); + + final expectedDate = DateTime(2021, 12, 24); + test('yMd("en")', () { + initializeDateFormatting("en"); + expect(DateFormat.yMd("en").parse("12/24/2021"), expectedDate); + }); + test('yMd("de")', () { + initializeDateFormatting("de"); + expect(DateFormat.yMd("de").parse("24.12.2021"), expectedDate); + }); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..a721f1f --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gps/widgets/gps_position_widget_bloc.dart'; +import 'package:gps/widgets/gps_title_widget_bloc.dart'; + +import '../lib/gps_bloc_app.dart'; + +void main() { + testWidgets('GPS smoke test', (WidgetTester tester) async { +// await tester.pumpWidget(const GpsBlocApp()); + +// expect(find.byType(GpsTitleWidgetBloc), findsOneWidget); +// expect(find.byType(GpsPositionWidget), findsOneWidget); + + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..457e665 --- /dev/null +++ b/web/index.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + flutter_demo_gps + + + + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..200d0d0 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "flutter_demo_gps", + "short_name": "flutter_demo_gps", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}