diff --git a/lib/api.dart b/lib/api.dart index 29e2e749..55e2b6b5 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:InvenTree/user_profile.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -67,15 +68,6 @@ class InvenTreeAPI { static const _URL_GET_TOKEN = "user/token/"; static const _URL_GET_VERSION = ""; - Future showServerError(BuildContext context, String description) async { - showErrorDialog( - context, - I18N.of(context).serverError, - description, - icon: FontAwesomeIcons.server - ); - } - // Base URL for InvenTree API e.g. http://192.168.120.10:8000 String _BASE_URL = ""; @@ -108,14 +100,11 @@ class InvenTreeAPI { String makeUrl(String endpoint) => _makeUrl(endpoint); - String _username = ""; - String _password = ""; + UserProfile profile; // Authentication token (initially empty, must be requested) String _token = ""; - bool isConnected() => _token.isNotEmpty; - /* * Check server connection and display messages if not connected. * Useful as a precursor check before performing operations. @@ -126,10 +115,10 @@ class InvenTreeAPI { showDialog( context: context, child: new SimpleDialog( - title: new Text("Not Connected"), + title: new Text(I18N.of(context).notConnected), children: [ ListTile( - title: Text("Server not connected"), + title: Text(I18N.of(context).serverNotConnected), ) ] ) @@ -154,8 +143,14 @@ class InvenTreeAPI { // Connection status flag - set once connection has been validated bool _connected = false; - bool get connected { - return _connected && baseUrl.isNotEmpty && _token.isNotEmpty; + bool _connecting = true; + + bool isConnected() { + return profile != null && _connected && baseUrl.isNotEmpty && _token.isNotEmpty; + } + + bool isConnecting() { + return !isConnected() && _connecting; } // Ensure we only ever create a single instance of the API class @@ -167,26 +162,17 @@ class InvenTreeAPI { InvenTreeAPI._internal(); - Future connect(BuildContext context) async { - var prefs = await SharedPreferences.getInstance(); - - String server = prefs.getString("server"); - String username = prefs.getString("username"); - String password = prefs.getString("password"); - - return connectToServer(context, server, username, password); - } - - Future connectToServer(BuildContext context, String address, String username, String password) async { + Future _connect(BuildContext context) async { /* Address is the base address for the InvenTree server, * e.g. http://127.0.0.1:8000 */ - String errorMessage = ""; + if (profile == null) return false; - address = address.trim(); - username = username.trim(); + String address = profile.server.trim(); + String username = profile.username.trim(); + String password = profile.password.trim(); if (address.isEmpty || username.isEmpty || password.isEmpty) { await showErrorDialog( @@ -211,41 +197,39 @@ class InvenTreeAPI { */ _BASE_URL = address; - _username = username; - _password = password; - _connected = false; - - print("Connecting to " + apiUrl + " -> " + username + ":" + password); + print("Connecting to ${apiUrl} -> ${username}:${password}"); var response = await get("").timeout(Duration(seconds: 10)).catchError((error) { + print("Error connecting to server: ${error.toString()}"); + if (error is SocketException) { - errorMessage = "Could not connect to server"; + showServerError( + context, + I18N.of(context).connectionRefused, + error.toString()); return null; } else if (error is TimeoutException) { - errorMessage = "Server timeout"; + showTimeoutError(context); 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; } }); if (response == null) { // Null (or error) response: Show dialog and exit - - await showServerError(context, errorMessage); return false; } if (response.statusCode != 200) { // Any status code other than 200! + showStatusCodeError(context, response.statusCode); + // TODO: Interpret the error codes and show custom message? - await showServerError(context, "Invalid response code: ${response.statusCode.toString()}"); return false; } @@ -254,29 +238,30 @@ class InvenTreeAPI { print("Response from server: $data"); // We expect certain response from the server - if (!data.containsKey("server") || !data.containsKey("version")) { + if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) { + + showServerError( + context, + "Missing Data", + "Server response missing required fields" + ); - await showServerError(context, "Server response missing required fields"); return false; } - print("Server: " + data["server"]); - print("Version: " + data["version"]); - + // Record server information _version = data["version"]; - - if (!_checkServerVersion(_version)) { - await showServerError(context, "Server version is too old.\n\nServer Version: ${_version}\n\nRequired version: ${_requiredVersionString}"); - return false; - } - - // Record the instance name of the server instance = data['instance'] ?? ''; - // Request token from the server if we do not already have one - if (false && _token.isNotEmpty) { - print("Already have token - $_token"); - return true; + // Check that the remote server version is *new* enough + if (!_checkServerVersion(_version)) { + showServerError( + context, + "Old Server Version", + "\n\nServer Version: ${_version}\n\nRequired version: ${_requiredVersionString}" + ); + + return false; } // Clear the existing token value @@ -285,24 +270,33 @@ class InvenTreeAPI { print("Requesting token from server"); response = await get(_URL_GET_TOKEN).timeout(Duration(seconds: 10)).catchError((error) { + print("Error requesting token:"); print(error); - return null; + }); if (response == null) { - await showServerError(context, "Error requesting access token"); + showServerError( + context, "Token Error", "Error requesting access token from server" + ); + return false; } if (response.statusCode != 200) { - await showServerError(context, "Invalid status code: ${response.statusCode.toString()}"); + showStatusCodeError(context, response.statusCode); return false; } else { var data = json.decode(response.body); if (!data.containsKey("token")) { - await showServerError(context, "No token provided in response"); + showServerError( + context, + "Missing Token", + "Access token missing from response" + ); + return false; } @@ -312,10 +306,50 @@ class InvenTreeAPI { _connected = true; + // Ok, probably pretty good... return true; }; } + bool disconnectFromServer() { + print("InvenTreeAPI().disconnectFromServer()"); + + _connected = false; + _connecting = false; + _token = ''; + profile = null; + } + + Future connectToServer(BuildContext context) async { + + // Ensure server is first disconnected + disconnectFromServer(); + + // Load selected profile + profile = await UserProfileDBManager().getSelectedProfile(); + + print("API Profile: ${profile.toString()}"); + + if (profile == null) { + await showErrorDialog( + context, + "Select Profile", + "User profile not selected" + ); + return false; + } + + _connecting = true; + + bool result = await _connect(context); + + print("_connect() returned result: ${result}"); + + _connecting = false; + + return result; + } + // Perform a PATCH request Future patch(String url, {Map body}) async { var _url = makeApiUrl(url); @@ -399,7 +433,9 @@ class InvenTreeAPI { Map defaultHeaders() { var headers = Map(); - headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); + if (profile != null) { + headers[HttpHeaders.authorizationHeader] = _authorizationHeader(profile.username, profile.password); + } return headers; } @@ -410,11 +446,11 @@ class InvenTreeAPI { return headers; } - String _authorizationHeader() { + String _authorizationHeader(String username, String password) { if (_token.isNotEmpty) { return "Token $_token"; } else { - return "Basic " + base64Encode(utf8.encode('$_username:$_password')); + return "Basic " + base64Encode(utf8.encode('${username}:${password}')); } } diff --git a/lib/barcode.dart b/lib/barcode.dart index e28c2688..a58c37b3 100644 --- a/lib/barcode.dart +++ b/lib/barcode.dart @@ -7,6 +7,7 @@ import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:InvenTree/inventree/stock.dart'; import 'package:InvenTree/inventree/part.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:InvenTree/api.dart'; @@ -65,14 +66,11 @@ class BarcodeHandler { ); } - Future processBarcode(BuildContext context, QRViewController _controller, String barcode, {String url = "barcode/", bool show_dialog = false}) { + Future processBarcode(BuildContext context, QRViewController _controller, String barcode, {String url = "barcode/"}) { this._context = context; this._controller = _controller; print("Scanned barcode data: ${barcode}"); - if (show_dialog) { - showProgressDialog(context, "Scanning", "Sending barcode data to server"); - } // Send barcode request to server InvenTreeAPI().post( @@ -82,10 +80,6 @@ class BarcodeHandler { } ).then((var response) { - if (show_dialog) { - hideProgressDialog(context); - } - if (response.statusCode != 200) { showErrorDialog( context, @@ -118,10 +112,6 @@ class BarcodeHandler { Duration(seconds: 5) ).catchError((error) { - if (show_dialog) { - hideProgressDialog(context); - } - showErrorDialog( context, "Error", @@ -207,7 +197,7 @@ class BarcodeScanHandler extends BarcodeHandler { showDialog( context: _context, child: SimpleDialog( - title: Text("Unknown response"), + title: Text(I18N.of(_context).unknownResponse), children: [ ListTile( title: Text("Response data"), diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 6d7d1a01..9c0358df 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'dart:io'; import 'package:InvenTree/api.dart'; import 'package:InvenTree/widget/dialogs.dart'; import 'package:flutter/cupertino.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + import 'dart:convert'; import 'package:path/path.dart' as path; @@ -105,24 +108,24 @@ class InvenTreeModel { /* * Reload this object, by requesting data from the server */ - Future reload(BuildContext context, {bool dialog = false}) async { - - if (dialog) { - showProgressDialog(context, "Refreshing data", "Refreshing data for ${NAME}"); - } + Future reload(BuildContext context) async { var response = await api.get(url, params: defaultGetFilters()) .timeout(Duration(seconds: 10)) .catchError((e) { - if (dialog) { - hideProgressDialog(context); + if (e is SocketException) { + showServerError( + context, + I18N.of(context).connectionRefused, + e.toString() + ); } - - if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + else if (e is TimeoutException) { + showTimeoutError(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error (Sentry will catch) + throw e; } return null; @@ -132,11 +135,8 @@ class InvenTreeModel { return false; } - if (dialog) { - hideProgressDialog(context); - } - if (response.statusCode != 200) { + showStatusCodeError(context, response.statusCode); print("Error retrieving data"); return false; } @@ -149,7 +149,7 @@ class InvenTreeModel { } // POST data to update the model - Future update(BuildContext context, {Map values, bool show_dialog = false}) async { + Future update(BuildContext context, {Map values}) async { var addr = path.join(URL, pk.toString()); @@ -157,22 +157,21 @@ class InvenTreeModel { addr += "/"; } - if (show_dialog) { - showProgressDialog(context, "Updating ${NAME}", "Sending data to server"); - } - var response = await api.patch(addr, body: values) .timeout(Duration(seconds: 10)) .catchError((e) { - if (show_dialog) { - hideProgressDialog(context); - } - - if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + if (e is SocketException) { + showServerError( + context, + I18N.of(context).connectionRefused, + e.toString() + ); + } else if (e is TimeoutException) { + showTimeoutError(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error, let Sentry report it + throw e; } return null; @@ -180,12 +179,8 @@ class InvenTreeModel { if (response == null) return false; - if (show_dialog) { - hideProgressDialog(context); - } - if (response.statusCode != 200) { - print("Error updating ${NAME}: Status code ${response.statusCode}"); + showStatusCodeError(context, response.statusCode); return false; } @@ -194,7 +189,7 @@ class InvenTreeModel { } // Return the detail view for the associated pk - Future get(BuildContext context, int pk, {Map filters, bool dialog = false}) async { + Future get(BuildContext context, int pk, {Map filters}) async { // TODO - Add "timeout" // TODO - Add error catching @@ -216,22 +211,18 @@ class InvenTreeModel { print("GET: $addr ${params.toString()}"); - if (dialog) { - showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server"); - } - var response = await api.get(addr, params: params) .timeout(Duration(seconds: 10)) .catchError((e) { - if (dialog) { - hideProgressDialog(context); + if (e is SocketException) { + showServerError(context, I18N.of(context).connectionRefused, e.toString()); } - - if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + else if (e is TimeoutException) { + showTimeoutError(context); } else { - showErrorDialog(context, "Error", e.toString()); + // Re-throw the error (handled by Sentry) + throw e; } return null; }); @@ -240,10 +231,8 @@ class InvenTreeModel { return null; } - hideProgressDialog(context); - if (response.statusCode != 200) { - print("Error retrieving data"); + showStatusCodeError(context, response.statusCode); return null; } @@ -266,11 +255,24 @@ class InvenTreeModel { InvenTreeModel _model; - await api.post(URL, body: data) - .timeout(Duration(seconds: 5)) - .catchError((e) { - print("Error creating new ${NAME}:"); + await api.post(URL, body: data).timeout(Duration(seconds: 10)).catchError((e) { + print("Error during CREATE"); print(e.toString()); + + if (e is SocketException) { + showServerError( + context, + I18N.of(context).connectionRefused, + e.toString() + ); + } + else if (e is TimeoutException) { + showTimeoutError(context); + } else { + // Re-throw the error (Sentry will catch) + throw e; + } + return null; }) .then((http.Response response) { @@ -279,8 +281,7 @@ class InvenTreeModel { var decoded = json.decode(response.body); _model = createFromJson(decoded); } else { - print("Error creating object: Status Code ${response.statusCode}"); - print(response.body); + showStatusCodeError(context, response.statusCode); } }); @@ -288,7 +289,7 @@ class InvenTreeModel { } // Return list of objects from the database, with optional filters - Future> list(BuildContext context, {Map filters, bool dialog=false}) async { + Future> list(BuildContext context, {Map filters}) async { if (filters == null) { filters = {}; @@ -307,20 +308,19 @@ class InvenTreeModel { // TODO - Add "timeout" // TODO - Add error catching - if (dialog) { - showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server"); - } - var response = await api.get(URL, params:params) .timeout(Duration(seconds: 10)) .catchError((e) { - if (dialog) { - hideProgressDialog(context); + if (e is SocketException) { + showServerError( + context, + I18N.of(context).connectionRefused, + e.toString() + ); } - - if (e is TimeoutException) { - showErrorDialog(context, "Timeout", "No response from server"); + else if (e is TimeoutException) { + showTimeoutError(context); } else { // Re-throw the error throw e; @@ -333,15 +333,13 @@ class InvenTreeModel { return null; } - if (dialog) { - hideProgressDialog(context); - } - // A list of "InvenTreeModel" items List results = new List(); if (response.statusCode != 200) { - print("Error retreiving data"); + showStatusCodeError(context, response.statusCode); + + // Return empty list return results; } @@ -399,8 +397,6 @@ class InvenTreeModel { return true; } - - } diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index ac98857b..c136a41b 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -155,13 +155,12 @@ class InvenTreePart extends InvenTreeModel { // Request stock items for this part Future getStockItems(BuildContext context, {bool showDialog=false}) async { - InvenTreeStockItem().list( + await InvenTreeStockItem().list( context, filters: { "part": "${pk}", "in_stock": "true", }, - dialog: showDialog, ).then((var items) { stockItems.clear(); @@ -186,7 +185,6 @@ class InvenTreePart extends InvenTreeModel { filters: { "part": "${pk}", }, - dialog: showDialog, ).then((var templates) { testingTemplates.clear(); @@ -205,6 +203,15 @@ class InvenTreePart extends InvenTreeModel { // Get the stock count for this Part double get inStock => double.tryParse(jsondata['in_stock'].toString() ?? '0'); + String get inStockString { + + if (inStock == inStock.toInt()) { + return inStock.toInt().toString(); + } else { + return inStock.toString(); + } + } + // Get the number of units being build for this Part double get building => double.tryParse(jsondata['building'].toString() ?? '0'); diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index c174e048..494b3aea 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -97,7 +97,6 @@ class InvenTreeStockItem extends InvenTreeModel { filters: { "part": "${partId}", }, - dialog: showDialog, ).then((var templates) { testTemplates.clear(); @@ -113,7 +112,7 @@ class InvenTreeStockItem extends InvenTreeModel { int get testResultCount => testResults.length; - Future getTestResults(BuildContext context, {bool showDialog=false}) async { + Future getTestResults(BuildContext context) async { await InvenTreeStockItemTestResult().list( context, @@ -121,7 +120,6 @@ class InvenTreeStockItem extends InvenTreeModel { "stock_item": "${pk}", "user_detail": "true", }, - dialog: showDialog, ).then((var results) { testResults.clear(); @@ -287,6 +285,11 @@ class InvenTreeStockItem extends InvenTreeModel { return 'SN ${serialNumber}'; } + // Is an integer? + if (quantity.toInt() == quantity) { + return '${quantity.toInt()}'; + } + return '${quantity}'; } diff --git a/lib/l10n b/lib/l10n index 58e2c502..90f3bbf1 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 58e2c5027b481a3a620c27b90ae20b888a02bd96 +Subproject commit 90f3bbf1fae86efd0bb0686bef12452a09507669 diff --git a/lib/main.dart b/lib/main.dart index 2c0e410a..f0a14c94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,12 +8,14 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'dsn.dart'; -import 'preferences.dart'; import 'package:sentry/sentry.dart'; // Use the secret app key -final SentryClient _sentry = SentryClient(dsn: SENTRY_DSN_KEY); +final SentryClient _sentry = SentryClient( + SentryOptions( + dsn: SENTRY_DSN_KEY, + )); bool isInDebugMode() { bool inDebugMode = false; @@ -31,13 +33,15 @@ Future _reportError(dynamic error, dynamic stackTrace) async { print(stackTrace); return; } else { - // Send the Exception and Stacktrace to Sentry in Production mode. - _sentry.captureException( - exception: error, - stackTrace: stackTrace, - ); - - print("Sending error to sentry.io"); + try { + await _sentry.captureException( + error, + stackTrace: stackTrace + ); + } catch (e) { + print("Sending error report to sentry.io failed: ${e}"); + print("Original error: ${error}"); + } } } @@ -47,12 +51,8 @@ void main() async { runZoned>(() async { runApp(InvenTreeApp()); - }, onError: (error, stackTrace) { - // Whenever an error occurs, call the `_reportError` function. This sends - // Dart errors to the dev console or Sentry depending on the environment. - _reportError(error, stackTrace); - }); - + }, onError: _reportError + ); } class InvenTreeApp extends StatelessWidget { diff --git a/lib/preferences.dart b/lib/preferences.dart index 91ced0b2..ea43720f 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'; @@ -34,30 +83,4 @@ class InvenTreePreferences { } InvenTreePreferences._internal(); - - // Load saved login details, and attempt connection - void loadLoginDetails(BuildContext context) async { - - print("Loading login details"); - - SharedPreferences prefs = await SharedPreferences.getInstance(); - - var server = prefs.getString(_SERVER) ?? ''; - var username = prefs.getString(_USERNAME) ?? ''; - var password = prefs.getString(_PASSWORD) ?? ''; - - await InvenTreeAPI().connectToServer(context, server, username, password); - } - - void saveLoginDetails(BuildContext context, String server, String username, String password) async { - - SharedPreferences prefs = await SharedPreferences.getInstance(); - - await prefs.setString(_SERVER, server); - await prefs.setString(_USERNAME, username); - await prefs.setString(_PASSWORD, password); - - // Reconnect the API - await InvenTreeAPI().connectToServer(context, server, username, password); - } } \ No newline at end of file diff --git a/lib/settings/about.dart b/lib/settings/about.dart index e0b07107..8f93a118 100644 --- a/lib/settings/about.dart +++ b/lib/settings/about.dart @@ -15,48 +15,96 @@ class InvenTreeAboutWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text("About InvenTree"), - ), - body: ListView( - children: [ - ListTile( - title: Text(I18N.of(context).serverDetails), - ), + List tiles = []; + + tiles.add( + ListTile( + title: Text( + I18N.of(context).serverDetails, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ) + ); + + if (InvenTreeAPI().isConnected()) { + tiles.add( ListTile( title: Text(I18N.of(context).address), subtitle: Text(InvenTreeAPI().baseUrl.isNotEmpty ? InvenTreeAPI().baseUrl : "Not connected"), - ), - ListTile( - title: Text(I18N.of(context).version), - subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : "Not connected"), - ), - ListTile( - title: Text("Server Instance"), - subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : "Not connected"), - ), - Divider(), - ListTile( - title: Text(I18N.of(context).appDetails), - ), - ListTile( - title: Text(I18N.of(context).name), - subtitle: Text("${info.appName}"), - ), - ListTile( - title: Text("Package Name"), - subtitle: Text("${info.packageName}"), - ), - ListTile( - title: Text(I18N.of(context).version), - subtitle: Text("${info.version}"), - ), - ListTile( - title: Text(I18N.of(context).build), - subtitle: Text("${info.buildNumber}"), ) - ], + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).version), + subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : "Not connected"), + ) + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).serverInstance), + subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : "Not connected"), + ) + ); + } else { + tiles.add( + ListTile( + title: Text(I18N.of(context).notConnected), + subtitle: Text( + I18N.of(context).serverNotConnected, + style: TextStyle(fontStyle: FontStyle.italic), + ) + ) + ); + } + + tiles.add( + ListTile( + title: Text( + I18N.of(context).appDetails, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ) + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).name), + subtitle: Text("${info.appName}"), + ) + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).packageName), + subtitle: Text("${info.packageName}"), + ) + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).version), + subtitle: Text("${info.version}"), + ) + ); + + tiles.add( + ListTile( + title: Text(I18N.of(context).build), + subtitle: Text("${info.buildNumber}"), + ) + ); + + return Scaffold( + appBar: AppBar( + title: Text(I18N.of(context).appAbout), + ), + body: ListView( + children: ListTile.divideTiles( + context: context, + tiles: tiles, + ).toList(), ) ); } diff --git a/lib/settings/login.dart b/lib/settings/login.dart index dbe95461..d0695020 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -1,37 +1,141 @@ +import 'package:InvenTree/widget/dialogs.dart'; +import 'package:InvenTree/widget/fields.dart'; +import 'package:InvenTree/widget/spinner.dart'; + import 'package:flutter/material.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(); - @override - _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(_preferences); + _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(); } class _InvenTreeLoginSettingsState extends State { + final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>(); + final GlobalKey _formKey = new GlobalKey(); - final SharedPreferences _preferences; + final GlobalKey _addProfileKey = new GlobalKey(); - String _server = ''; - String _username = ''; - String _password = ''; + List profiles; - _InvenTreeLoginSettingsState(this._preferences) : super() { - _server = _preferences.getString('server') ?? ''; - _username = _preferences.getString('username') ?? ''; - _password = _preferences.getString('password') ?? ''; + _InvenTreeLoginSettingsState() { + _reload(); } + void _reload() async { + + profiles = await UserProfileDBManager().getAllProfiles(); + + setState(() { + }); + } + + void _editProfile(BuildContext context, {UserProfile userProfile, bool createNew = false}) { + + var _name; + var _server; + var _username; + var _password; + + UserProfile profile; + + if (userProfile != null) { + profile = userProfile; + } + + 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: () { + if (_addProfileKey.currentState.validate()) { + _addProfileKey.currentState.save(); + + if (createNew) { + // TODO - create the new profile... + UserProfile profile = UserProfile( + name: _name, + server: _server, + username: _username, + password: _password + ); + + _addProfile(profile); + } else { + + profile.name = _name; + profile.server = _server; + profile.username = _username; + profile.password = _password; + + _updateProfile(profile); + + } + } + } + ) + ], + fields: [ + StringField( + label: I18N.of(context).name, + hint: "Enter profile name", + initial: createNew ? '' : profile.name, + onSaved: (value) => _name = value, + validator: _validateProfileName, + ), + StringField( + label: I18N.of(context).server, + hint: "http[s]://:", + initial: createNew ? '' : profile.server, + validator: _validateServer, + onSaved: (value) => _server = value, + ), + StringField( + label: I18N.of(context).username, + hint: "Enter username", + initial: createNew ? '' : profile.username, + onSaved: (value) => _username = value, + validator: _validateUsername, + ), + StringField( + label: I18N.of(context).password, + hint: "Enter password", + initial: createNew ? '' : profile.password, + onSaved: (value) => _password = value, + validator: _validatePassword, + ) + ] + ); + } + + String _validateProfileName(String value) { + + if (value.isEmpty) { + return 'Profile name cannot be empty'; + } + + // TODO: Check if a profile already exists with ths name + + return null; + } String _validateServer(String value) { @@ -43,6 +147,8 @@ class _InvenTreeLoginSettingsState extends State { return 'Server must start with http[s]'; } + // TODO: URL validator + return null; } @@ -62,12 +168,94 @@ class _InvenTreeLoginSettingsState extends State { return null; } - void _save(BuildContext context) async { - if (_formKey.currentState.validate()) { - _formKey.currentState.save(); + void _selectProfile(BuildContext context, UserProfile profile) async { - await InvenTreePreferences().saveLoginDetails(context, _server, _username, _password); + // Disconnect InvenTree + InvenTreeAPI().disconnectFromServer(); + await UserProfileDBManager().selectProfile(profile.key); + + _reload(); + + // Attempt server login (this will load the newly selected profile + InvenTreeAPI().connectToServer(_loginKey.currentContext).then((result) { + _reload(); + }); + + _reload(); + } + + void _deleteProfile(UserProfile profile) async { + + await UserProfileDBManager().deleteProfile(profile); + + // Close the dialog + Navigator.of(context).pop(); + + _reload(); + + if (InvenTreeAPI().isConnected() && profile.key == InvenTreeAPI().profile.key) { + InvenTreeAPI().disconnectFromServer(); + } + } + + void _updateProfile(UserProfile profile) async { + + await UserProfileDBManager().updateProfile(profile); + + // Dismiss the dialog + Navigator.of(context).pop(); + + _reload(); + + if (InvenTreeAPI().isConnected() && profile.key == InvenTreeAPI().profile.key) { + // Attempt server login (this will load the newly selected profile + + InvenTreeAPI().connectToServer(_loginKey.currentContext).then((result) { + _reload(); + }); + } + } + + void _addProfile(UserProfile profile) async { + + await UserProfileDBManager().addProfile(profile); + + // Dismiss the create dialog + Navigator.of(context).pop(); + + _reload(); + } + + Widget _getProfileIcon(UserProfile profile) { + + // Not selected? No icon for you! + if (profile == null || !profile.selected) return null; + + // Selected, but (for some reason) not the same as the API... + if (InvenTreeAPI().profile == null || InvenTreeAPI().profile.key != profile.key) { + return FaIcon( + FontAwesomeIcons.questionCircle, + color: Color.fromRGBO(250, 150, 50, 1) + ); + } + + // Reflect the connection status of the server + if (InvenTreeAPI().isConnected()) { + return FaIcon( + FontAwesomeIcons.checkCircle, + color: Color.fromRGBO(50, 250, 50, 1) + ); + } else if (InvenTreeAPI().isConnecting()) { + return Spinner( + icon: FontAwesomeIcons.spinner, + color: Color.fromRGBO(50, 50, 250, 1), + ); + } else { + return FaIcon( + FontAwesomeIcons.timesCircle, + color: Color.fromRGBO(250, 50, 50, 1), + ); } } @@ -76,64 +264,92 @@ class _InvenTreeLoginSettingsState extends State { final Size screenSize = MediaQuery.of(context).size; - return Scaffold( - appBar: AppBar( - title: Text("Login Settings"), - ), - body: new Container( - padding: new EdgeInsets.all(20.0), - child: new Form( - key: _formKey, - child: new ListView( - children: [ - Text(I18N.of(context).serverAddress), - new TextFormField( - initialValue: _server, - decoration: InputDecoration( - hintText: "127.0.0.1:8000", - ), - validator: _validateServer, - onSaved: (String value) { - _server = value; - }, - ), - Divider(), - Text(I18N.of(context).accountDetails), - TextFormField( - initialValue: _username, - decoration: InputDecoration( - hintText: I18N.of(context).username, - labelText: I18N.of(context).username, - ), - validator: _validateUsername, - onSaved: (String value) { - _username = value; + List children = []; + + if (profiles != null && profiles.length > 0) { + for (int idx = 0; idx < profiles.length; idx++) { + UserProfile profile = profiles[idx]; + + children.add(ListTile( + title: Text( + profile.name, + ), + tileColor: profile.selected ? Color.fromRGBO(0, 0, 0, 0.05) : null, + subtitle: Text("${profile.server}"), + trailing: _getProfileIcon(profile), + onTap: () { + _selectProfile(context, profile); + }, + onLongPress: () { + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(profile.name), + children: [ + Divider(), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _selectProfile(context, profile); + }, + child: Text(I18N.of(context).profileSelect), + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _editProfile(context, userProfile: profile); + }, + child: Text(I18N.of(context).profileEdit), + ), + SimpleDialogOption( + onPressed: () { + // Navigator.of(context, rootNavigator: true).pop(); + confirmationDialog( + context, + I18N.of(context).delete, + "Delete this profile?", + onAccept: () { + _deleteProfile(profile); + } + ); + }, + child: Text(I18N.of(context).profileDelete), + ) + ], + ); } - ), - TextFormField( - initialValue: _password, - obscureText: true, - decoration: InputDecoration( - hintText: I18N.of(context).password, - labelText: I18N.of(context).password, - ), - validator: _validatePassword, - onSaved: (String value) { - _password = value; - }, - ), - Container( - width: screenSize.width, - child: RaisedButton( - child: Text(I18N.of(context).save), - onPressed: () { - _save(context); - } - ) - ) - ], - ) + ); + }, + )); + } + } else { + // No profile available! + children.add( + ListTile( + title: Text("No profiles available"), ) + ); + } + + return Scaffold( + key: _loginKey, + appBar: AppBar( + title: Text(I18N.of(context).profileSelect), + ), + body: Container( + child: ListView( + children: ListTile.divideTiles( + context: context, + tiles: children + ).toList(), + ) + ), + floatingActionButton: FloatingActionButton( + child: Icon(FontAwesomeIcons.plus), + onPressed: () { + _editProfile(context, createNew: true); + }, ) ); } diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index a2cf1663..a93c8821 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'; @@ -28,37 +30,39 @@ class _InvenTreeSettingsState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("InvenTree Settings"), + title: Text(I18N.of(context).settings), ), body: Center( child: ListView( - children: [ - ListTile( - title: Text(I18N.of(context).serverSettings), - subtitle: Text("Configure server and login settings"), - leading: FaIcon(FontAwesomeIcons.server), - onTap: _editServerSettings, - ), - Divider(), - ListTile( - title: Text(I18N.of(context).about), - subtitle: Text(I18N.of(context).appDetails), - leading: FaIcon(FontAwesomeIcons.infoCircle), - onTap: _about, - ), - ListTile( - title: Text(I18N.of(context).releaseNotes), - subtitle: Text("Display app release notes"), - leading: FaIcon(FontAwesomeIcons.fileAlt), - onTap: _releaseNotes, - ), - ListTile( - title: Text(I18N.of(context).reportBug), - subtitle: Text("Report bug or suggest new feature"), - leading: FaIcon(FontAwesomeIcons.bug), - onTap: null, - ), - ], + children: ListTile.divideTiles( + context: context, + tiles: [ + ListTile( + title: Text(I18N.of(context).profile), + subtitle: Text("Configure user profile settings"), + leading: FaIcon(FontAwesomeIcons.user), + onTap: _editServerSettings, + ), + ListTile( + title: Text(I18N.of(context).about), + subtitle: Text(I18N.of(context).appDetails), + leading: FaIcon(FontAwesomeIcons.infoCircle), + onTap: _about, + ), + ListTile( + title: Text(I18N.of(context).releaseNotes), + subtitle: Text("Display app release notes"), + leading: FaIcon(FontAwesomeIcons.fileAlt), + onTap: _releaseNotes, + ), + ListTile( + title: Text(I18N.of(context).reportBug), + subtitle: Text("Report bug or suggest new feature"), + leading: FaIcon(FontAwesomeIcons.bug), + onTap: null, + ), + ] + ).toList() ) ) ); @@ -68,7 +72,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())); } void _about() async { diff --git a/lib/user_profile.dart b/lib/user_profile.dart new file mode 100644 index 00000000..cef17f31 --- /dev/null +++ b/lib/user_profile.dart @@ -0,0 +1,177 @@ + +/* + * Class for InvenTree user / login details + */ +import 'package:sembast/sembast.dart'; +import 'preferences.dart'; + +class UserProfile { + + UserProfile({ + this.key, + this.name, + this.server, + this.username, + this.password, + this.selected, + }); + + // ID of the profile + int key; + + // Name of the user profile + String name; + + // Base address of the InvenTree server + String server; + + // Username + String username; + + // Password + String password; + + bool selected = false; + + // User ID (will be provided by the server on log-in) + int user_id; + + factory UserProfile.fromJson(int key, Map json, bool isSelected) => UserProfile( + key: key, + name: json['name'], + server: json['server'], + username: json['username'], + password: json['password'], + selected: isSelected, + ); + + Map toJson() => { + "name": name, + "server": server, + "username": username, + "password": password, + }; + + @override + String toString() { + return "<${key}> ${name} : ${server} - ${username}:${password}"; + } +} + +class UserProfileDBManager { + + final store = StoreRef("profiles"); + + Future get _db async => await InvenTreePreferencesDB.instance.database; + + Future profileNameExists(String name) async { + + final finder = Finder(filter: Filter.equals("name", name)); + + final profiles = await store.find(await _db, finder: finder); + + return profiles.length > 0; + } + + Future addProfile(UserProfile profile) async { + + // Check if a profile already exists with the name + final bool exists = await profileNameExists(profile.name); + + if (exists) { + print("UserProfile '${profile.name}' already exists"); + return; + } + + int key = await store.add(await _db, profile.toJson()); + + print("Added user profile <${key}> - '${profile.name}'"); + + // Record the key + profile.key = key; + } + + Future selectProfile(int key) async { + /* + * Mark the particular profile as selected + */ + + final result = await store.record("selected").put(await _db, key); + + return result; + } + + Future updateProfile(UserProfile profile) async { + + if (profile.key == null) { + await addProfile(profile); + return; + } + + final result = await store.record(profile.key).update(await _db, profile.toJson()); + + print("Updated user profile <${profile.key}> - '${profile.name}'"); + + return result; + } + + Future deleteProfile(UserProfile profile) async { + final finder = Finder(filter: Filter.equals("name", profile.name)); + + await store.record(profile.key).delete(await _db); + print("Deleted user profile <${profile.key}> - '${profile.name}'"); + } + + Future getSelectedProfile() async { + /* + * Return the currently selected profile. + * + * key should match the "selected" property + */ + + final selected = await store.record("selected").get(await _db); + + final profiles = await store.find(await _db); + + List profileList = new List(); + + for (int idx = 0; idx < profiles.length; idx++) { + + if (profiles[idx].key is int && profiles[idx].key == selected) { + return UserProfile.fromJson( + profiles[idx].key, + profiles[idx].value, + profiles[idx].key == selected, + ); + } + } + + return null; + } + + /* + * Return all user profile objects + */ + Future> getAllProfiles() async { + + final selected = await store.record("selected").get(await _db); + + final profiles = await store.find(await _db); + + List profileList = new List(); + + for (int idx = 0; idx < profiles.length; idx++) { + + if (profiles[idx].key is int) { + profileList.add( + UserProfile.fromJson( + profiles[idx].key, + profiles[idx].value, + profiles[idx].key == selected, + )); + } + } + + return profileList; + } +} diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 5e26daf9..2a6c6722 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -231,9 +231,10 @@ class SubcategoryList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: ClampingScrollPhysics(), + separatorBuilder: (_, __) => const Divider(height: 3), itemBuilder: _build, itemCount: _categories.length); } } @@ -267,7 +268,7 @@ class PartList extends StatelessWidget { return ListTile( title: Text("${part.name}"), subtitle: Text("${part.description}"), - trailing: Text("${part.inStock}"), + trailing: Text("${part.inStockString}"), leading: InvenTreeAPI().getImage( part.thumbnail, width: 40, @@ -282,9 +283,10 @@ class PartList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: ClampingScrollPhysics(), + separatorBuilder: (_, __) => const Divider(height: 3), itemBuilder: _build, itemCount: _parts.length); } } diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index 1816df20..803476ab 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -3,6 +3,54 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.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( @@ -45,23 +93,53 @@ Future showErrorDialog(BuildContext context, String title, String descript showDialog( context: context, - child: SimpleDialog( - title: ListTile( - title: Text(error), - leading: FaIcon(icon), - ), - children: [ - ListTile( - title: Text(title), - subtitle: Text(description) - ) - ] - ) - ).then((value) { - if (onDismissed != null) { - onDismissed(); - } - }); + builder: (dialogContext) { + return SimpleDialog( + title: ListTile( + title: Text(error), + leading: FaIcon(icon), + ), + children: [ + ListTile( + title: Text(title), + subtitle: Text(description) + ) + ] + ); + }).then((value) { + if (onDismissed != null) { + onDismissed(); + } + }); +} + +Future showServerError(BuildContext context, String title, String description) async { + + if (title == null || title.isEmpty) { + title = I18N.of(context).serverError; + } + + await showErrorDialog( + context, + title, + description, + error: I18N.of(context).serverError, + icon: FontAwesomeIcons.server + ); +} + +Future showStatusCodeError(BuildContext context, int status, {int expected = 200}) async { + + await showServerError( + context, + "Invalid Response Code", + "Server responded with status code ${status}" + ); +} + +Future showTimeoutError(BuildContext context) async { + + await showServerError(context, I18N.of(context).timeout, I18N.of(context).noResponse); } void showProgressDialog(BuildContext context, String title, String description) { diff --git a/lib/widget/drawer.dart b/lib/widget/drawer.dart index cc9ec7b2..40e70631 100644 --- a/lib/widget/drawer.dart +++ b/lib/widget/drawer.dart @@ -3,6 +3,7 @@ import 'package:InvenTree/barcode.dart'; import 'package:InvenTree/widget/company_list.dart'; import 'package:InvenTree/widget/search.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:InvenTree/api.dart'; @@ -101,65 +102,65 @@ class InvenTreeDrawer extends StatelessWidget { Widget build(BuildContext context) { return Drawer( child: new ListView( - children: [ - new ListTile( - leading: new Image.asset( - "assets/image/icon.png", - fit: BoxFit.scaleDown, - width: 40, + children: ListTile.divideTiles( + context: context, + tiles: [ + new ListTile( + leading: new Image.asset( + "assets/image/icon.png", + fit: BoxFit.scaleDown, + width: 40, + ), + title: new Text(I18N.of(context).appTitle), + onTap: _home, ), - title: new Text("InvenTree"), - onTap: _home, - ), - new Divider(), - /* - // TODO - Add search functionality! - new ListTile( - title: new Text("Search"), - leading: new FaIcon(FontAwesomeIcons.search), - onTap: _search, - ), - */ - new ListTile( - title: new Text("Scan Barcode"), - onTap: _scan, - leading: new FaIcon(FontAwesomeIcons.barcode), - ), - new Divider(), - new ListTile( - title: new Text("Parts"), - leading: new Icon(Icons.category), - onTap: _showParts, - ), - new ListTile( - title: new Text("Stock"), - leading: new FaIcon(FontAwesomeIcons.boxes), - onTap: _showStock, - ), - /* - new ListTile( - title: new Text("Suppliers"), - leading: new FaIcon(FontAwesomeIcons.building), - onTap: _showSuppliers, - ), - new ListTile( - title: Text("Manufacturers"), - leading: new FaIcon(FontAwesomeIcons.industry), - onTap: _showManufacturers, - ), - new ListTile( - title: new Text("Customers"), - leading: new FaIcon(FontAwesomeIcons.users), - onTap: _showCustomers, - ), - */ - new Divider(), - new ListTile( - title: new Text("Settings"), - leading: new Icon(Icons.settings), - onTap: _settings, - ), - ] + /* + // TODO - Add search functionality! + new ListTile( + title: new Text("Search"), + leading: new FaIcon(FontAwesomeIcons.search), + onTap: _search, + ), + */ + new ListTile( + title: new Text(I18N.of(context).scanBarcode), + onTap: _scan, + leading: new FaIcon(FontAwesomeIcons.barcode), + ), + new ListTile( + title: new Text(I18N.of(context).parts), + leading: new Icon(Icons.category), + onTap: _showParts, + ), + new ListTile( + title: new Text(I18N.of(context).stock), + leading: new FaIcon(FontAwesomeIcons.boxes), + onTap: _showStock, + ), + /* + new ListTile( + title: new Text("Suppliers"), + leading: new FaIcon(FontAwesomeIcons.building), + onTap: _showSuppliers, + ), + new ListTile( + title: Text("Manufacturers"), + leading: new FaIcon(FontAwesomeIcons.industry), + onTap: _showManufacturers, + ), + new ListTile( + title: new Text("Customers"), + leading: new FaIcon(FontAwesomeIcons.users), + onTap: _showCustomers, + ), + */ + new ListTile( + title: new Text(I18N.of(context).settings), + leading: new Icon(Icons.settings), + onTap: _settings, + ), + ] + ).toList(), ) ); } diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 0a2d0129..3955668e 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -1,18 +1,21 @@ +import 'package:InvenTree/user_profile.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:InvenTree/barcode.dart'; import 'package:InvenTree/api.dart'; +import 'package:InvenTree/settings/login.dart'; + import 'package:InvenTree/widget/category_display.dart'; import 'package:InvenTree/widget/company_list.dart'; import 'package:InvenTree/widget/location_display.dart'; import 'package:InvenTree/widget/search.dart'; +import 'package:InvenTree/widget/spinner.dart'; import 'package:InvenTree/widget/drawer.dart'; class InvenTreeHomePage extends StatefulWidget { @@ -24,70 +27,18 @@ class InvenTreeHomePage extends StatefulWidget { class _InvenTreeHomePageState extends State { + final GlobalKey<_InvenTreeHomePageState> _homeKey = GlobalKey<_InvenTreeHomePageState>(); + _InvenTreeHomePageState() : super() { + + // Initially load the profile and attempt server connection + _loadProfile(); } - String _serverAddress = ""; + // Selected user profile + UserProfile _profile; - String _serverStatus = "Connecting to server"; - - String _serverMessage = ""; - - bool _serverConnection = false; - - FaIcon _serverIcon = new FaIcon(FontAwesomeIcons.spinner); - - Color _serverStatusColor = Color.fromARGB(255, 50, 50, 250); - - void onConnectSuccess(String msg) { - _serverConnection = true; - _serverMessage = msg; - _serverStatus = "Connected to $_serverAddress"; - _serverStatusColor = Color.fromARGB(255, 50, 250, 50); - _serverIcon = new FaIcon(FontAwesomeIcons.checkCircle, color: _serverStatusColor); - - setState(() {}); - } - - void onConnectFailure(String msg) { - _serverConnection = false; - _serverMessage = msg; - _serverStatus = "Could not connect to $_serverAddress"; - _serverStatusColor = Color.fromARGB(255, 250, 50, 50); - _serverIcon = new FaIcon(FontAwesomeIcons.timesCircle, color: _serverStatusColor); - - setState(() {}); - } - - /* - * Test the server connection - */ - void _checkServerConnection(BuildContext context) async { - - var prefs = await SharedPreferences.getInstance(); - - _serverAddress = prefs.getString("server"); - - // Reset the connection status variables - _serverStatus = "Connecting to server"; - _serverMessage = ""; - _serverConnection = false; - _serverIcon = new FaIcon(FontAwesomeIcons.spinner); - _serverStatusColor = Color.fromARGB(255, 50, 50, 250); - - InvenTreeAPI().connect(context).then((bool result) { - - if (result) { - onConnectSuccess(""); - } else { - onConnectFailure("Could not connect to server"); - } - - }); - - // Update widget state - setState(() {}); - } + BuildContext _context; void _search() { if (!InvenTreeAPI().checkConnection(context)) return; @@ -96,19 +47,19 @@ class _InvenTreeHomePageState extends State { } - void _scan() { + void _scan(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; scanQrCode(context); } - void _parts() { + void _parts(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); } - void _stock() { + void _stock(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); @@ -132,6 +83,15 @@ class _InvenTreeHomePageState extends State { Navigator.push(context, MaterialPageRoute(builder: (context) => CustomerListWidget())); } + void _selectProfile() { + Navigator.push( + context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget()) + ).then((context) { + // Once we return + _loadProfile(); + }); + } + void _unsupported() { showDialog( context: context, @@ -147,8 +107,92 @@ class _InvenTreeHomePageState extends State { ); } + + void _loadProfile() async { + + _profile = await UserProfileDBManager().getSelectedProfile(); + + // A valid profile was loaded! + if (_profile != null) { + if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) { + + // Attempt server connection + InvenTreeAPI().connectToServer(_homeKey.currentContext).then((result) { + setState(() {}); + }); + } + } + + setState(() {}); + } + + ListTile _serverTile() { + + // No profile selected + // Tap to select / create a profile + if (_profile == null) { + return ListTile( + title: Text("No Profile Selected"), + subtitle: Text("Tap to create or select a profile"), + leading: FaIcon(FontAwesomeIcons.server), + trailing: FaIcon( + FontAwesomeIcons.user, + color: Color.fromRGBO(250, 50, 50, 1), + ), + onTap: () { + _selectProfile(); + }, + ); + } + + // Profile is selected ... + if (InvenTreeAPI().isConnecting()) { + return ListTile( + title: Text("Connecting to server..."), + subtitle: Text("${InvenTreeAPI().baseUrl}"), + leading: FaIcon(FontAwesomeIcons.server), + trailing: Spinner( + icon: FontAwesomeIcons.spinner, + color: Color.fromRGBO(50, 50, 250, 1), + ), + onTap: () { + _selectProfile(); + } + ); + } else if (InvenTreeAPI().isConnected()) { + return ListTile( + title: Text("Connected to server"), + subtitle: Text("${InvenTreeAPI().baseUrl}"), + leading: FaIcon(FontAwesomeIcons.server), + trailing: FaIcon( + FontAwesomeIcons.checkCircle, + color: Color.fromRGBO(50, 250, 50, 1) + ), + onTap: () { + _selectProfile(); + }, + ); + } else { + return ListTile( + title: Text("Could not connect to server"), + subtitle: Text("${_profile.server}"), + leading: FaIcon(FontAwesomeIcons.server), + trailing: FaIcon( + FontAwesomeIcons.timesCircle, + color: Color.fromRGBO(250, 50, 50, 1), + ), + onTap: () { + _selectProfile(); + }, + ); + } + } + @override Widget build(BuildContext context) { + + _context = context; + // This method is rerun every time setState is called, for instance as done // by the _incrementCounter method above. // @@ -156,6 +200,7 @@ class _InvenTreeHomePageState extends State { // fast, so that you can just rebuild anything that needs updating rather // than having to individually change instances of widgets. return Scaffold( + key: _homeKey, appBar: AppBar( title: Text(I18N.of(context).appTitle), actions: [ @@ -197,7 +242,7 @@ class _InvenTreeHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.barcode), tooltip: I18N.of(context).scanBarcode, - onPressed: _scan, + onPressed: () { _scan(context); }, ), Text(I18N.of(context).scanBarcode), ], @@ -213,7 +258,7 @@ class _InvenTreeHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.shapes), tooltip: I18N.of(context).parts, - onPressed: _parts, + onPressed: () { _parts(context); }, ), Text(I18N.of(context).parts), ], @@ -223,7 +268,7 @@ class _InvenTreeHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.boxes), tooltip: I18N.of(context).stock, - onPressed: _stock, + onPressed: () { _stock(context); }, ), Text(I18N.of(context).stock), ], @@ -306,25 +351,13 @@ class _InvenTreeHomePageState extends State { ), Spacer(), */ + Spacer(), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: ListTile( - title: Text("$_serverStatus", - style: TextStyle(color: _serverStatusColor), - ), - subtitle: Text("$_serverMessage", - style: TextStyle(color: _serverStatusColor), - ), - leading: _serverIcon, - onTap: () { - if (!_serverConnection) { - _checkServerConnection(context); - } - }, - ), + child: _serverTile(), ), ], ), diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index 6e8563de..ef75f408 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -6,7 +6,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; - +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:InvenTree/widget/refreshable_state.dart'; class LocationDisplayWidget extends StatefulWidget { @@ -97,8 +97,8 @@ class _LocationDisplayState extends RefreshableState { if (location == null) { return Card( child: ListTile( - title: Text("Stock Locations"), - subtitle: Text("Top level stock location") + title: Text(I18N.of(context).stockLocations), + subtitle: Text(I18N.of(context).stockTopLevel), ) ); } else { @@ -135,14 +135,14 @@ class _LocationDisplayState extends RefreshableState { return BottomNavigationBar( currentIndex: tabIndex, onTap: onTabSelectionChanged, - items: const [ + items: [ BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.boxes), - title: Text("Stock"), + title: Text(I18N.of(context).stock), ), BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.wrench), - title: Text("Actions"), + title: Text(I18N.of(context).actions), ) ] ); @@ -156,7 +156,10 @@ class _LocationDisplayState extends RefreshableState { ); case 1: return ListView( - children: actionTiles(), + children: ListTile.divideTiles( + context: context, + tiles: actionTiles() + ).toList() ); default: return null; @@ -298,10 +301,11 @@ class SublocationList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: ClampingScrollPhysics(), itemBuilder: _build, + separatorBuilder: (_, __) => const Divider(height: 3), itemCount: _locations.length ); } @@ -342,9 +346,10 @@ class StockList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: ClampingScrollPhysics(), + separatorBuilder: (_, __) => const Divider(height: 3), itemBuilder: _build, itemCount: _items.length); } } \ No newline at end of file diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 45f2fcfe..64cedbe9 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:InvenTree/inventree/part.dart'; import 'package:InvenTree/widget/full_screen_image.dart'; @@ -82,6 +83,7 @@ class _PartDisplayState extends RefreshableState { void _showStock(BuildContext context) async { await part.getStockItems(context); + Navigator.push( context, MaterialPageRoute(builder: (context) => PartStockDetailWidget(part)) @@ -213,9 +215,9 @@ class _PartDisplayState extends RefreshableState { // Stock information tiles.add( ListTile( - title: Text("Stock"), + title: Text(I18N.of(context).stock), leading: FaIcon(FontAwesomeIcons.boxes), - trailing: Text("${part.inStock}"), + trailing: Text("${part.inStockString}"), onTap: () { _showStock(context); }, @@ -229,7 +231,9 @@ class _PartDisplayState extends RefreshableState { title: Text("On Order"), leading: FaIcon(FontAwesomeIcons.shoppingCart), trailing: Text("${part.onOrder}"), - onTap: null, + onTap: () { + // TODO: Click through to show items on order + }, ) ); } @@ -325,7 +329,7 @@ class _PartDisplayState extends RefreshableState { tiles.add( ListTile( - title: Text("Create Stock Item"), + title: Text(I18N.of(context).stockItemCreate), leading: FaIcon(FontAwesomeIcons.box), onTap: null, ) @@ -349,13 +353,19 @@ class _PartDisplayState extends RefreshableState { case 0: return Center( child: ListView( - children: partTiles(), + children: ListTile.divideTiles( + context: context, + tiles: partTiles() + ).toList() ), ); case 1: return Center( child: ListView( - children: actionTiles(), + children: ListTile.divideTiles( + context: context, + tiles: actionTiles() + ).toList() ) ); default: @@ -368,14 +378,14 @@ class _PartDisplayState extends RefreshableState { return BottomNavigationBar( currentIndex: tabIndex, onTap: onTabSelectionChanged, - items: const [ + items: [ BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.infoCircle), - title: Text("Details"), + title: Text(I18N.of(context).details), ), BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.wrench), - title: Text("Actions"), + title: Text(I18N.of(context).actions), ), ] ); diff --git a/lib/widget/part_stock_detail.dart b/lib/widget/part_stock_detail.dart index 36528351..521d7025 100644 --- a/lib/widget/part_stock_detail.dart +++ b/lib/widget/part_stock_detail.dart @@ -38,7 +38,6 @@ class _PartStockDisplayState extends RefreshableState { @override Future onBuild(BuildContext context) async { refresh(); - print("onBuild"); } @override @@ -100,10 +99,11 @@ class PartStockList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: ClampingScrollPhysics(), itemBuilder: _build, + separatorBuilder: (_, __) => const Divider(height: 3), itemCount: _items.length ); } diff --git a/lib/widget/spinner.dart b/lib/widget/spinner.dart new file mode 100644 index 00000000..5bab6c4f --- /dev/null +++ b/lib/widget/spinner.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; + +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class Spinner extends StatefulWidget { + final IconData icon; + final Duration duration; + final Color color; + + const Spinner({ + this.color = const Color.fromRGBO(150, 150, 150, 1), + Key key, + @required this.icon, + this.duration = const Duration(milliseconds: 1800), + }) : super(key: key); + + @override + _SpinnerState createState() => _SpinnerState(); +} + +class _SpinnerState extends State with SingleTickerProviderStateMixin { + AnimationController _controller; + Widget _child; + + @override + void initState() { + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 2000), + ) + ..repeat(); + _child = FaIcon( + widget.icon, + color: widget.color + ); + + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _controller, + child: _child, + ); + } +} \ No newline at end of file diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 10bf44b2..95d9fb9d 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -303,6 +303,7 @@ class _StockItemDisplayState extends RefreshableState { title: Text("${item.partName}"), subtitle: Text("${item.partDescription}"), leading: InvenTreeAPI().getImage(item.partImage), + trailing: Text(item.serialOrQuantityDisplay()), ) ); } @@ -365,7 +366,7 @@ class _StockItemDisplayState extends RefreshableState { if (item.isSerialized()) { tiles.add( ListTile( - title: Text("Serial Number"), + title: Text(I18N.of(context).serialNumber), leading: FaIcon(FontAwesomeIcons.hashtag), trailing: Text("${item.serialNumber}"), ) @@ -381,7 +382,8 @@ class _StockItemDisplayState extends RefreshableState { } // Supplier part? - if (item.supplierPartId > 0) { + // TODO: Display supplier part info page? + if (false && item.supplierPartId > 0) { tiles.add( ListTile( title: Text("${item.supplierName}"), @@ -410,7 +412,12 @@ class _StockItemDisplayState extends RefreshableState { leading: FaIcon(FontAwesomeIcons.tasks), trailing: Text("${item.testResultCount}"), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => StockItemTestResultsWidget(item))); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => StockItemTestResultsWidget(item)) + ).then((context) { + refresh(); + }); } ) ); @@ -421,7 +428,12 @@ class _StockItemDisplayState extends RefreshableState { title: Text(I18N.of(context).history), leading: FaIcon(FontAwesomeIcons.history), trailing: Text("${item.trackingItemCount}"), - onTap: null, + onTap: () { + // TODO: Load tracking history + + // TODO: Push tracking history page to the route + + }, ) ); } @@ -432,7 +444,10 @@ class _StockItemDisplayState extends RefreshableState { title: Text(I18N.of(context).notes), leading: FaIcon(FontAwesomeIcons.stickyNote), trailing: Text(""), - onTap: null, + onTap: () { + // TODO: Load notes in markdown viewer widget + // TODO: Make this widget editable? + } ) ); } @@ -579,11 +594,17 @@ class _StockItemDisplayState extends RefreshableState { switch (index) { case 0: return ListView( - children: detailTiles(), + children: ListTile.divideTiles( + context: context, + tiles: detailTiles() + ).toList(), ); case 1: return ListView( - children: actionTiles(), + children: ListTile.divideTiles( + context: context, + tiles: actionTiles() + ).toList() ); default: return null; diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index 0563b355..1ff75209 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -250,7 +250,10 @@ class _StockItemTestResultDisplayState extends RefreshableState=2.1.0 <3.0.0" @@ -30,18 +30,19 @@ dependencies: shared_preferences: ^0.5.7 cached_network_image: ^2.5.0 - 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: