diff --git a/lib/api.dart b/lib/api.dart index 29e2e749..ed721c28 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -227,10 +227,8 @@ class InvenTreeAPI { errorMessage = "Server timeout"; return null; } else { - // Unknown error type - errorMessage = error.toString(); - // Unknown error type, re-throw error - return null; + // Unknown error type - re-throw the error and Sentry will catch it + throw error; } }); diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 6d7d1a01..e7aec6d3 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -5,6 +5,9 @@ import 'package:InvenTree/widget/dialogs.dart'; import 'package:flutter/cupertino.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + import 'dart:convert'; import 'package:path/path.dart' as path; @@ -108,7 +111,7 @@ class InvenTreeModel { Future reload(BuildContext context, {bool dialog = false}) async { if (dialog) { - showProgressDialog(context, "Refreshing data", "Refreshing data for ${NAME}"); + showProgressDialog(context, I18N.of(context).refreshing, "Refreshing data for ${NAME}"); } var response = await api.get(url, params: defaultGetFilters()) @@ -120,9 +123,10 @@ class InvenTreeModel { } if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + showTimeoutDialog(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error (Sentry will catch) + throw e; } return null; @@ -137,6 +141,12 @@ class InvenTreeModel { } if (response.statusCode != 200) { + showErrorDialog( + context, + I18N.of(context).serverError, + "${I18N.of(context).statusCode}: ${response.statusCode}" + ); + print("Error retrieving data"); return false; } @@ -170,9 +180,10 @@ class InvenTreeModel { } if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + showTimeoutDialog(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error, let Sentry report it + throw e; } return null; @@ -185,7 +196,7 @@ class InvenTreeModel { } if (response.statusCode != 200) { - print("Error updating ${NAME}: Status code ${response.statusCode}"); + showErrorDialog(context, I18N.of(context).serverError, "${I18N.of(context).statusCode}: ${response.statusCode}"); return false; } @@ -217,7 +228,7 @@ class InvenTreeModel { print("GET: $addr ${params.toString()}"); if (dialog) { - showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server"); + showProgressDialog(context, I18N.of(context).requestingData, "Requesting ${NAME} data from server"); } var response = await api.get(addr, params: params) @@ -229,9 +240,10 @@ class InvenTreeModel { } if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + showTimeoutDialog(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error (handled by Sentry) + throw e; } return null; }); @@ -243,7 +255,7 @@ class InvenTreeModel { hideProgressDialog(context); if (response.statusCode != 200) { - print("Error retrieving data"); + showErrorDialog(context, I18N.of(context).serverError, "${I18N.of(context).statusCode}: ${response.statusCode}"); return null; } @@ -269,8 +281,12 @@ class InvenTreeModel { await api.post(URL, body: data) .timeout(Duration(seconds: 5)) .catchError((e) { - print("Error creating new ${NAME}:"); print(e.toString()); + showErrorDialog( + context, + I18N.of(context).serverError, + e.toString() + ); return null; }) .then((http.Response response) { @@ -279,8 +295,11 @@ class InvenTreeModel { var decoded = json.decode(response.body); _model = createFromJson(decoded); } else { - print("Error creating object: Status Code ${response.statusCode}"); - print(response.body); + showErrorDialog( + context, + I18N.of(context).serverError, + "${I18N.of(context).statusCode}: ${response.statusCode}" + ); } }); @@ -308,7 +327,7 @@ class InvenTreeModel { // TODO - Add error catching if (dialog) { - showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server"); + showProgressDialog(context, I18N.of(context).requestingData, "Requesting ${NAME} data from server"); } var response = await api.get(URL, params:params) @@ -320,7 +339,7 @@ class InvenTreeModel { } if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + showTimeoutDialog(context); } else { // Re-throw the error throw e; diff --git a/lib/l10n b/lib/l10n index 58e2c502..86fbf66a 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 58e2c5027b481a3a620c27b90ae20b888a02bd96 +Subproject commit 86fbf66aea18c43cc8920eddce22df5966a7a875 diff --git a/lib/preferences.dart b/lib/preferences.dart index 91ced0b2..201f4c27 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -1,8 +1,57 @@ import 'package:flutter/cupertino.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'api.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; +import 'package:path/path.dart'; +import 'dart:async'; +/* + * Class for storing InvenTree preferences in a NoSql DB + */ +class InvenTreePreferencesDB { + + static final InvenTreePreferencesDB _singleton = InvenTreePreferencesDB._(); + + static InvenTreePreferencesDB get instance => _singleton; + + InvenTreePreferencesDB._(); + + Completer _dbOpenCompleter; + + Future get database async { + // If completer is null, AppDatabaseClass is newly instantiated, so database is not yet opened + if (_dbOpenCompleter == null) { + _dbOpenCompleter = Completer(); + // Calling _openDatabase will also complete the completer with database instance + _openDatabase(); + } + // If the database is already opened, awaiting the future will happen instantly. + // Otherwise, awaiting the returned future will take some time - until complete() is called + // on the Completer in _openDatabase() below. + return _dbOpenCompleter.future; + } + + Future _openDatabase() async { + // Get a platform-specific directory where persistent app data can be stored + final appDocumentDir = await getApplicationDocumentsDirectory(); + + print("Documents Dir: ${appDocumentDir.toString()}"); + + print("Path: ${appDocumentDir.path}"); + + // Path with the form: /platform-specific-directory/demo.db + final dbPath = join(appDocumentDir.path, 'InvenTreeSettings.db'); + + final database = await databaseFactoryIo.openDatabase(dbPath); + + // Any code awaiting the Completer's future will now start executing + _dbOpenCompleter.complete(database); + } +} + class InvenTreePreferences { static const String _SERVER = 'server'; diff --git a/lib/settings/login.dart b/lib/settings/login.dart index dbe95461..e298604d 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -1,18 +1,27 @@ +import 'dart:ffi'; + +import 'package:InvenTree/widget/dialogs.dart'; +import 'package:InvenTree/widget/fields.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../api.dart'; import '../preferences.dart'; +import '../user_profile.dart'; class InvenTreeLoginSettingsWidget extends StatefulWidget { final SharedPreferences _preferences; - InvenTreeLoginSettingsWidget(this._preferences) : super(); + final List _profiles; + + InvenTreeLoginSettingsWidget(this._profiles, this._preferences) : super(); @override - _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(_preferences); + _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(_profiles, _preferences); } @@ -20,18 +29,61 @@ class _InvenTreeLoginSettingsState extends State { final GlobalKey _formKey = new GlobalKey(); + final _addProfileKey = new GlobalKey(); + final SharedPreferences _preferences; + List profiles; + String _server = ''; String _username = ''; String _password = ''; - _InvenTreeLoginSettingsState(this._preferences) : super() { + _InvenTreeLoginSettingsState(this.profiles, this._preferences) : super() { _server = _preferences.getString('server') ?? ''; _username = _preferences.getString('username') ?? ''; _password = _preferences.getString('password') ?? ''; } + void _createProfile(BuildContext context) { + + showFormDialog( + context, + I18N.of(context).profileAdd, + key: _addProfileKey, + actions: [ + FlatButton( + child: Text(I18N.of(context).cancel), + onPressed: () { + Navigator.of(context).pop(); + } + ), + FlatButton( + child: Text(I18N.of(context).save), + onPressed: () { + // TODO + } + ) + ], + fields: [ + StringField( + label: I18N.of(context).name, + initial: "profile", + ), + StringField( + label: "Server", + initial: "http://127.0.0.1:8000", + ), + StringField( + label: "Username", + ), + StringField( + label: "Password" + ) + ] + ); + } + String _validateServer(String value) { @@ -71,11 +123,95 @@ class _InvenTreeLoginSettingsState extends State { } } + void _deleteProfile(UserProfile profile) async { + + await UserProfileDBManager().deleteProfile(profile); + + // Reload profiles + profiles = await UserProfileDBManager().getAllProfiles(); + + setState(() { + }); + } + @override Widget build(BuildContext context) { final Size screenSize = MediaQuery.of(context).size; + List children = []; + + for (int idx = 0; idx < profiles.length; idx++) { + + UserProfile profile = profiles[idx]; + + children.add(ListTile( + title: Text(profile.name), + subtitle: Text(profile.server), + trailing: FaIcon(FontAwesomeIcons.checkCircle), + onLongPress: () { + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(profile.name), + children: [ + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + // TODO - Mark profile as selected + }, + child: Text(I18N.of(context).profileSelect), + ), + SimpleDialogOption( + onPressed: () { + //Navigator.of(context).pop(); + // TODO - Edit profile! + }, + child: Text(I18N.of(context).profileEdit), + ), + SimpleDialogOption( + onPressed: () { + // Navigator.of(context, rootNavigator: true).pop(); + confirmationDialog( + context, + "Delete", + "Delete this profile?", + onAccept: () { + _deleteProfile(profile); + } + ); + }, + child: Text(I18N.of(context).profileDelete), + ) + ], + ); + } + ); + }, + onTap: () { + + }, + )); + } + + return Scaffold( + appBar: AppBar( + title: Text(I18N.of(context).profile), + ), + body: Container( + child: ListView( + children: children, + ) + ), + floatingActionButton: FloatingActionButton( + child: Icon(FontAwesomeIcons.plus), + onPressed: () { + _createProfile(context); + }, + ) + ); + return Scaffold( appBar: AppBar( title: Text("Login Settings"), diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index a2cf1663..387ff4f4 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -1,6 +1,8 @@ import 'package:InvenTree/settings/about.dart'; import 'package:InvenTree/settings/login.dart'; import 'package:InvenTree/settings/release.dart'; +import 'package:InvenTree/user_profile.dart'; +import 'package:InvenTree/preferences.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -58,6 +60,25 @@ class _InvenTreeSettingsState extends State { leading: FaIcon(FontAwesomeIcons.bug), onTap: null, ), + ListTile( + title: Text("Throw Error"), + onTap: () { + throw("My custom error"); + }, + ), + ListTile( + title: Text("add profile"), + onTap: () { + UserProfileDBManager().addProfile( + UserProfile( + name: "My Profile", + server: "https://127.0.0.1:8000", + username: "Oliver", + password: "hunter2", + ) + ); + }, + ) ], ) ) @@ -68,7 +89,9 @@ class _InvenTreeSettingsState extends State { var prefs = await SharedPreferences.getInstance(); - Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget(prefs))); + List profiles = await UserProfileDBManager().getAllProfiles(); + + Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget(profiles, prefs))); } void _about() async { diff --git a/lib/user_profile.dart b/lib/user_profile.dart new file mode 100644 index 00000000..d896274f --- /dev/null +++ b/lib/user_profile.dart @@ -0,0 +1,109 @@ + +/* + * Class for InvenTree user / login details + */ +import 'package:sembast/sembast.dart'; +import 'preferences.dart'; + +class UserProfile { + + UserProfile({ + this.name, + this.server, + this.username, + this.password + }); + + // Name of the user profile + String name; + + // Base address of the InvenTree server + String server; + + // Username + String username; + + // Password + String password; + + // User ID (will be provided by the server on log-in) + int user_id; + + factory UserProfile.fromJson(Map json) => UserProfile( + name: json['name'], + server: json['server'], + username: json['username'], + password: json['password'], + ); + + Map toJson() => { + "name": name, + "server": server, + "username": username, + "password": password, + }; + + @override + String toString() { + return "${server} - ${username}:${password}"; + } +} + +class UserProfileDBManager { + + static const String folder_name = "profiles"; + + final _folder = intMapStoreFactory.store(folder_name); + + Future get _db async => await InvenTreePreferencesDB.instance.database; + + Future addProfile(UserProfile profile) async { + + UserProfile existingProfile = await getProfile(profile.name); + + if (existingProfile != null) { + print("UserProfile '${profile.name}' already exists"); + return; + } + + await _folder.add(await _db, profile.toJson()); + + print("Added user profile '${profile.name}'"); + } + + Future deleteProfile(UserProfile profile) async { + final finder = Finder(filter: Filter.equals("name", profile.name)); + await _folder.delete(await _db, finder: finder); + + print("Deleted user profile ${profile.name}"); + } + + Future getProfile(String name) async { + // Lookup profile by name (or return null if does not exist) + final finder = Finder(filter: Filter.equals("name", name)); + + final profiles = await _folder.find(await _db, finder: finder); + + if (profiles.length == 0) { + return null; + } + + // Return the first matching profile object + return UserProfile.fromJson(profiles[0].value); + } + + /* + * Return all user profile objects + */ + Future> getAllProfiles() async { + final profiles = await _folder.find(await _db); + + List profileList = new List(); + + for (int idx = 0; idx < profiles.length; idx++) { + profileList.add(UserProfile.fromJson(profiles[idx].value)); + } + + return profileList; + } +} diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index 1816df20..828a4476 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -4,6 +4,53 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +Future confirmationDialog(BuildContext context, String title, String text, {String acceptText, String rejectText, Function onAccept, Function onReject}) async { + + if (acceptText == null || acceptText.isEmpty) { + acceptText = I18N.of(context).ok; + } + + if (rejectText == null || rejectText.isEmpty) { + rejectText = I18N.of(context).cancel; + } + + AlertDialog dialog = AlertDialog( + title: ListTile( + title: Text(title), + leading: FaIcon(FontAwesomeIcons.questionCircle), + ), + content: Text(text), + actions: [ + FlatButton( + child: Text(rejectText), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(); + if (onReject != null) { + onReject(); + } + } + ), + FlatButton( + child: Text(acceptText), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(); + if (onAccept != null) { + onAccept(); + } + } + ) + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return dialog; + } + ); +} + void showMessage(BuildContext context, String message) { Scaffold.of(context).showSnackBar(SnackBar( content: Text(message), @@ -64,6 +111,18 @@ Future showErrorDialog(BuildContext context, String title, String descript }); } +void showTimeoutDialog(BuildContext context) { + /* + Show a server timeout dialog + */ + + showErrorDialog( + context, + I18N.of(context).timeout, + I18N.of(context).noResponse + ); +} + void showProgressDialog(BuildContext context, String title, String description) { showDialog( diff --git a/pubspec.lock b/pubspec.lock index 04026bf0..cd28fab8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: name: camera url: "https://pub.dartlang.org" source: hosted - version: "0.6.4+5" + version: "0.7.0+2" camera_platform_interface: dependency: transitive description: @@ -106,6 +106,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + device_info: + dependency: "direct main" + description: + name: device_info + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + device_info_platform_interface: + dependency: transitive + description: + name: device_info_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" fake_async: dependency: transitive description: @@ -152,7 +166,7 @@ packages: name: flutter_keyboard_visibility url: "https://pub.dartlang.org" source: hosted - version: "4.0.2" + version: "4.0.3" flutter_keyboard_visibility_platform_interface: dependency: transitive description: @@ -251,14 +265,14 @@ packages: name: image_picker url: "https://pub.dartlang.org" source: hosted - version: "0.6.7+21" + version: "0.6.7+22" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.1.6" intl: dependency: "direct main" description: @@ -300,7 +314,7 @@ packages: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "0.4.3+2" + version: "0.4.3+4" path: dependency: "direct main" description: @@ -392,6 +406,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.14" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.5" rxdart: dependency: transitive description: @@ -399,13 +420,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.25.0" + sembast: + dependency: "direct main" + description: + name: sembast + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.9" sentry: dependency: "direct main" description: name: sentry url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "4.0.4" shared_preferences: dependency: "direct main" description: @@ -447,7 +475,7 @@ packages: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+3" + version: "0.0.2+3" sky_engine: dependency: transitive description: flutter @@ -466,14 +494,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "1.3.2+2" + version: "1.3.2+3" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.3+1" stack_trace: dependency: transitive description: @@ -564,7 +592,7 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.5+1" + version: "0.1.5+3" url_launcher_windows: dependency: transitive description: @@ -572,13 +600,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.1+3" - usage: - dependency: transitive - description: - name: usage - url: "https://pub.dartlang.org" - source: hosted - version: "3.4.2" uuid: dependency: transitive description: @@ -599,7 +620,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "1.7.4" + version: "1.7.4+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ad7660c2..c212ecc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,15 +33,17 @@ dependencies: preferences: ^5.2.0 # Persistent settings storage qr_code_scanner: ^0.0.13 package_info: ^0.4.0 # App information introspection + device_info: ^1.0.0 # Information about the device font_awesome_flutter: ^8.8.1 # FontAwesome icon set flutter_speed_dial: ^1.2.5 # FAB menu elements - sentry: ^3.0.1 # Error reporting + sentry: ^4.0.4 # Error reporting flutter_typeahead: ^1.8.1 # Auto-complete input field image_picker: ^0.6.6 # Select or take photos url_launcher: ^5.7.10 # Open link in system browser flutter_markdown: ^0.5.2 # Rendering markdown camera: - path_provider: + path_provider: ^1.6.27 # Local file storage + sembast: ^2.4.9 # NoSQL data storage path: dev_dependencies: