From 8736655fb12c6d9a8fe94a2a2021f2668789486b Mon Sep 17 00:00:00 2001 From: Nico Mexis Date: Thu, 1 Sep 2022 15:51:09 +0200 Subject: [PATCH] Allow exporting current view to CSV --- analysis_options.yaml | 2 +- lib/bloc/cubits/data_cubit.dart | 53 +++++++++------ lib/bloc/cubits/export_cubit.dart | 52 +++++++++++++++ lib/bloc/states.dart | 65 +++++++++++++++---- lib/config/locale_config.dart | 3 + lib/constants.dart | 5 ++ lib/data/fl_spot.dart | 18 ++++++ lib/data/trend.dart | 5 ++ lib/main.dart | 4 ++ lib/navigation/header.dart | 103 +++++++++++++++++++----------- lib/pages/home.dart | 40 ++++++++---- lib/util/math.dart | 9 +++ lib/util/navigation.dart | 12 ++++ pubspec.lock | 16 +++++ pubspec.yaml | 7 ++ 15 files changed, 310 insertions(+), 84 deletions(-) create mode 100644 lib/bloc/cubits/export_cubit.dart create mode 100644 lib/data/fl_spot.dart create mode 100644 lib/util/math.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index dc74740..736165a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -101,7 +101,7 @@ linter: - avoid_unnecessary_containers - avoid_unused_constructor_parameters - avoid_void_async - - avoid_web_libraries_in_flutter + # - avoid_web_libraries_in_flutter - await_only_futures - camel_case_extensions - camel_case_types diff --git a/lib/bloc/cubits/data_cubit.dart b/lib/bloc/cubits/data_cubit.dart index 10b37b4..1c66fd7 100644 --- a/lib/bloc/cubits/data_cubit.dart +++ b/lib/bloc/cubits/data_cubit.dart @@ -1,8 +1,8 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:powasys_frontend/bloc/repos/data_repo.dart'; import 'package:powasys_frontend/bloc/states.dart'; +import 'package:powasys_frontend/data/fl_spot.dart'; import 'package:powasys_frontend/data/trend.dart'; import 'package:powasys_frontend/util/hex_color.dart'; import 'package:tuple/tuple.dart'; @@ -10,7 +10,7 @@ import 'package:tuple/tuple.dart'; class DataCubit extends Cubit { final DataRepo _dataRepo; - DataCubit(this._dataRepo) : super(const DataState(PowaSysState.notFetched)); + DataCubit(this._dataRepo) : super(const DataState(DataFetchState.notFetched)); Future fetchData({ required List disabledPowadors, @@ -18,7 +18,7 @@ class DataCubit extends Cubit { required int minDiv, }) async { try { - emit(state.copyWith(state: PowaSysState.fetching)); + emit(state.copyWith(state: DataFetchState.fetching)); final powadors = >{}; for (final entry in await _dataRepo.getPowas()) { @@ -45,39 +45,50 @@ class DataCubit extends Cubit { final averages = >{}; for (final entry in parsed24h['averages']) { - averages[int.parse(entry['powadorId'].toString())] = - Trend.values.asMap().map( - (id, trend) => - MapEntry(trend, double.parse(entry[trend.id].toString())), - ); + averages[int.parse(entry['powadorId'].toString())] = Trend.values + .where((t) => t != Trend.state) + .toList(growable: false) + .asMap() + .map( + (id, trend) => + MapEntry(trend, double.parse(entry[trend.id].toString())), + ); } final max = >{}; for (final entry in parsed24h['max']) { - max[int.parse(entry['powadorId'].toString())] = - Trend.values.asMap().map( - (id, trend) => - MapEntry(trend, double.parse(entry[trend.id].toString())), - ); + max[int.parse(entry['powadorId'].toString())] = Trend.values + .where((t) => t != Trend.state) + .toList(growable: false) + .asMap() + .map( + (id, trend) => + MapEntry(trend, double.parse(entry[trend.id].toString())), + ); } var minVal = 0.0; var maxVal = 0.0; - final data = Map>.fromEntries( + final data = Map>.fromEntries( powadors.keys.map((e) => MapEntry(e, [])), ); for (final entry in parsed24h['data']) { final powaId = int.parse(entry['powadorId'].toString()); if (!disabledPowadors.contains(powaId)) { - final value = double.parse(entry[currentTrend.id].toString()); + final values = { + for (var t in Trend.values) + t: entry[t.id] == null + ? null + : double.parse(entry[t.id].toString()) + }; + final value = values[currentTrend]!; minVal = value < minVal ? value : minVal; maxVal = value > maxVal ? value : maxVal; data[powaId]!.add( - FlSpot( - DateTime.parse(entry['time'].toString()) - .millisecondsSinceEpoch - .toDouble(), + PowaSpot.fromDateTime( + DateTime.parse(entry['time'].toString()), value, + values, ), ); } @@ -85,7 +96,7 @@ class DataCubit extends Cubit { emit( state.copyWith( - state: PowaSysState.fetchedData, + state: DataFetchState.fetchedData, powadors: powadors, minVal: minVal, maxVal: maxVal, @@ -96,7 +107,7 @@ class DataCubit extends Cubit { ), ); } catch (e) { - emit(state.copyWith(state: PowaSysState.fetchError)); + emit(state.copyWith(state: DataFetchState.fetchError, ex: e)); } } } diff --git a/lib/bloc/cubits/export_cubit.dart b/lib/bloc/cubits/export_cubit.dart new file mode 100644 index 0000000..d224b6e --- /dev/null +++ b/lib/bloc/cubits/export_cubit.dart @@ -0,0 +1,52 @@ +// ignore_for_file: missing_whitespace_between_adjacent_strings +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:powasys_frontend/bloc/states.dart'; +import 'package:powasys_frontend/constants.dart'; +import 'package:powasys_frontend/data/fl_spot.dart'; +import 'package:powasys_frontend/data/trend.dart'; +import 'package:tuple/tuple.dart'; + +class ExportCubit extends Cubit { + ExportCubit() : super(const ExportState(ExportGenState.notStarted)); + + Future exportData(Map> data) async { + try { + emit(state.copyWith(state: ExportGenState.exporting)); + + var toExport = 'Powador ID;Time;State;Generator Voltage;' + 'Generator Current;Generator Power;Net Voltage;Net Current;Net Power;' + 'Temperature\n'; + + final entries = data.entries + .expand((e) => e.value.map((f) => Tuple2(f, e.key))) + .toList(growable: false); + entries.sort((a, b) { + final comp = a.item1.compare(b.item1); + return comp != 0 ? comp : a.item2 - b.item2; + }); + toExport += entries + .map( + (e) => '${e.item2};' + '${e.item1.time};' + '${e.item1.values[Trend.state] as int?};' + '${decimalFormatOne.format(e.item1.values[Trend.genVoltage])};' + '${decimalFormatTwo.format(e.item1.values[Trend.genCurrent])};' + '${e.item1.values[Trend.genPower] as int?};' + '${decimalFormatOne.format(e.item1.values[Trend.netVoltage])};' + '${decimalFormatTwo.format(e.item1.values[Trend.netCurrent])};' + '${e.item1.values[Trend.netPower] as int?};' + '${e.item1.values[Trend.temperature] as int?}', + ) + .join('\n'); + + emit( + state.copyWith( + state: ExportGenState.exported, + toExport: toExport, + ), + ); + } catch (e) { + emit(state.copyWith(state: ExportGenState.exportError, ex: e)); + } + } +} diff --git a/lib/bloc/states.dart b/lib/bloc/states.dart index da15155..9310839 100644 --- a/lib/bloc/states.dart +++ b/lib/bloc/states.dart @@ -1,25 +1,30 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +import 'package:powasys_frontend/data/fl_spot.dart'; import 'package:powasys_frontend/data/trend.dart'; import 'package:tuple/tuple.dart'; -class BlocState { - final PowaSysState state; +class BlocState { + final State state; + final dynamic ex; - const BlocState(this.state); + const BlocState( + this.state, { + this.ex, + }); } -class DataState extends BlocState { +class DataState extends BlocState { final double minVal; final double maxVal; final Map> powadors; final Map>> latest; final Map> averages; final Map> max; - final Map> data; + final Map> data; const DataState( super.state, { + super.ex, this.minVal = 0, this.maxVal = 0, this.powadors = const {}, @@ -30,17 +35,19 @@ class DataState extends BlocState { }); DataState copyWith({ - PowaSysState? state, + DataFetchState? state, + dynamic ex, double? minVal, double? maxVal, Map>? powadors, Map>>? latest, Map>? averages, Map>? max, - Map>? data, + Map>? data, }) => DataState( - state ?? this.state, + state ?? super.state, + ex: ex ?? super.ex, minVal: minVal ?? this.minVal, maxVal: maxVal ?? this.maxVal, powadors: powadors ?? this.powadors, @@ -51,7 +58,7 @@ class DataState extends BlocState { ); } -enum PowaSysState { +enum DataFetchState { notFetched(), fetching(), fetchedData(finished: true), @@ -60,7 +67,43 @@ enum PowaSysState { final bool finished; final bool errored; - const PowaSysState({ + const DataFetchState({ + this.finished = false, + this.errored = false, + }); +} + +class ExportState extends BlocState { + final String toExport; + + const ExportState( + super.state, { + super.ex, + this.toExport = '', + }); + + ExportState copyWith({ + ExportGenState? state, + dynamic ex, + String? toExport, + }) => + ExportState( + state ?? super.state, + ex: ex ?? super.ex, + toExport: toExport ?? this.toExport, + ); +} + +enum ExportGenState { + notStarted(), + exporting(), + exported(finished: true), + exportError(finished: true, errored: true); + + final bool finished; + final bool errored; + + const ExportGenState({ this.finished = false, this.errored = false, }); diff --git a/lib/config/locale_config.dart b/lib/config/locale_config.dart index ea009ca..88b8842 100644 --- a/lib/config/locale_config.dart +++ b/lib/config/locale_config.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:hive/hive.dart'; +import 'package:intl/intl.dart'; import 'package:powasys_frontend/l10n/lang_chooser.dart'; const currentLocaleKey = 'locale.current'; @@ -12,6 +13,7 @@ class LocaleSettings extends ChangeNotifier { LocaleSettings(this._box) { if (_box.containsKey(currentLocaleKey)) { _locale = _box.get(currentLocaleKey) as String; + Intl.defaultLocale = _locale; } else { _box.put(currentLocaleKey, _locale); } @@ -26,6 +28,7 @@ class LocaleSettings extends ChangeNotifier { void setLocale(String locale) { _locale = locale; _box.put(currentLocaleKey, _locale); + Intl.defaultLocale = _locale; notifyListeners(); } } diff --git a/lib/constants.dart b/lib/constants.dart index c058501..06f527d 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,4 +1,5 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:powasys_frontend/data/trend.dart'; @@ -10,6 +11,10 @@ String get apiEndpoint24h => dotenv.env['APIEndpoint24h']!; late PackageInfo packageInfo; +final decimalFormatOne = NumberFormat('##0.0'); + +final decimalFormatTwo = NumberFormat('##0.00'); + // Cached Settings which should be gone after reloading List disabledPowadors = []; Trend currentTrend = Trend.netPower; diff --git a/lib/data/fl_spot.dart b/lib/data/fl_spot.dart new file mode 100644 index 0000000..3b970d4 --- /dev/null +++ b/lib/data/fl_spot.dart @@ -0,0 +1,18 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:powasys_frontend/data/trend.dart'; +import 'package:powasys_frontend/util/math.dart'; + +class PowaSpot extends FlSpot { + final DateTime time; + final Map values; + + PowaSpot(super.x, super.y, this.time, this.values); + + PowaSpot.fromDateTime(DateTime time, double y, Map values) + : this(time.millisecondsSinceEpoch.toDouble(), y, time, values); + + int compare(PowaSpot other) { + final comp = signum(x - other.x); + return comp != 0 ? comp : signum(y - other.y); + } +} diff --git a/lib/data/trend.dart b/lib/data/trend.dart index 4190ec6..c21543d 100644 --- a/lib/data/trend.dart +++ b/lib/data/trend.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:powasys_frontend/generated/l10n.dart'; enum Trend { + state(id: 'state'), genVoltage(id: 'genVoltage'), genCurrent(id: 'genCurrent'), genPower(id: 'genPower'), @@ -17,6 +18,8 @@ enum Trend { String name(BuildContext context) { switch (this) { + case Trend.state: + return 'State'; // TODO(Nico): I18n! case Trend.genVoltage: return S.of(context).genVoltage; case Trend.genCurrent: @@ -36,6 +39,8 @@ enum Trend { String unit(BuildContext context) { switch (this) { + case Trend.state: + return ''; case Trend.genVoltage: return S.of(context).voltage_unit; case Trend.genCurrent: diff --git a/lib/main.dart b/lib/main.dart index a1a52ab..1362895 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:powasys_frontend/bloc/cubits/data_cubit.dart'; +import 'package:powasys_frontend/bloc/cubits/export_cubit.dart'; import 'package:powasys_frontend/bloc/repos/data_repo.dart'; import 'package:powasys_frontend/config/config.dart'; import 'package:powasys_frontend/constants.dart'; @@ -49,6 +50,9 @@ class _PowaSysFrontendWidgetState extends State { BlocProvider( create: (context) => DataCubit(context.read()), ), + BlocProvider( + create: (context) => ExportCubit(), + ), ], child: MaterialApp( onGenerateTitle: (context) => S.of(context).app_name, diff --git a/lib/navigation/header.dart b/lib/navigation/header.dart index b68043a..cb86157 100644 --- a/lib/navigation/header.dart +++ b/lib/navigation/header.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:powasys_frontend/bloc/cubits/data_cubit.dart'; +import 'package:powasys_frontend/bloc/cubits/export_cubit.dart'; +import 'package:powasys_frontend/bloc/states.dart'; import 'package:powasys_frontend/config/config.dart'; import 'package:powasys_frontend/generated/l10n.dart'; import 'package:powasys_frontend/pages/home.dart'; @@ -35,52 +39,73 @@ class PopMenu extends StatelessWidget { const PopMenu(this.packageInfo, {super.key}); @override - Widget build(BuildContext context) => PopupMenuButton( - icon: Icon( - Icons.more_vert, - color: Theme.of(context) - .textButtonTheme - .style! - .foregroundColor! - .resolve({MaterialState.focused}), - ), - onSelected: (d) { - switch (d) { - case PopupItems.license: - showAboutDialog( + Widget build(BuildContext context) => BlocBuilder( + builder: (context, stateD) => BlocListener( + listener: (context, stateE) { + if (stateE.state.errored) { + showDialog( context: context, - applicationName: S.of(context).app_name, - applicationLegalese: - sprintf(S.of(context).copyright, [DateTime.now().year]), - applicationVersion: packageInfo.version, - // TODO(Nico): Icon? - /*applicationIcon: Image.asset( + builder: (context) => AlertDialog( + title: const Text('Exception'), + content: Text(stateE.ex.toString()), + ), + ); + } else if (stateE.state.finished) { + downloadBytes(stateE.toExport.codeUnits, 'export.csv'); + } + }, + child: PopupMenuButton( + icon: Icon( + Icons.more_vert, + color: Theme.of(context) + .textButtonTheme + .style! + .foregroundColor! + .resolve({MaterialState.focused}), + ), + onSelected: (d) { + switch (d) { + case PopupItems.export: + context.read().exportData(stateD.data); + break; + case PopupItems.license: + showAboutDialog( + context: context, + applicationName: S.of(context).app_name, + applicationLegalese: + sprintf(S.of(context).copyright, [DateTime.now().year]), + applicationVersion: packageInfo.version, + // TODO(Nico): Icon? + /*applicationIcon: Image.asset( 'assets/logo.png', width: 50, ),*/ - applicationIcon: const Icon( - Icons.code, - size: 50, - ), - ); - break; - case PopupItems.theme: - themeSettings.setTheme(isDark: !themeSettings.isDark); - break; - } - }, - itemBuilder: (context) => PopupItems.values - .map( - (item) => PopupMenuItem( - value: item, - child: _PopupItem(item.icon, item.name(context)), - ), - ) - .toList(), + applicationIcon: const Icon( + Icons.code, + size: 50, + ), + ); + break; + case PopupItems.theme: + themeSettings.setTheme(isDark: !themeSettings.isDark); + break; + } + }, + itemBuilder: (context) => PopupItems.values + .map( + (item) => PopupMenuItem( + value: item, + child: _PopupItem(item.icon, item.name(context)), + ), + ) + .toList(), + ), + ), ); } enum PopupItems { + export(icon: Icons.download), theme(icon: Icons.brightness_medium), license(icon: Icons.article_outlined); @@ -90,6 +115,8 @@ enum PopupItems { String name(BuildContext context) { switch (this) { + case PopupItems.export: + return 'Export'; // TODO(Nico): I18n! case PopupItems.license: return S.of(context).license; case PopupItems.theme: diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 1e18add..17e09c0 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:powasys_frontend/bloc/cubits/data_cubit.dart'; +import 'package:powasys_frontend/bloc/states.dart'; import 'package:powasys_frontend/constants.dart'; import 'package:powasys_frontend/data/divider_settings.dart'; import 'package:powasys_frontend/data/powa_settings.dart'; @@ -44,19 +45,32 @@ class _HomeState extends State { ], ), bottomNavigationBar: const Footer(), - body: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - primary: true, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - TrendTable(), - TrendDiagram(), - DividerSettings(), - PowaSettings(), - TrendSettings(), - ], + body: BlocListener( + listener: (context, state) { + if (state.state.errored) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exception'), + content: Text(state.ex.toString()), + ), + ); + } + }, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + TrendTable(), + TrendDiagram(), + DividerSettings(), + PowaSettings(), + TrendSettings(), + ], + ), ), ), ), diff --git a/lib/util/math.dart b/lib/util/math.dart new file mode 100644 index 0000000..7572de0 --- /dev/null +++ b/lib/util/math.dart @@ -0,0 +1,9 @@ +int signum(double num) { + if (num < 0) { + return -1; + } else if (num == 0) { + return 0; + } else { + return 1; + } +} diff --git a/lib/util/navigation.dart b/lib/util/navigation.dart index f290593..45d58cb 100644 --- a/lib/util/navigation.dart +++ b/lib/util/navigation.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:html'; + import 'package:flutter/widgets.dart'; void navigateTo(BuildContext context, String name) { @@ -6,3 +9,12 @@ void navigateTo(BuildContext context, String name) { Navigator.pushNamedAndRemoveUntil(context, name, (route) => false); } } + +void downloadBytes(List bytes, String fileName) { + final content = base64Encode(bytes); + AnchorElement( + href: 'data:application/octet-stream;charset=utf-16le;base64,$content', + ) + ..setAttribute('download', fileName) + ..click(); +} diff --git a/pubspec.lock b/pubspec.lock index 35f8c93..8682dbe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.1" async: dependency: transitive description: @@ -57,6 +64,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.5" + excel: + dependency: "direct main" + description: + path: "." + ref: null-safety + resolved-ref: eb091ba91963773741b7f0f8a3378269300a8ed2 + url: "https://github.com/justkawal/excel.git" + source: git + version: "2.0.0-null-safety-4" ffi: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e41113f..52063ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ environment: dependencies: equatable: ^2.0.0 + excel: ^2.0.0-null-safety-3 fl_chart: ^0.55.1 flag: ^6.0.0 flutter: @@ -45,6 +46,12 @@ dependencies: dev_dependencies: flutter_lints: ^2.0.1 +dependency_overrides: + excel: + git: + url: https://github.com/justkawal/excel.git + ref: null-safety + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec