From f317847a7dd0690bf4d5d5ad689127ce7479e965 Mon Sep 17 00:00:00 2001 From: Max Goeckel Date: Mon, 18 Jan 2021 13:50:17 +0100 Subject: [PATCH] Bug fixes --- .../article_categorization_controller.dart | 37 ++- .../article_download_controller.dart | 9 +- .../article_persistence_controller.dart | 2 +- lib/main.dart | 2 +- lib/models/article.dart | 2 + lib/models/category.dart | 5 +- lib/views/home.dart | 248 +++++++++++++++--- lib/views/widgets/dialog.dart | 38 +++ pubspec.lock | 23 +- pubspec.yaml | 1 + 10 files changed, 316 insertions(+), 51 deletions(-) diff --git a/lib/controllers/article_categorization_controller.dart b/lib/controllers/article_categorization_controller.dart index 5a6dc85..3986278 100644 --- a/lib/controllers/article_categorization_controller.dart +++ b/lib/controllers/article_categorization_controller.dart @@ -2,8 +2,21 @@ import 'package:flutter_app/controllers/article_download_controller.dart'; import 'package:flutter_app/models/article.dart'; import 'package:flutter_app/models/category.dart'; +// TODO as Singleton + +/// This class controls the article categories. It is reposnsibe for filling the +/// categories with articles and removing duplicate articles. +/// +/// From these categories, the top horizontal-scrolling list is built. +/// +/// Note: It does NOT hold the saved/dismissed articles! These are in home.dart class ArticleCategorizationController { + //The language of the articles that are to be fetched. + //TODO make changable by settings + final String _language = "de"; + + // All categories Category _business; Category _entertainment; Category _general; @@ -12,8 +25,10 @@ class ArticleCategorizationController { Category _sports; Category _technology; + // All categories as a List List _categories; + /// Creates all categories. They are empty after creation. ArticleCategorizationController() { _business = new Category.createEmpty("Business", "business", "Business and finance news", "https://images.unsplash.com/photo-1491336477066-31156b5e4f35?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1350&q=80"); _entertainment = new Category.createEmpty("Entertainment", "entertainment", "Entertainment news and stars", "https://images.unsplash.com/photo-1496337589254-7e19d01cec44?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1350&q=80"); @@ -33,6 +48,7 @@ class ArticleCategorizationController { _categories.add(_technology); } + // Getter for all categories Category get business => _business; Category get general => _general; Category get entertainment => _entertainment; @@ -41,21 +57,34 @@ class ArticleCategorizationController { Category get sports => _sports; Category get technology => _technology; + /// Returns a List with all categories. List getAllCategories() { return _categories; } + /// Fetches articles for a given category. + /// This uses + /// ArticleDownloadController->fetch() + /// with the categories api-name to get the top headlines for the category + /// and language. Future fetchArticles(Category c) async { - ArticleDownloadController adc = ArticleDownloadController.getTopHeadlines(c.apiName, "de", ""); + ArticleDownloadController adc = ArticleDownloadController.getTopHeadlines(c.apiName, _language, ""); await adc.fetch(); c.addArticles(adc.articles); } - - void fetchForAll() { + /// Fetches articles for all categories + /// Unfortuately, the fetching cannot happen in parallel because + /// the view (home.dart) will not wait for it to be finished and display + /// an empty card list. So, we have to "await" every category to finish. + /// + /// The categories are filled after this completes. + Future fetchForAll() async { + //TODO see if general can be handled extra for (Category c in _categories) { - this.fetchArticles(c); + await this.fetchArticles(c); } + return; } diff --git a/lib/controllers/article_download_controller.dart b/lib/controllers/article_download_controller.dart index 65cc8da..2da8143 100644 --- a/lib/controllers/article_download_controller.dart +++ b/lib/controllers/article_download_controller.dart @@ -17,18 +17,22 @@ class ArticleDownloadController { final String searchKeyword; - + /// Constructor to get the top headlines for the given Category and country. + /// a search keyword can be passed aswell. ArticleDownloadController.getTopHeadlines(this.articleCategory, this.countryCode, this.searchKeyword) { this._endpoint = "v2/top-headlines"; _buildURL(); } + /// Constructor to get every headline for the given Category and country. + /// a search keyword can be passed aswell. ArticleDownloadController.getEverything(this.articleCategory, this.countryCode, this.searchKeyword) { this._endpoint = "v2/everything"; _buildURL(); } + /// Builds the URL from the parameters given to the constructor. void _buildURL() { this._url = "http://newsapi.org/" + this._endpoint + "?"; @@ -78,7 +82,8 @@ class ArticleDownloadController { articles.add(a); } }); - + //TODO remove + print("Downloaded all articles for category " + this.articleCategory); return; } else { return; diff --git a/lib/controllers/article_persistence_controller.dart b/lib/controllers/article_persistence_controller.dart index 7665e70..35d25cd 100644 --- a/lib/controllers/article_persistence_controller.dart +++ b/lib/controllers/article_persistence_controller.dart @@ -89,7 +89,7 @@ class ArticlePersistenceController { /// Clears the contents of the save file /// - /// This is a debug method that is called via the Settings view + /// This is a debug method that is called in the Settings view Future clear() async { final file = await _localFile; List
emptyList = new List
(); diff --git a/lib/main.dart b/lib/main.dart index 4213f40..7c72f0f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_app/views/home.dart'; -void main() { +void main() async { runApp(MyApp()); } diff --git a/lib/models/article.dart b/lib/models/article.dart index 136f6d6..80721e0 100644 --- a/lib/models/article.dart +++ b/lib/models/article.dart @@ -1,4 +1,6 @@ /// The Model for an article +/// The fields are final and set by the download controller. +/// They correspond to the JSON values that newsapi.org provides. class Article { final String sourceId; diff --git a/lib/models/category.dart b/lib/models/category.dart index bafb2f3..a8f4576 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -2,9 +2,8 @@ import 'package:flutter_app/models/article.dart'; /// Model for an Article category /// -/// Thus far, there are only two categories of Articles: -/// - Saved articles -/// - dismissed Articles +/// It has the necessary information for all controllers and views (api keys, images, description) +/// and a List of all Articles for the Category. class Category { final String name; final String description; diff --git a/lib/views/home.dart b/lib/views/home.dart index 8bca862..f1e5dc5 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:esense_flutter/esense.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_app/controllers/article_categorization_controller.dart'; @@ -10,13 +9,15 @@ import 'package:flutter_app/models/category.dart'; import 'package:flutter_app/views/saved_article_view.dart'; import 'package:flutter_app/views/settings_view.dart'; import 'package:flutter_app/views/widgets/category_tile.dart'; +import 'package:flutter_app/views/widgets/dialog.dart'; import 'package:flutter_app/views/widgets/news_block.dart'; +import 'package:flutter_blue/flutter_blue.dart'; /// The home view. /// /// A white top bar with the App name in it. /// All fetched Articles will be displayed here as a scrollable list of Cards. -/// Above it are the two categories (saved/dismissed) +/// Above it are all the categories in a horizontal list /// Below it are controls to go to saved/dismissed Articles. /// A floating button allows for connection to the ESense earables. class Home extends StatefulWidget { @@ -29,7 +30,6 @@ class _HomeState extends State { //holds all "general" articles ArticlePersistenceController apc = ArticlePersistenceController(); ArticleCategorizationController acc = ArticleCategorizationController(); - int _navigationBarIndex = 1; final GlobalKey listKey = GlobalKey(); @@ -39,15 +39,19 @@ class _HomeState extends State { //indicates that the fetching of articles is done bool _loading = true; - //Indicates weather the app is subscribed to esense sensor events - bool _sensing = false; + // is bluetooth on? + bool _btOn = false; //indicted weather the esense is connected bool _connected = false; - final String eSenseName = "eSense-0332"; //TODO change to the name of the device - StreamSubscription subscription; - int _threshold = 5; //TODO tweak + final String eSenseName = "eSense-0332"; //TODO change to the name of the device + int _threshold = 2250; //TODO tweak + FlutterBlue flutterBlue; + BluetoothCharacteristic motionSenor; + BluetoothCharacteristic startStop; + BluetoothDevice device; + var dataList = new List(); @override @@ -55,6 +59,7 @@ class _HomeState extends State { _fetchArticles(); super.initState(); _loading = true; + } /// Fetches all Articles, loads saved Articles from disk and compares them. @@ -70,7 +75,7 @@ class _HomeState extends State { /// This has to be async'd because network and file operations are async. /// The app can display the Articles when _loading becomes false. _fetchArticles() async { - acc.fetchForAll(); + await acc.fetchForAll(); List
aList = await apc.loadArticleListFromDisk(); for (Article a in aList) { @@ -82,6 +87,8 @@ class _HomeState extends State { setState(() { _loading = false; }); + + } @@ -203,7 +210,7 @@ class _HomeState extends State { } }, child: Icon(Icons.bluetooth), - backgroundColor: Colors.green, + backgroundColor: _connected ? Colors.green : Colors.red, ), bottomNavigationBar: BottomNavigationBar( items: const [ @@ -280,47 +287,210 @@ class _HomeState extends State { background: saveBackground(), secondaryBackground: dismissBackground() - ) ); } - Future _connectToESense() async { - ESenseManager.connectionEvents.listen((event) { - if (event.type == ConnectionType.connected) _startSensing(); - }); + /// Connects to an ESense earable using bluetooth (FlutterBlue). + /// The name of the ESense to use is stored in a variable in home.dart + /// If the connection is successful, _connected will be true and the + /// floating button for the connection will turn green. + /// If it fails, a dialog will be shown. + /// + /// If it is already connected, the ESense will be disconnected using + /// _disconnectFromESense() method. + Future _connectToESense() { + flutterBlue = FlutterBlue.instance; + setState((){ + if(_btOn) { + _disconnectFromESense(); + } else { + _btOn = true; + flutterBlue.isOn.then((isOn) { + if(isOn) { + flutterBlue.scan(timeout: Duration(seconds: 4)).listen((_onScanResult)).onDone(() { + if(device == null) { + ESenseDialog.showBluetoothDialog(context, "Connection failed", "No ESense device found. Make sure the device is turned on"); + } + + _connected = true; + }); + } else { + ESenseDialog.showBluetoothDialog(context, "Connection failed", "Bluetooth is turned off. Please turn it on and try again."); + } + }); + + } + }); + } + + + /// Disconnects from the ESense earable. + /// All event subscriptions/alters will be stopped, the scanning for devices + /// will be stopped and the earable will be successfully disconnected. + /// + /// No error message will be shown if disconnecting fails. + Future _disconnectFromESense() async { + if(device == null) { + setState(() { + _btOn = false; + _connected = false; + }); + return; + } + + flutterBlue.stopScan(); + await startStop.write([0x53, 0x16, 0x02, 0x00, 0x14]); + motionSenor.setNotifyValue(false); + + device.disconnect().whenComplete((){ + setState((){ + _btOn = false; + _connected = false; + }); + startStop = null; + motionSenor = null; + }); + } - _connected = await ESenseManager.connect(eSenseName); - _startSensing(); + /// The scan result of the bluetooth connection function is evaluated here. + /// + /// If the device is an ESense device and all charateristics are received + /// successfully, scanning for further devices will be stopped and the + /// event listener for the motion sensor is started. + void _onScanResult(ScanResult scanResult) { + BluetoothDevice device = scanResult.device; + if(device.name.contains("eSense")) { + this.device = device; + flutterBlue.stopScan(); + + this.device.connect().whenComplete(() async { + await _initCharacteristics(this.device).then((value) { + if(!value) { + flutterBlue.scan(timeout: Duration(seconds: 4)).listen((_onScanResult)); + } + }); + + if (startStop != null && motionSenor != null) { + await startSampling(startStop); + if (motionSenor.isNotifying == false) { + motionSenor.setNotifyValue(true); + } + motionSenor.value.listen((receiveData)); + } + }); + } } - void _startSensing() async { - // subscribe to sensor event from the eSense device - subscription = ESenseManager.sensorEvents.listen((event) { - if (event.runtimeType == AccelerometerOffsetRead) { - int offset = (event as AccelerometerOffsetRead).offsetY; - if (offset >= _threshold) { - dismissFirstArticle(); - } else if (offset <= -(_threshold)) { - saveFirstArticle(); - } - }; - }); - setState(() { - _sensing = true; - }); + /// Searches for the motion sensor in the devices supported services. + /// This assumes that the device is an ESense device because it is called + /// only in _connectToEsense()->_onScanResult(). + /// + /// The global variables motionSensor and startStop are set here to the + /// corresponding characteristics. + /// + /// returns: true if the motion sensor was found, false otherwise. + /// Note that even if true is returned it is not 100% safe to assume + /// that motionSensor and startStop are set! + Future _initCharacteristics (BluetoothDevice device) async { + List services = await device.discoverServices(); + bool serviceFound = false; + services.forEach((service){ + if(service.uuid.toString().contains("0000ff06")) { + serviceFound = true; + service.characteristics.forEach((characteristic) { + if (characteristic.uuid.toString().contains("0000ff08")) { + motionSenor = characteristic; + } else if (characteristic.uuid.toString().contains("0000ff07")) { + startStop = characteristic; + } + }); + } + }); + return serviceFound; + } + + + /// Starts the sampling of the motion sensor by writing into the startStop + /// characteristic. + Future startSampling (BluetoothCharacteristic startStop) async { + await startStop.write([0x53, 0x17, 0x02, 0x01, 0x14]); + } + + + /// Receives daa from the earable and pre-selects it so that processData() + /// can work on the accelerometer data. + void receiveData(List value) { + if(value.length > 15){ + int yGyro = (value.elementAt(6) << 8).toSigned(16) + value.elementAt(7); + + if(dataList.length > 30) { + dataList.removeAt(0); + } + + dataList.add((yGyro)); + + if(dataList.length > 20) { + setState(() { + _connected = false; + }); + processData(dataList.getRange(dataList.length - 20, dataList.length - 1)); + } + } } - void _disconnectFromESense() { - subscription.cancel(); - setState(() { - _sensing = false; - }); - ESenseManager.disconnect(); - super.dispose(); + + /// Triggers for the dismiss/save gesture + /// If movement to the left is detected, the article is dismissed + /// If movement to the right is detected, the article is saved + /// + /// This uses dismissFirstArticle() and saveFirstArticle() to manipulate + /// the article list and view + void processData(Iterable data) { + if (detectMovement(data, true) == true) { + dismissFirstArticle(); + } else if (detectMovement(data, false) == true) { + saveFirstArticle(); + }; + } + + + /// Detects head movement to the left or right. The Iterable data is the + /// pre-selected sensor data, the bool left decides wether a left or right + /// head movement should be detected. + /// + /// The sensor data must surpass a certain _threshold (global var.) to count + /// as a movement so that not every small head movement counts as + /// a dismiss/save action. + /// + /// return: true if movement has been detected, false otherwise. + bool detectMovement(Iterable data, bool left) { + int count = 0; + int countThreshold = 4; + int sign = left ? 1 : -1; + int countReset = 0; + + data.forEach((value){ + if (value * sign > 4500 - _threshold) { + count++; + } + + if (count >= countThreshold) { + sign = sign * (-1); + count = 0; + countReset++; + } + }); + + //TODO check if that is necessary of if it only detects a double movement + if (countReset >= 2 ) { + return true; + } + + return false; } } diff --git a/lib/views/widgets/dialog.dart b/lib/views/widgets/dialog.dart index e69de29..2ecc8d0 100644 --- a/lib/views/widgets/dialog.dart +++ b/lib/views/widgets/dialog.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// Represents a dialog that is shown to the user. +/// It can only be dismissed by pressing the "OK" button. +class ESenseDialog { + + /// Builds a dialog with the given String title, text. + /// The context is passed so that the dialog knows where to show up. + /// + /// return: The dialog. + static Future showBluetoothDialog(BuildContext context, String title, String text) async { + return showDialog( + context: context, + barrierDismissible: false, // user must tap button! + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: ListBody( + children: [ + Text(text), + ], + ), + ), + actions: [ + TextButton( + child: Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 045b7ce..f22796c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,11 +113,25 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.2.1" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_blue: + dependency: "direct main" + description: + name: flutter_blue + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.3" flutter_blurhash: dependency: transitive description: @@ -263,13 +277,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.13" + protobuf: + dependency: transitive + description: + name: protobuf + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" rxdart: dependency: transitive description: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.25.0" + version: "0.24.1" settings_ui: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 056ea22..218a449 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: webview_flutter: ^1.0.7 path_provider: ^1.6.24 settings_ui: ^0.5.0 + flutter_blue: ^0.7.3