From e02b258aba24d32526cd7300010d31069b4a379e Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 08:50:48 -0500 Subject: [PATCH 1/9] fix: flutter_svg: ^0.17.4 # fixed: Error: The superclass, 'Diagnosticable', has no unnamed constructor that takes no arguments. fix: flutter_datetime_picker: ^1.3.8 # fixed: Error: The superclass, 'Diagnosticable', has no unnamed constructor that takes no fix: shared_preferences: ^0.5.7+3 # fixed: [deprecation] getFlutterEngine() in FlutterPluginBinding has been deprecated --- pubspec.lock | 59 +++++++++++++++++++++++++++++++++++----------------- pubspec.yaml | 6 +++--- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 675b41ea..8d9fa606 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -28,21 +28,21 @@ packages: name: archive url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.13" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "1.5.2" + version: "1.6.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.4.0" + version: "2.4.1" bloc: dependency: transitive description: @@ -56,21 +56,21 @@ packages: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "2.0.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.14.11" + version: "1.14.12" convert: dependency: transitive description: @@ -91,7 +91,7 @@ packages: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.3" + version: "2.1.4" csslib: dependency: transitive description: @@ -159,7 +159,7 @@ packages: name: flutter_datetime_picker url: "https://pub.dartlang.org" source: hosted - version: "1.3.4" + version: "1.3.8" flutter_driver: dependency: "direct dev" description: flutter @@ -225,7 +225,7 @@ packages: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "0.17.1" + version: "0.17.4" flutter_swiper: dependency: "direct main" description: @@ -303,14 +303,14 @@ packages: name: image url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.12" intl: dependency: "direct main" description: name: intl url: "https://pub.dartlang.org" source: hosted - version: "0.16.0" + version: "0.16.1" io: dependency: transitive description: @@ -513,14 +513,14 @@ packages: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "1.4.2" + version: "1.4.4" quiver: dependency: transitive description: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.1.3" random_color: dependency: "direct main" description: @@ -616,7 +616,7 @@ packages: name: source_map_stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.1.5" + version: "2.0.0" source_maps: dependency: transitive description: @@ -630,7 +630,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.5.5" + version: "1.7.0" sqflite: dependency: "direct main" description: @@ -659,6 +659,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" + sync_http: + dependency: transitive + description: + name: sync_http + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" synchronized: dependency: transitive description: @@ -679,21 +686,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.9.4" + version: "1.14.4" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.11" + version: "0.2.15" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.3.4" tool_base: dependency: transitive description: @@ -785,13 +792,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + webdriver: + dependency: transitive + description: + name: webdriver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.2" xml: dependency: transitive description: name: xml url: "https://pub.dartlang.org" source: hosted - version: "3.5.0" + version: "3.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ca71f2be..a2db9495 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,13 +19,13 @@ dependencies: flutter_slidable: ^0.5.4 about: ^1.0.5 url_launcher: ^5.4.2 - flutter_svg: ^0.17.1 + flutter_svg: ^0.17.4 # fixed: Error: The superclass, 'Diagnosticable', has no unnamed constructor that takes no arguments. flutter_material_color_picker: ^1.0.5 - flutter_datetime_picker: ^1.3.4 + flutter_datetime_picker: ^1.3.8 # fixed: Error: The superclass, 'Diagnosticable', has no unnamed constructor that takes no arguments. csv: ^4.0.3 flutter_share: ^1.0.2+1 path_provider: ^1.6.1 - shared_preferences: ^0.5.6+2 + shared_preferences: ^0.5.7+3 # fixed: [deprecation] getFlutterEngine() in FlutterPluginBinding has been deprecated fluent: ^1.0.1+2 fl_chart: ^0.8.3 flutter_swiper: ^1.1.6 From e343bed005872730609010524bc0dc66b6597172 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 11:01:13 -0500 Subject: [PATCH 2/9] feat: added new setting defaultFilterStartDateToMonday ("Default Filter Start Date to Monday") to default the following filters to the latest Monday: dashboard filter, report filter, export filter --- l10n/en.flt | 2 + lib/blocs/settings/settings_bloc.dart | 69 +-- lib/blocs/settings/settings_event.dart | 5 +- lib/blocs/settings/settings_state.dart | 24 +- .../l10n/fluent_l10n_provider.dart | 23 +- lib/data_providers/l10n/l10n_provider.dart | 125 ++--- .../dashboard/bloc/dashboard_bloc.dart | 21 +- lib/screens/export/ExportScreen.dart | 383 ++++++++-------- lib/screens/reports/ReportsScreen.dart | 427 +++++++++--------- lib/screens/settings/SettingsScreen.dart | 75 +-- pubspec.lock | 4 +- terms.flt | 2 + 12 files changed, 604 insertions(+), 556 deletions(-) diff --git a/l10n/en.flt b/l10n/en.flt index 978bc769..eb519529 100644 --- a/l10n/en.flt +++ b/l10n/en.flt @@ -118,6 +118,8 @@ collapseDays = Collapse Days autocompleteDescription = Autocomplete Descriptions +defaultFilterStartDateToMonday = Default Filter Start Date to Monday + hours = Hours total = Total diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index 0c79aa73..fe71a9c1 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -28,7 +28,7 @@ class SettingsBloc extends Bloc { Stream mapEventToState( SettingsEvent event, ) async* { - if(event is LoadSettingsFromRepository) { + if (event is LoadSettingsFromRepository) { bool exportGroupTimers = await settings.getBool("exportGroupTimers") ?? state.exportGroupTimers; bool exportIncludeProject = await settings.getBool("exportIncludeProject") ?? state.exportIncludeProject; bool exportIncludeDate = await settings.getBool("exportIncludeDate") ?? state.exportIncludeDate; @@ -41,20 +41,21 @@ class SettingsBloc extends Bloc { bool groupTimers = await settings.getBool("groupTimers") ?? state.groupTimers; bool collapseDays = await settings.getBool("collapseDays") ?? state.collapseDays; bool autocompleteDescription = await settings.getBool("autocompleteDescription") ?? state.autocompleteDescription; + bool defaultFilterStartDateToMonday = await settings.getBool("defaultFilterStartDateToMonday") ?? state.defaultFilterStartDateToMonday; yield SettingsState( - exportGroupTimers: exportGroupTimers, - exportIncludeDate: exportIncludeDate, - exportIncludeProject: exportIncludeProject, - exportIncludeDescription: exportIncludeDescription, - exportIncludeProjectDescription: exportIncludeProjectDescription, - exportIncludeStartTime: exportIncludeStartTime, - exportIncludeEndTime: exportIncludeEndTime, - exportIncludeDurationHours: exportIncludeDurationHours, - defaultProjectID: defaultProjectID, - groupTimers: groupTimers, - collapseDays: collapseDays, - autocompleteDescription: autocompleteDescription, - ); + exportGroupTimers: exportGroupTimers, + exportIncludeDate: exportIncludeDate, + exportIncludeProject: exportIncludeProject, + exportIncludeDescription: exportIncludeDescription, + exportIncludeProjectDescription: exportIncludeProjectDescription, + exportIncludeStartTime: exportIncludeStartTime, + exportIncludeEndTime: exportIncludeEndTime, + exportIncludeDurationHours: exportIncludeDurationHours, + defaultProjectID: defaultProjectID, + groupTimers: groupTimers, + collapseDays: collapseDays, + autocompleteDescription: autocompleteDescription, + defaultFilterStartDateToMonday: defaultFilterStartDateToMonday); } /*else if(event is SetExportGroupTimers) { await settings.setBool("exportGroupTimers", event.value); @@ -88,44 +89,47 @@ class SettingsBloc extends Bloc { await settings.setBool("exportIncludeDurationHours", event.value); yield SettingsState.clone(state, exportIncludeDurationHours: event.value); }*/ - else if(event is SetDefaultProjectID) { + else if (event is SetDefaultProjectID) { await settings.setInt("defaultProjectID", event.projectID ?? -1); yield SettingsState.clone(state, defaultProjectID: event.projectID ?? -1); } - else if(event is SetBoolValueEvent) { - if(event.exportGroupTimers != null) { + else if (event is SetBoolValueEvent) { + if (event.exportGroupTimers != null) { await settings.setBool("exportGroupTimers", event.exportGroupTimers); } - if(event.exportIncludeDate != null) { + if (event.exportIncludeDate != null) { await settings.setBool("exportIncludeDate", event.exportIncludeDate); } - if(event.exportIncludeProject != null) { + if (event.exportIncludeProject != null) { await settings.setBool("exportIncludeProject", event.exportIncludeProject); } - if(event.exportIncludeDescription != null) { + if (event.exportIncludeDescription != null) { await settings.setBool("exportIncludeDescription", event.exportIncludeDescription); } - if(event.exportIncludeProjectDescription != null) { + if (event.exportIncludeProjectDescription != null) { await settings.setBool("exportIncludeProjectDescription", event.exportIncludeProjectDescription); } - if(event.exportIncludeStartTime != null) { + if (event.exportIncludeStartTime != null) { await settings.setBool("exportIncludeStartTime", event.exportIncludeStartTime); } - if(event.exportIncludeEndTime != null) { + if (event.exportIncludeEndTime != null) { await settings.setBool("exportIncludeEndTime", event.exportIncludeEndTime); } - if(event.exportIncludeDurationHours != null) { + if (event.exportIncludeDurationHours != null) { await settings.setBool("exportIncludeDurationHours", event.exportIncludeDurationHours); } - if(event.groupTimers != null) { + if (event.groupTimers != null) { await settings.setBool("groupTimers", event.groupTimers); } - if(event.collapseDays != null) { + if (event.collapseDays != null) { await settings.setBool("collapseDays", event.collapseDays); } - if(event.autocompleteDescription != null) { + if (event.autocompleteDescription != null) { await settings.setBool("autocompleteDescription", event.autocompleteDescription); } + if (event.defaultFilterStartDateToMonday != null) { + await settings.setBool("defaultFilterStartDateToMonday", event.defaultFilterStartDateToMonday); + } yield SettingsState.clone(state, exportGroupTimers: event.exportGroupTimers, exportIncludeDate: event.exportIncludeDate, @@ -138,7 +142,18 @@ class SettingsBloc extends Bloc { groupTimers: event.groupTimers, collapseDays: event.collapseDays, autocompleteDescription: event.autocompleteDescription, + defaultFilterStartDateToMonday: event.defaultFilterStartDateToMonday, ); } } + + DateTime getFilterStartDate() { + if (state.defaultFilterStartDateToMonday) { + var dayOfWeek = 1; // Monday=1, Tuesday=2... + DateTime date = DateTime.now(); + return date.subtract(Duration(days: date.weekday - dayOfWeek)); + } else { + return DateTime.now().subtract(Duration(days: 30)); + } + } } diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index 520d162c..24067005 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -88,8 +88,9 @@ class SetBoolValueEvent extends SettingsEvent { final bool groupTimers; final bool collapseDays; final bool autocompleteDescription; + final bool defaultFilterStartDateToMonday; - const SetBoolValueEvent({this.exportGroupTimers, this.exportIncludeDate, this.exportIncludeProject, this.exportIncludeDescription, this.exportIncludeProjectDescription, this.exportIncludeStartTime, this.exportIncludeEndTime, this.exportIncludeDurationHours, this.groupTimers, this.collapseDays, this.autocompleteDescription}); + const SetBoolValueEvent({this.exportGroupTimers, this.exportIncludeDate, this.exportIncludeProject, this.exportIncludeDescription, this.exportIncludeProjectDescription, this.exportIncludeStartTime, this.exportIncludeEndTime, this.exportIncludeDurationHours, this.groupTimers, this.collapseDays, this.autocompleteDescription,this.defaultFilterStartDateToMonday}); - @override List get props => [exportGroupTimers, exportIncludeDate, exportIncludeProject, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, exportIncludeEndTime, exportIncludeDurationHours, groupTimers, collapseDays, autocompleteDescription]; + @override List get props => [exportGroupTimers, exportIncludeDate, exportIncludeProject, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, exportIncludeEndTime, exportIncludeDurationHours, groupTimers, collapseDays, autocompleteDescription, defaultFilterStartDateToMonday]; } diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index b6d78aaf..607d0720 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -28,6 +28,7 @@ class SettingsState extends Equatable { final bool groupTimers; final bool collapseDays; final bool autocompleteDescription; + final bool defaultFilterStartDateToMonday; SettingsState({ @required this.exportGroupTimers, @@ -42,6 +43,7 @@ class SettingsState extends Equatable { @required this.groupTimers, @required this.collapseDays, @required this.autocompleteDescription, + @required this.defaultFilterStartDateToMonday, }) : assert(exportGroupTimers != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), @@ -53,7 +55,8 @@ class SettingsState extends Equatable { assert(defaultProjectID != null), assert(groupTimers != null), assert(collapseDays != null), - assert(autocompleteDescription != null); + assert(autocompleteDescription != null), + assert(defaultFilterStartDateToMonday != null); static SettingsState initial() { return SettingsState( @@ -69,6 +72,7 @@ class SettingsState extends Equatable { groupTimers: true, collapseDays: false, autocompleteDescription: true, + defaultFilterStartDateToMonday: true, ); } @@ -85,16 +89,17 @@ class SettingsState extends Equatable { bool groupTimers, bool collapseDays, bool autocompleteDescription, - }) - : this( + bool defaultFilterStartDateToMonday, + }) + : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, - exportIncludeDate: + exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, exportIncludeDescription: exportIncludeDescription ?? project.exportIncludeDescription, - exportIncludeProjectDescription: + exportIncludeProjectDescription: exportIncludeProjectDescription ?? project.exportIncludeProjectDescription, exportIncludeStartTime: exportIncludeStartTime ?? project.exportIncludeStartTime, @@ -102,14 +107,16 @@ class SettingsState extends Equatable { exportIncludeEndTime ?? project.exportIncludeEndTime, exportIncludeDurationHours: exportIncludeDurationHours ?? project.exportIncludeDurationHours, - defaultProjectID: + defaultProjectID: defaultProjectID ?? project.defaultProjectID, - groupTimers: + groupTimers: groupTimers ?? project.groupTimers, - collapseDays: + collapseDays: collapseDays ?? project.collapseDays, autocompleteDescription: autocompleteDescription ?? project.autocompleteDescription, + defaultFilterStartDateToMonday: + defaultFilterStartDateToMonday ?? project.defaultFilterStartDateToMonday, ); @override @@ -126,5 +133,6 @@ class SettingsState extends Equatable { groupTimers, collapseDays, autocompleteDescription, + defaultFilterStartDateToMonday, ]; } diff --git a/lib/data_providers/l10n/fluent_l10n_provider.dart b/lib/data_providers/l10n/fluent_l10n_provider.dart index 334396a1..b6f56a9f 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -21,18 +21,18 @@ class FluentL10NProvider extends L10NProvider { final FluentBundle _bundle; List _errors = []; - FluentL10NProvider._internal(this._bundle) + FluentL10NProvider._internal(this._bundle) : assert(_bundle != null); static Future load(Locale locale) async { final FluentBundle bundle = FluentBundle(locale.toLanguageTag()); - + String src = "l10n/${locale.languageCode}.flt"; // special handling of zh-CN & zh-TW for now - if(locale.languageCode == "zh" && locale.countryCode == "CN") { + if (locale.languageCode == "zh" && locale.countryCode == "CN") { src = "l10n/zh-CN.flt"; } - else if(locale.languageCode == "zh" && locale.countryCode == "TW") { + else if (locale.languageCode == "zh" && locale.countryCode == "TW") { src = "l10n/zh-TW.flt"; } String messages = await rootBundle.loadString(src); @@ -97,10 +97,10 @@ class FluentL10NProvider extends L10NProvider { String get dark => _bundle.format("dark", errors: _errors) ?? "dark"; String get black => _bundle.format("black", errors: _errors) ?? "black"; String langName(Locale locale) { - if(locale == null) { + if (locale == null) { return auto; } - switch(locale.languageCode) { + switch (locale.languageCode) { case "ar": return "العربية"; case "de": return "Deutsch"; case "en": return "English"; @@ -114,12 +114,12 @@ class FluentL10NProvider extends L10NProvider { case "pt": return "Português"; case "ru": return "русский"; case "zh": { - switch(locale.countryCode) { - case "CN": return "中文(简体)"; - case "TW": return "中文(繁體)"; - default: return "中文"; + switch (locale.countryCode) { + case "CN": return "中文(简体)"; + case "TW": return "中文(繁體)"; + default: return "中文"; + } } - } } return ""; } @@ -130,6 +130,7 @@ class FluentL10NProvider extends L10NProvider { } String get collapseDays => _bundle.format("collapseDays", errors: _errors) ?? "collapseDays"; String get autocompleteDescription => _bundle.format("autocompleteDescription", errors: _errors) ?? "autocompleteDescription"; + String get defaultFilterStartDateToMonday => _bundle.format("defaultFilterStartDateToMonday", errors: _errors) ?? "defaultFilterStartDateToMonday"; String get hours => _bundle.format("hours", errors: _errors) ?? "hours"; String get total => _bundle.format("total", errors: _errors) ?? "total"; } diff --git a/lib/data_providers/l10n/l10n_provider.dart b/lib/data_providers/l10n/l10n_provider.dart index 63643441..ff77ad0a 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -15,66 +15,67 @@ import 'package:flutter/widgets.dart'; abstract class L10NProvider { - String get about; - String get appDescription; - String get appLegalese; - String get appName; - String get areYouSureYouWantToDelete; - String get cancel; - String get changeLog; - String get confirmDelete; - String get create; - String get createNewProject; - String get delete; - String get deleteTimerConfirm; - String get description; - String get duration; - String get editProject; - String get editTimer; - String get endTime; - String get export; - String get filter; - String get from; - String get logoSemantics; - String get noProject; - String get pleaseEnterAName; - String get project; - String get projectName; - String get projects; - String get readme; - String get runningTimers; - String get save; - String get sourceCode; - String get startTime; - String get timeH; - String get to; - String get whatAreYouDoing; - String get whatWereYouDoing; - String get noDescription; - String timeCopDatabase(String date); - String timeCopEntries(String date); - String get options; - String get groupTimers; - String get columns; - String get date; - String get combinedProjectDescription; - String get reports; - String nHours(String hours); - String get averageDailyHours; - String get totalProjectShare; - String get weeklyHours; - String get contributors; - String get settings; - String get theme; - String get auto; - String get light; - String get dark; - String get black; - String langName(Locale locale); - String get language; - String get automaticLanguage; - String get collapseDays; - String get autocompleteDescription; - String get hours; - String get total; + String get about; + String get appDescription; + String get appLegalese; + String get appName; + String get areYouSureYouWantToDelete; + String get cancel; + String get changeLog; + String get confirmDelete; + String get create; + String get createNewProject; + String get delete; + String get deleteTimerConfirm; + String get description; + String get duration; + String get editProject; + String get editTimer; + String get endTime; + String get export; + String get filter; + String get from; + String get logoSemantics; + String get noProject; + String get pleaseEnterAName; + String get project; + String get projectName; + String get projects; + String get readme; + String get runningTimers; + String get save; + String get sourceCode; + String get startTime; + String get timeH; + String get to; + String get whatAreYouDoing; + String get whatWereYouDoing; + String get noDescription; + String timeCopDatabase(String date); + String timeCopEntries(String date); + String get options; + String get groupTimers; + String get columns; + String get date; + String get combinedProjectDescription; + String get reports; + String nHours(String hours); + String get averageDailyHours; + String get totalProjectShare; + String get weeklyHours; + String get contributors; + String get settings; + String get theme; + String get auto; + String get light; + String get dark; + String get black; + String langName(Locale locale); + String get language; + String get automaticLanguage; + String get collapseDays; + String get autocompleteDescription; + String get defaultFilterStartDateToMonday; + String get hours; + String get total; } \ No newline at end of file diff --git a/lib/screens/dashboard/bloc/dashboard_bloc.dart b/lib/screens/dashboard/bloc/dashboard_bloc.dart index db28126d..cd2cc20d 100644 --- a/lib/screens/dashboard/bloc/dashboard_bloc.dart +++ b/lib/screens/dashboard/bloc/dashboard_bloc.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/models/project.dart'; @@ -18,42 +19,42 @@ class DashboardBloc extends Bloc { @override DashboardState get initialState { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - return DashboardState("", newProject, false, DateTime.now().subtract(Duration(days: 30)), null, [], null); + return DashboardState("", newProject, false, settingsBloc.getFilterStartDate(), null, [], null); } @override Stream mapEventToState( DashboardEvent event, ) async* { - if(event is DescriptionChangedEvent) { + if (event is DescriptionChangedEvent) { yield DashboardState(event.description, state.newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); } - else if(event is ProjectChangedEvent) { + else if (event is ProjectChangedEvent) { yield DashboardState(state.newDescription, event.project, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); } - else if(event is TimerWasStartedEvent) { + else if (event is TimerWasStartedEvent) { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); yield DashboardState("", newProject, true, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); } - else if(event is ResetEvent) { + else if (event is ResetEvent) { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); yield DashboardState("", newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); } - else if(event is FilterStartChangedEvent) { + else if (event is FilterStartChangedEvent) { DateTime end = state.filterEnd; if(state.filterEnd != null && event.filterStart.isAfter(state.filterEnd)) { - end = event.filterStart.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); + end = event.filterStart.add( Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } yield DashboardState(state.newDescription, state.newProject, false, event.filterStart, end, state.hiddenProjects, state.searchString); } - else if(event is FilterEndChangedEvent) { + else if (event is FilterEndChangedEvent) { yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, event.filterEnd, state.hiddenProjects, state.searchString); } - else if(event is FilterProjectsChangedEvent) { + else if (event is FilterProjectsChangedEvent) { yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, state.filterEnd, event.projects, state.searchString); } - else if(event is SearchChangedEvent) { + else if (event is SearchChangedEvent) { yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, event.search); } } diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index 37ad2eff..bafdd0b7 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -64,6 +64,9 @@ class _ExportScreenState extends State { final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); selectedProjects = [null].followedBy(projects.state.projects.map((p) => Project.clone(p))).toList(); + + final SettingsBloc settingsBloc = BlocProvider.of(context); + _startDate = settingsBloc.getFilterStartDate(); } @override @@ -92,15 +95,15 @@ class _ExportScreenState extends State { dbPath = copiedDB.path; } await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopDatabase(_dateFormat.format(DateTime.now())), filePath: dbPath); - } + } on Exception catch (e) { _scaffoldKey.currentState.showSnackBar( SnackBar( backgroundColor: Theme.of(context).errorColor, content: Text(e.toString(), style: TextStyle(color: Colors.white),), duration: Duration(seconds: 5), - ) - ); + ) + ); } }, ) @@ -111,9 +114,9 @@ class _ExportScreenState extends State { ExpansionTile( title: Text( L10N.of(context).tr.filter, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 ) ), initiallyExpanded: true, @@ -134,30 +137,30 @@ class _ExportScreenState extends State { currentTime: _startDate, onChanged: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), onConfirm: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); }, ), secondaryActions: _startDate == null ? [] : [ - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.minusCircle, - onTap: () { - setState(() { - _startDate = null; - }); - }, - ) - ], + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.minusCircle, + onTap: () { + setState(() { + _startDate = null; + }); + }, + ) + ], ), Slidable( actionPane: SlidableDrawerActionPane(), @@ -175,30 +178,30 @@ class _ExportScreenState extends State { currentTime: _endDate, onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); }, ), secondaryActions: _endDate == null ? [] : [ - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.minusCircle, - onTap: () { - setState(() { - _endDate = null; - }); - }, - ) - ], + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.minusCircle, + onTap: () { + setState(() { + _endDate = null; + }); + }, + ) + ], ), ], ), @@ -208,11 +211,11 @@ class _ExportScreenState extends State { key: Key("optionColumns"), title: Text( L10N.of(context).tr.columns, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 + ) + ), children: [ SwitchListTile( title: Text(L10N.of(context).tr.date), @@ -262,11 +265,11 @@ class _ExportScreenState extends State { ExpansionTile( title: Text( L10N.of(context).tr.projects, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 + ) + ), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -294,30 +297,30 @@ class _ExportScreenState extends State { ].followedBy( [null].followedBy(projectsBloc.state.projects).map( (project) => CheckboxListTile( - secondary: ProjectColour(project: project,), - title: Text(project?.name ?? L10N.of(context).tr.noProject), - value: selectedProjects.any((p) => p?.id == project?.id), - activeColor: Theme.of(context).accentColor, - onChanged: (_) => setState(() { - if(selectedProjects.any((p) => p?.id == project?.id)) { - selectedProjects.removeWhere((p) => p?.id == project?.id); - } - else { - selectedProjects.add(project); - } - }), - ) - ) - ).toList(), + secondary: ProjectColour(project: project,), + title: Text(project?.name ?? L10N.of(context).tr.noProject), + value: selectedProjects.any((p) => p?.id == project?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) => setState(() { + if (selectedProjects.any((p) => p?.id == project?.id)) { + selectedProjects.removeWhere((p) => p?.id == project?.id); + } + else { + selectedProjects.add(project); + } + }), + ) + ) + ).toList(), ), ExpansionTile( title: Text( L10N.of(context).tr.options, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 ) - ), + ), children: [ BlocBuilder( bloc: settingsBloc, @@ -334,60 +337,49 @@ class _ExportScreenState extends State { .toList(), ), floatingActionButton: FloatingActionButton( - key: Key("exportFAB"), - child: Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 15, - left: 19, - child: Icon(FontAwesomeIcons.fileExport), - ) - ], - ), - onPressed: () async { - final TimersBloc timers = BlocProvider.of(context); - assert(timers != null); - final ProjectsBloc projects = BlocProvider.of(context); - assert(projects != null); - - List headers = []; - if(settingsBloc.state.exportIncludeDate) { - headers.add(L10N.of(context).tr.date); - } - if(settingsBloc.state.exportIncludeProject) { - headers.add(L10N.of(context).tr.project); - } - if(settingsBloc.state.exportIncludeDescription) { - headers.add(L10N.of(context).tr.description); - } - if(settingsBloc.state.exportIncludeProjectDescription) { - headers.add(L10N.of(context).tr.combinedProjectDescription); - } - if(settingsBloc.state.exportIncludeStartTime) { - headers.add(L10N.of(context).tr.startTime); - } - if(settingsBloc.state.exportIncludeEndTime) { - headers.add(L10N.of(context).tr.endTime); - } - if(settingsBloc.state.exportIncludeDurationHours) { - headers.add(L10N.of(context).tr.timeH); - } + key: Key("exportFAB"), + child: Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 15, + left: 19, + child: Icon(FontAwesomeIcons.fileExport), + ) + ], + ), + onPressed: () async { + final TimersBloc timers = BlocProvider.of(context); + assert(timers != null); + final ProjectsBloc projects = BlocProvider.of(context); + assert(projects != null); - List filteredTimers = timers.state.timers - .where((t) => t.endTime != null) - .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) - .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) - .where((t) => _endDate == null ? true : t.endTime.isBefore(_endDate)) - .toList(); - filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); + List headers = []; + if (settingsBloc.state.exportIncludeDate) { + headers.add(L10N.of(context).tr.date); + } + if (settingsBloc.state.exportIncludeProject) { + headers.add(L10N.of(context).tr.project); + } + if (settingsBloc.state.exportIncludeDescription) { + headers.add(L10N.of(context).tr.description); + } + if (settingsBloc.state.exportIncludeProjectDescription) { + headers.add(L10N.of(context).tr.combinedProjectDescription); + } + if (settingsBloc.state.exportIncludeStartTime) { + headers.add(L10N.of(context).tr.startTime); + } + if (settingsBloc.state.exportIncludeEndTime) { + headers.add(L10N.of(context).tr.endTime); + } + if (settingsBloc.state.exportIncludeDurationHours) { + headers.add(L10N.of(context).tr.timeH); + } - // group similar timers if that's what you're in to - if(settingsBloc.state.exportGroupTimers && !(settingsBloc.state.exportIncludeStartTime || settingsBloc.state.exportIncludeEndTime)) { - filteredTimers = - timers.state.timers + List filteredTimers = timers.state.timers .where((t) => t.endTime != null) .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) @@ -395,80 +387,91 @@ class _ExportScreenState extends State { .toList(); filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); - // now start grouping those suckers - LinkedHashMap>> derp = LinkedHashMap(); - for(TimerEntry timer in filteredTimers) { - String date = _exportDateFormat.format(timer.startTime); - LinkedHashMap> pairedEntries = derp.putIfAbsent(date, () => LinkedHashMap()); - List pairedList = pairedEntries.putIfAbsent(ProjectDescriptionPair(timer.projectID, timer.description), () => []); - pairedList.add(timer); - } + // group similar timers if that's what you're in to + if (settingsBloc.state.exportGroupTimers && !(settingsBloc.state.exportIncludeStartTime || settingsBloc.state.exportIncludeEndTime)) { + filteredTimers = + timers.state.timers + .where((t) => t.endTime != null) + .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) + .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) + .where((t) => _endDate == null ? true : t.endTime.isBefore(_endDate)) + .toList(); + filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); - // ok, now they're grouped based on date, then combined project + description pairs - // time to get them back into a flat list - filteredTimers = derp.values.expand((LinkedHashMap> pairedEntries) { - return pairedEntries.values.map((List groupedEntries) { - assert(groupedEntries.isNotEmpty); + // now start grouping those suckers + LinkedHashMap>> derp = LinkedHashMap(); + for (TimerEntry timer in filteredTimers) { + String date = _exportDateFormat.format(timer.startTime); + LinkedHashMap> pairedEntries = derp.putIfAbsent(date, () => LinkedHashMap()); + List pairedList = pairedEntries.putIfAbsent(ProjectDescriptionPair(timer.projectID, timer.description), () => []); + pairedList.add(timer); + } - // not a grouped entry - if(groupedEntries.length == 1) return groupedEntries[0]; + // ok, now they're grouped based on date, then combined project + description pairs + // time to get them back into a flat list + filteredTimers = derp.values.expand((LinkedHashMap> pairedEntries) { + return pairedEntries.values.map((List groupedEntries) { + assert(groupedEntries.isNotEmpty); - // yes a group entry, build a dummy timer entry - Duration totalTime = groupedEntries.fold(Duration(), (Duration d, TimerEntry t) => d + t.endTime.difference(t.startTime)); - return TimerEntry.clone(groupedEntries[0], endTime: groupedEntries[0].startTime.add(totalTime)); - }); - }) - .toList(); - } + // not a grouped entry + if (groupedEntries.length == 1) return groupedEntries[0]; - List> data = >[headers] - .followedBy( - filteredTimers - .map( - (timer) { - List row = []; - if(settingsBloc.state.exportIncludeDate) { - row.add(_exportDateFormat.format(timer.startTime)); - } - if(settingsBloc.state.exportIncludeProject) { - row.add(projects.getProjectByID(timer.projectID)?.name ?? ""); - } - if(settingsBloc.state.exportIncludeDescription) { - row.add(timer.description ?? ""); - } - if(settingsBloc.state.exportIncludeProjectDescription) { - row.add((projects.getProjectByID(timer.projectID)?.name ?? "") + ": " + (timer.description ?? "")); - } - if(settingsBloc.state.exportIncludeStartTime) { - row.add(timer.startTime.toUtc().toIso8601String()); - } - if(settingsBloc.state.exportIncludeEndTime) { - row.add(timer.endTime.toUtc().toIso8601String()); - } - if(settingsBloc.state.exportIncludeDurationHours) { - row.add((timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600.0).toStringAsFixed(4)); - } - return row; - } - ) + // yes a group entry, build a dummy timer entry + Duration totalTime = groupedEntries.fold(Duration(), (Duration d, TimerEntry t) => d + t.endTime.difference(t.startTime)); + return TimerEntry.clone(groupedEntries[0], endTime: groupedEntries[0].startTime.add(totalTime)); + }); + }) + .toList(); + } + + List> data = >[headers] + .followedBy( + filteredTimers + .map( + (timer) { + List row = []; + if (settingsBloc.state.exportIncludeDate) { + row.add(_exportDateFormat.format(timer.startTime)); + } + if (settingsBloc.state.exportIncludeProject) { + row.add(projects.getProjectByID(timer.projectID)?.name ?? ""); + } + if (settingsBloc.state.exportIncludeDescription) { + row.add(timer.description ?? ""); + } + if (settingsBloc.state.exportIncludeProjectDescription) { + row.add((projects.getProjectByID(timer.projectID)?.name ?? "") + ": " + (timer.description ?? "")); + } + if (settingsBloc.state.exportIncludeStartTime) { + row.add(timer.startTime.toUtc().toIso8601String()); + } + if (settingsBloc.state.exportIncludeEndTime) { + row.add(timer.endTime.toUtc().toIso8601String()); + } + if (settingsBloc.state.exportIncludeDurationHours) { + row.add((timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600.0).toStringAsFixed(4)); + } + return row; + } + ) ).toList(); - String csv = ListToCsvConverter().convert(data); - print('CSV:'); - print(csv); + String csv = ListToCsvConverter().convert(data); + print('CSV:'); + print(csv); - Directory directory; - if (Platform.isAndroid) { - directory = await getExternalStorageDirectory(); - } - else { - directory = await getApplicationDocumentsDirectory(); + Directory directory; + if (Platform.isAndroid) { + directory = await getExternalStorageDirectory(); + } + else { + directory = await getApplicationDocumentsDirectory(); + } + final String localPath = '${directory.path}/timecop.csv'; + File file = File(localPath); + await file.writeAsString(csv, flush: true); + await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopEntries(_dateFormat.format(DateTime.now())), filePath: localPath); } - final String localPath = '${directory.path}/timecop.csv'; - File file = File(localPath); - await file.writeAsString(csv, flush: true); - await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopEntries(_dateFormat.format(DateTime.now())), filePath: localPath); - } - ), + ), ); } } diff --git a/lib/screens/reports/ReportsScreen.dart b/lib/screens/reports/ReportsScreen.dart index 4eb7bb56..96dd8ddd 100644 --- a/lib/screens/reports/ReportsScreen.dart +++ b/lib/screens/reports/ReportsScreen.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,6 +20,7 @@ import 'package:flutter_swiper/flutter_swiper.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; @@ -50,14 +51,16 @@ class _ReportsScreenState extends State { final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); selectedProjects = [null].followedBy(projects.state.projects.map((p) => Project.clone(p))).toList(); - _startDate = DateTime.now().subtract(Duration(days: 30)); + + final SettingsBloc settings = BlocProvider.of(context); + _startDate = settings.getFilterStartDate(); } void setStartDate(DateTime dt) { assert(dt != null); setState(() { _startDate = dt; - if(_endDate != null && _startDate.isAfter(_endDate)){ + if (_endDate != null && _startDate.isAfter(_endDate)) { _endDate = _startDate.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } }); @@ -66,226 +69,226 @@ class _ReportsScreenState extends State { @override Widget build(BuildContext context) { final ProjectsBloc projectsBloc = BlocProvider.of(context); - + return Scaffold( - appBar: AppBar( - title: Text(L10N.of(context).tr.reports), - ), - body: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Swiper( - itemBuilder: (BuildContext context, int index) { - switch(index) { - case 0: return ProjectBreakdown( - startDate: _startDate, - endDate: _endDate, - selectedProjects: selectedProjects, - ); - case 1: return WeeklyTotals( - startDate: _startDate, - endDate: _endDate, - selectedProjects: selectedProjects, - ); - case 2: return WeekdayAverages( - context, - startDate: _startDate, - endDate: _endDate, - selectedProjects: selectedProjects, - ); - case 3: return TimeTable( - startDate: _startDate, - endDate: _endDate, - selectedProjects: selectedProjects, - ); - } - return Container(); - }, - itemCount: 4, - pagination: SwiperPagination( - builder: DotSwiperPaginationBuilder( + appBar: AppBar( + title: Text(L10N.of(context).tr.reports), + ), + body: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Swiper( + itemBuilder: (BuildContext context, int index) { + switch (index) { + case 0: return ProjectBreakdown( + startDate: _startDate, + endDate: _endDate, + selectedProjects: selectedProjects, + ); + case 1: return WeeklyTotals( + startDate: _startDate, + endDate: _endDate, + selectedProjects: selectedProjects, + ); + case 2: return WeekdayAverages( + context, + startDate: _startDate, + endDate: _endDate, + selectedProjects: selectedProjects, + ); + case 3: return TimeTable( + startDate: _startDate, + endDate: _endDate, + selectedProjects: selectedProjects, + ); + } + return Container(); + }, + itemCount: 4, + pagination: SwiperPagination( + builder: DotSwiperPaginationBuilder( color: Theme.of(context).disabledColor, activeColor: Theme.of(context).accentColor, ) ), - control: SwiperControl(iconPrevious: null, iconNext: null), - ), - ), - ExpansionTile( - title: Text( - L10N.of(context).tr.filter, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) + control: SwiperControl(iconPrevious: null, iconNext: null), + ), ), - initiallyExpanded: false, - children: [ - Slidable( - actionPane: SlidableDrawerActionPane(), - actionExtentRatio: 0.15, - child: ListTile( - leading: Icon(FontAwesomeIcons.calendar), - title: Text(L10N.of(context).tr.from), - trailing: Padding( - padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_startDate == null ? "—" : _dateFormat.format(_startDate)), - ), - onTap: () async { - _oldStartDate = _startDate.clone(); - _oldEndDate = _endDate.clone(); - DateTime newStartDate = await DatePicker.showDatePicker( - context, - currentTime: _startDate, - onChanged: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), - onConfirm: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); - - // if the user cancelled, this should be null - if(newStartDate == null) { - setState(() { - _startDate = _oldStartDate; - _endDate = _oldEndDate; - }); - } - }, + ExpansionTile( + title: Text( + L10N.of(context).tr.filter, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 + ) ), - secondaryActions: - _startDate == null - ? [] - : [ - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.minusCircle, - onTap: () { - setState(() { - _startDate = null; - }); - }, - ) - ], - ), - Slidable( - actionPane: SlidableDrawerActionPane(), - actionExtentRatio: 0.15, - child: ListTile( - leading: Icon(FontAwesomeIcons.calendar), - title: Text(L10N.of(context).tr.to), - trailing: Padding( - padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_endDate == null ? "—" : _dateFormat.format(_endDate)), - ), - onTap: () async { - _oldEndDate = _endDate.clone(); - DateTime newEndDate = await DatePicker.showDatePicker( - context, - currentTime: _endDate, - minTime: _startDate, - onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + initiallyExpanded: false, + children: [ + Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: Icon(FontAwesomeIcons.calendar), + title: Text(L10N.of(context).tr.from), + trailing: Padding( + padding: EdgeInsets.fromLTRB(0, 0, 18, 0), + child: Text(_startDate == null ? "—" : _dateFormat.format(_startDate)), + ), + onTap: () async { + _oldStartDate = _startDate.clone(); + _oldEndDate = _endDate.clone(); + DateTime newStartDate = await DatePicker.showDatePicker( + context, + currentTime: _startDate, + onChanged: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), + onConfirm: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); - // if the user cancelled, this should be null - if(newEndDate == null) { - setState(() { - _endDate = _oldEndDate; - }); - } - }, - ), - secondaryActions: - _endDate == null - ? [] - : [ - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.minusCircle, - onTap: () { - setState(() { - _endDate = null; - }); - }, - ) - ], - ), - ], - ), - ExpansionTile( - title: Text( - L10N.of(context).tr.projects, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.max, - children: [ - RaisedButton( - child: Text("Select None"), - onPressed: () { - setState(() { - selectedProjects.clear(); - }); + // if the user cancelled, this should be null + if (newStartDate == null) { + setState(() { + _startDate = _oldStartDate; + _endDate = _oldEndDate; + }); + } }, ), - RaisedButton( - child: Text("Select All"), - onPressed: () { - setState(() { - selectedProjects = [null].followedBy(projectsBloc.state.projects.map((p) => Project.clone(p))).toList(); - }); + secondaryActions: + _startDate == null + ? [] + : [ + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.minusCircle, + onTap: () { + setState(() { + _startDate = null; + }); + }, + ) + ], + ), + Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: Icon(FontAwesomeIcons.calendar), + title: Text(L10N.of(context).tr.to), + trailing: Padding( + padding: EdgeInsets.fromLTRB(0, 0, 18, 0), + child: Text(_endDate == null ? "—" : _dateFormat.format(_endDate)), + ), + onTap: () async { + _oldEndDate = _endDate.clone(); + DateTime newEndDate = await DatePicker.showDatePicker( + context, + currentTime: _endDate, + minTime: _startDate, + onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); + + // if the user cancelled, this should be null + if (newEndDate == null) { + setState(() { + _endDate = _oldEndDate; + }); + } }, ), - ], - ), - Container( - constraints: BoxConstraints( - maxHeight: 150, + secondaryActions: + _endDate == null + ? [] + : [ + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.minusCircle, + onTap: () { + setState(() { + _endDate = null; + }); + }, + ) + ], ), - child: ListView( - children: [null].followedBy(projectsBloc.state.projects).map( - (project) => CheckboxListTile( - secondary: ProjectColour(project: project,), - title: Text(project?.name ?? L10N.of(context).tr.noProject), - value: selectedProjects.any((p) => p?.id == project?.id), - activeColor: Theme.of(context).accentColor, - onChanged: (_) => setState(() { - if(selectedProjects.any((p) => p?.id == project?.id)) { - selectedProjects.removeWhere((p) => p?.id == project?.id); - } - else { - selectedProjects.add(project); - } - }), - ) - ) - .toList(), + ], + ), + ExpansionTile( + title: Text( + L10N.of(context).tr.projects, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 + ) + ), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + RaisedButton( + child: Text("Select None"), + onPressed: () { + setState(() { + selectedProjects.clear(); + }); + }, + ), + RaisedButton( + child: Text("Select All"), + onPressed: () { + setState(() { + selectedProjects = [null].followedBy(projectsBloc.state.projects.map((p) => Project.clone(p))).toList(); + }); + }, + ), + ], ), - ) - ], - ), - ], - ) - ); + Container( + constraints: BoxConstraints( + maxHeight: 150, + ), + child: ListView( + children: [null].followedBy(projectsBloc.state.projects).map( + (project) => CheckboxListTile( + secondary: ProjectColour(project: project,), + title: Text(project?.name ?? L10N.of(context).tr.noProject), + value: selectedProjects.any((p) => p?.id == project?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) => setState(() { + if (selectedProjects.any((p) => p?.id == project?.id)) { + selectedProjects.removeWhere((p) => p?.id == project?.id); + } + else { + selectedProjects.add(project); + } + }), + ) + ) + .toList(), + ), + ) + ], + ), + ], + ) + ); } } \ No newline at end of file diff --git a/lib/screens/settings/SettingsScreen.dart b/lib/screens/settings/SettingsScreen.dart index e4bf0b2e..58035e56 100644 --- a/lib/screens/settings/SettingsScreen.dart +++ b/lib/screens/settings/SettingsScreen.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -32,49 +32,60 @@ class SettingsScreen extends StatelessWidget { final SettingsBloc settingsBloc = BlocProvider.of(context); return Scaffold( - appBar: AppBar( - title: Text(L10N.of(context).tr.settings), - ), - body: ListView( - children: [ - ThemeOptions( - bloc: themeBloc, - ), - LocaleOptions( - bloc: localeBloc, - ), - BlocBuilder( - bloc: settingsBloc, - builder: (BuildContext context, SettingsState settings) => - SwitchListTile( + appBar: AppBar( + title: Text(L10N.of(context).tr.settings), + ), + body: ListView( + children: [ + ThemeOptions( + bloc: themeBloc, + ), + LocaleOptions( + bloc: localeBloc, + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( title: Text(L10N.of(context).tr.groupTimers), value: settings.groupTimers, onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(groupTimers: value)), activeColor: Theme.of(context).accentColor, ), - ), - BlocBuilder( - bloc: settingsBloc, - builder: (BuildContext context, SettingsState settings) => - SwitchListTile( + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( title: Text(L10N.of(context).tr.collapseDays), value: settings.collapseDays, onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(collapseDays: value)), activeColor: Theme.of(context).accentColor, ), - ), - BlocBuilder( - bloc: settingsBloc, - builder: (BuildContext context, SettingsState settings) => - SwitchListTile( + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( title: Text(L10N.of(context).tr.autocompleteDescription), value: settings.autocompleteDescription, onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(autocompleteDescription: value)), activeColor: Theme.of(context).accentColor, ), - ), - ], - ) - ); + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( + title: Text(L10N.of(context).tr.defaultFilterStartDateToMonday), + value: settings.defaultFilterStartDateToMonday, + onChanged: (bool value) => settingsBloc.add( + SetBoolValueEvent(defaultFilterStartDateToMonday: value)), + activeColor: Theme.of(context).accentColor, + ), + ), + ], + ) + ); } } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 8d9fa606..4cf81e1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -555,7 +555,7 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "0.5.6+2" + version: "0.5.7+3" shared_preferences_macos: dependency: transitive description: @@ -822,4 +822,4 @@ packages: version: "2.2.0" sdks: dart: ">=2.7.0 <3.0.0" - flutter: ">=1.12.13+hotfix.4 <2.0.0" + flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/terms.flt b/terms.flt index dfd04faf..c8e03b0d 100644 --- a/terms.flt +++ b/terms.flt @@ -119,6 +119,8 @@ collapseDays = Collapse Days autocompleteDescription = Autocomplete Descriptions +defaultFilterStartDateToMonday = Default Filter Start Date to Monday + hours = Hours total = Total From afbe407fe873c4fe01b41e0437a054e01ca8c0bb Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 12:40:33 -0500 Subject: [PATCH 3/9] feat: added new setting allowMultipleActiveTimers ("Allow Multiple Active Timers") to control whether multiple timers are allowed to run at the same time or not --- l10n/en.flt | 2 + lib/blocs/settings/settings_bloc.dart | 29 ++- lib/blocs/settings/settings_event.dart | 39 ++- lib/blocs/settings/settings_state.dart | 51 ++-- .../l10n/fluent_l10n_provider.dart | 1 + lib/data_providers/l10n/l10n_provider.dart | 1 + .../components/GroupedStoppedTimersRow.dart | 44 ++-- .../components/StartTimerButton.dart | 70 ++--- .../components/StartTimerSpeedDial.dart | 244 +++++++++--------- .../dashboard/components/StoppedTimerRow.dart | 81 +++--- lib/screens/settings/SettingsScreen.dart | 13 +- terms.flt | 2 + 12 files changed, 329 insertions(+), 248 deletions(-) diff --git a/l10n/en.flt b/l10n/en.flt index eb519529..427504df 100644 --- a/l10n/en.flt +++ b/l10n/en.flt @@ -120,6 +120,8 @@ autocompleteDescription = Autocomplete Descriptions defaultFilterStartDateToMonday = Default Filter Start Date to Monday +allowMultipleActiveTimers = Allow Multiple Active Timers + hours = Hours total = Total diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index fe71a9c1..f2c154be 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -42,6 +42,7 @@ class SettingsBloc extends Bloc { bool collapseDays = await settings.getBool("collapseDays") ?? state.collapseDays; bool autocompleteDescription = await settings.getBool("autocompleteDescription") ?? state.autocompleteDescription; bool defaultFilterStartDateToMonday = await settings.getBool("defaultFilterStartDateToMonday") ?? state.defaultFilterStartDateToMonday; + bool allowMultipleActiveTimers = await settings.getBool("allowMultipleActiveTimers") ?? state.allowMultipleActiveTimers; yield SettingsState( exportGroupTimers: exportGroupTimers, exportIncludeDate: exportIncludeDate, @@ -55,7 +56,8 @@ class SettingsBloc extends Bloc { groupTimers: groupTimers, collapseDays: collapseDays, autocompleteDescription: autocompleteDescription, - defaultFilterStartDateToMonday: defaultFilterStartDateToMonday); + defaultFilterStartDateToMonday: defaultFilterStartDateToMonday, + allowMultipleActiveTimers: allowMultipleActiveTimers); } /*else if(event is SetExportGroupTimers) { await settings.setBool("exportGroupTimers", event.value); @@ -92,8 +94,7 @@ class SettingsBloc extends Bloc { else if (event is SetDefaultProjectID) { await settings.setInt("defaultProjectID", event.projectID ?? -1); yield SettingsState.clone(state, defaultProjectID: event.projectID ?? -1); - } - else if (event is SetBoolValueEvent) { + } else if (event is SetBoolValueEvent) { if (event.exportGroupTimers != null) { await settings.setBool("exportGroupTimers", event.exportGroupTimers); } @@ -130,7 +131,11 @@ class SettingsBloc extends Bloc { if (event.defaultFilterStartDateToMonday != null) { await settings.setBool("defaultFilterStartDateToMonday", event.defaultFilterStartDateToMonday); } - yield SettingsState.clone(state, + if (event.allowMultipleActiveTimers != null) { + await settings.setBool("allowMultipleActiveTimers", event.allowMultipleActiveTimers); + } + yield SettingsState.clone( + state, exportGroupTimers: event.exportGroupTimers, exportIncludeDate: event.exportIncludeDate, exportIncludeProject: event.exportIncludeProject, @@ -143,17 +148,27 @@ class SettingsBloc extends Bloc { collapseDays: event.collapseDays, autocompleteDescription: event.autocompleteDescription, defaultFilterStartDateToMonday: event.defaultFilterStartDateToMonday, + allowMultipleActiveTimers: event.allowMultipleActiveTimers, ); } } + /** + * return the start date for a date filter with time set to 00:00:00.000. + * If setting defaultFilterStartDateToMonday is true, then return Monday of + * the current week (week starts on Monday). Otherwise, return 30 days prior to + * today. + */ DateTime getFilterStartDate() { + DateTime now = DateTime.now(); + DateTime todayZerothHour = DateTime(now.year, now.month, now.day, 0, 0, 0, 0, 0); + DateTime startDate; if (state.defaultFilterStartDateToMonday) { var dayOfWeek = 1; // Monday=1, Tuesday=2... - DateTime date = DateTime.now(); - return date.subtract(Duration(days: date.weekday - dayOfWeek)); + startDate = todayZerothHour.subtract(Duration(days: todayZerothHour.weekday - dayOfWeek)); } else { - return DateTime.now().subtract(Duration(days: 30)); + startDate = todayZerothHour.subtract(Duration(days: 30)); } + return startDate; } } diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index 24067005..9bee37c3 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -19,7 +19,8 @@ abstract class SettingsEvent extends Equatable { } class LoadSettingsFromRepository extends SettingsEvent { - @override List get props => []; + @override + List get props => []; } /*class SetExportGroupTimers extends SettingsEvent { @@ -73,7 +74,8 @@ class SetExportIncludeDurationHours extends SettingsEvent { class SetDefaultProjectID extends SettingsEvent { final int projectID; const SetDefaultProjectID(this.projectID); - @override List get props => [projectID]; + @override + List get props => [projectID]; } class SetBoolValueEvent extends SettingsEvent { @@ -89,8 +91,37 @@ class SetBoolValueEvent extends SettingsEvent { final bool collapseDays; final bool autocompleteDescription; final bool defaultFilterStartDateToMonday; + final bool allowMultipleActiveTimers; - const SetBoolValueEvent({this.exportGroupTimers, this.exportIncludeDate, this.exportIncludeProject, this.exportIncludeDescription, this.exportIncludeProjectDescription, this.exportIncludeStartTime, this.exportIncludeEndTime, this.exportIncludeDurationHours, this.groupTimers, this.collapseDays, this.autocompleteDescription,this.defaultFilterStartDateToMonday}); + const SetBoolValueEvent( + {this.exportGroupTimers, + this.exportIncludeDate, + this.exportIncludeProject, + this.exportIncludeDescription, + this.exportIncludeProjectDescription, + this.exportIncludeStartTime, + this.exportIncludeEndTime, + this.exportIncludeDurationHours, + this.groupTimers, + this.collapseDays, + this.autocompleteDescription, + this.defaultFilterStartDateToMonday, + this.allowMultipleActiveTimers}); - @override List get props => [exportGroupTimers, exportIncludeDate, exportIncludeProject, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, exportIncludeEndTime, exportIncludeDurationHours, groupTimers, collapseDays, autocompleteDescription, defaultFilterStartDateToMonday]; + @override + List get props => [ + exportGroupTimers, + exportIncludeDate, + exportIncludeProject, + exportIncludeDescription, + exportIncludeProjectDescription, + exportIncludeStartTime, + exportIncludeEndTime, + exportIncludeDurationHours, + groupTimers, + collapseDays, + autocompleteDescription, + defaultFilterStartDateToMonday, + allowMultipleActiveTimers + ]; } diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index 607d0720..ce4e4231 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -29,6 +29,7 @@ class SettingsState extends Equatable { final bool collapseDays; final bool autocompleteDescription; final bool defaultFilterStartDateToMonday; + final bool allowMultipleActiveTimers; SettingsState({ @required this.exportGroupTimers, @@ -44,6 +45,7 @@ class SettingsState extends Equatable { @required this.collapseDays, @required this.autocompleteDescription, @required this.defaultFilterStartDateToMonday, + @required this.allowMultipleActiveTimers, }) : assert(exportGroupTimers != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), @@ -56,7 +58,8 @@ class SettingsState extends Equatable { assert(groupTimers != null), assert(collapseDays != null), assert(autocompleteDescription != null), - assert(defaultFilterStartDateToMonday != null); + assert(defaultFilterStartDateToMonday != null), + assert(allowMultipleActiveTimers != null); static SettingsState initial() { return SettingsState( @@ -73,10 +76,12 @@ class SettingsState extends Equatable { collapseDays: false, autocompleteDescription: true, defaultFilterStartDateToMonday: true, + allowMultipleActiveTimers: true, ); } - SettingsState.clone(SettingsState project, { + SettingsState.clone( + SettingsState project, { bool exportGroupTimers, bool exportIncludeDate, bool exportIncludeProject, @@ -90,33 +95,22 @@ class SettingsState extends Equatable { bool collapseDays, bool autocompleteDescription, bool defaultFilterStartDateToMonday, - }) - : this( + bool allowMultipleActiveTimers, + }) : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, - exportIncludeDate: - exportIncludeDate ?? project.exportIncludeDate, - exportIncludeProject: - exportIncludeProject ?? project.exportIncludeProject, - exportIncludeDescription: - exportIncludeDescription ?? project.exportIncludeDescription, - exportIncludeProjectDescription: - exportIncludeProjectDescription ?? project.exportIncludeProjectDescription, - exportIncludeStartTime: - exportIncludeStartTime ?? project.exportIncludeStartTime, - exportIncludeEndTime: - exportIncludeEndTime ?? project.exportIncludeEndTime, - exportIncludeDurationHours: - exportIncludeDurationHours ?? project.exportIncludeDurationHours, - defaultProjectID: - defaultProjectID ?? project.defaultProjectID, - groupTimers: - groupTimers ?? project.groupTimers, - collapseDays: - collapseDays ?? project.collapseDays, - autocompleteDescription: - autocompleteDescription ?? project.autocompleteDescription, - defaultFilterStartDateToMonday: - defaultFilterStartDateToMonday ?? project.defaultFilterStartDateToMonday, + exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, + exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, + exportIncludeDescription: exportIncludeDescription ?? project.exportIncludeDescription, + exportIncludeProjectDescription: exportIncludeProjectDescription ?? project.exportIncludeProjectDescription, + exportIncludeStartTime: exportIncludeStartTime ?? project.exportIncludeStartTime, + exportIncludeEndTime: exportIncludeEndTime ?? project.exportIncludeEndTime, + exportIncludeDurationHours: exportIncludeDurationHours ?? project.exportIncludeDurationHours, + defaultProjectID: defaultProjectID ?? project.defaultProjectID, + groupTimers: groupTimers ?? project.groupTimers, + collapseDays: collapseDays ?? project.collapseDays, + autocompleteDescription: autocompleteDescription ?? project.autocompleteDescription, + defaultFilterStartDateToMonday: defaultFilterStartDateToMonday ?? project.defaultFilterStartDateToMonday, + allowMultipleActiveTimers: allowMultipleActiveTimers ?? project.allowMultipleActiveTimers, ); @override @@ -134,5 +128,6 @@ class SettingsState extends Equatable { collapseDays, autocompleteDescription, defaultFilterStartDateToMonday, + allowMultipleActiveTimers, ]; } diff --git a/lib/data_providers/l10n/fluent_l10n_provider.dart b/lib/data_providers/l10n/fluent_l10n_provider.dart index b6f56a9f..befa3cb7 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -131,6 +131,7 @@ class FluentL10NProvider extends L10NProvider { String get collapseDays => _bundle.format("collapseDays", errors: _errors) ?? "collapseDays"; String get autocompleteDescription => _bundle.format("autocompleteDescription", errors: _errors) ?? "autocompleteDescription"; String get defaultFilterStartDateToMonday => _bundle.format("defaultFilterStartDateToMonday", errors: _errors) ?? "defaultFilterStartDateToMonday"; + String get allowMultipleActiveTimers => _bundle.format("allowMultipleActiveTimers", errors: _errors) ?? "allowMultipleActiveTimers"; String get hours => _bundle.format("hours", errors: _errors) ?? "hours"; String get total => _bundle.format("total", errors: _errors) ?? "total"; } diff --git a/lib/data_providers/l10n/l10n_provider.dart b/lib/data_providers/l10n/l10n_provider.dart index ff77ad0a..8aee066a 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -76,6 +76,7 @@ abstract class L10NProvider { String get collapseDays; String get autocompleteDescription; String get defaultFilterStartDateToMonday; + String get allowMultipleActiveTimers; String get hours; String get total; } \ No newline at end of file diff --git a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart index 7bebb57e..b6ba8d0a 100644 --- a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart +++ b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart @@ -17,6 +17,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/blocs/timers/timers_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; @@ -45,7 +46,7 @@ class _GroupedStoppedTimersRowState extends State with Animation _iconTurns; @override - void initState() { + void initState() { super.initState(); _expanded = false; _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this); @@ -53,7 +54,7 @@ class _GroupedStoppedTimersRowState extends State with } @override - void dispose() { + void dispose() { _controller.dispose(); super.dispose(); } @@ -81,17 +82,17 @@ class _GroupedStoppedTimersRowState extends State with onExpansionChanged: (expanded) { setState(() { _expanded = expanded; - if(_expanded) { + if (_expanded) { _controller.forward(); - } + } else { _controller.reverse(); } }); }, leading: ProjectColour( - project: BlocProvider.of(context) - .getProjectByID(widget.timers[0].projectID) + project: BlocProvider.of(context) + .getProjectByID(widget.timers[0].projectID) ), title: Text( formatDescription(context, widget.timers[0].description), @@ -107,31 +108,36 @@ class _GroupedStoppedTimersRowState extends State with ), Container(width: 8), Text( - TimerEntry.formatDuration( - widget.timers.fold( - Duration(), - (Duration sum, TimerEntry timer) => sum + timer.endTime.difference(timer.startTime) - ) + TimerEntry.formatDuration( + widget.timers.fold( + Duration(), + (Duration sum, TimerEntry timer) => sum + timer.endTime.difference(timer.startTime) + ) + ), + style: TextStyle(fontFamily: "FiraMono") ), - style: TextStyle(fontFamily: "FiraMono") - ), ], ), children: widget.timers.map((timer) => StoppedTimerRow(timer: timer)).toList(), ), secondaryActions: [ IconSlideAction( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.play, - onTap: () { + color: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.play, + onTap: () { final TimersBloc timersBloc = BlocProvider.of(context); assert(timersBloc != null); final ProjectsBloc projectsBloc = BlocProvider.of(context); assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(widget.timers.first?.projectID); - timersBloc.add(CreateTimer(description: widget.timers.first?.description ?? "", project: project)); - } + + final SettingsBloc settingsBloc = BlocProvider.of(context); + if (!settingsBloc.state.allowMultipleActiveTimers) { + timersBloc.add(StopAllTimers()); + } + timersBloc.add(CreateTimer( description: widget.timers.first?.description ?? "", project: project)); + } ) ], ); diff --git a/lib/screens/dashboard/components/StartTimerButton.dart b/lib/screens/dashboard/components/StartTimerButton.dart index 228bcb2b..49485174 100644 --- a/lib/screens/dashboard/components/StartTimerButton.dart +++ b/lib/screens/dashboard/components/StartTimerButton.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:timecop/blocs/settings/bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:timecop/screens/dashboard/components/StartTimerSpeedDial.dart'; @@ -31,38 +32,47 @@ class _StartTimerButtonState extends State { Widget build(BuildContext context) { final DashboardBloc bloc = BlocProvider.of(context); assert(bloc != null); + final SettingsBloc settingsBloc = BlocProvider.of(context); + assert(settingsBloc != null); return BlocBuilder( - builder: (BuildContext context, TimersState timersState) { - if(timersState.timers.where((t) => t.endTime == null).isEmpty) { - return FloatingActionButton( - key: Key("startTimerButton"), - child: Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 15, - left: 18, - child: Icon(FontAwesomeIcons.play), - ) - ], - ), - backgroundColor: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - onPressed: () { - final TimersBloc timers = BlocProvider.of(context); - assert(timers != null); - timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject)); - bloc.add(TimerWasStartedEvent()); - }, - ); - } - else { - return StartTimerSpeedDial(); - } + builder: (BuildContext context, TimersState timersState) { + if (timersState.timers.where((t) => t.endTime == null).isEmpty || + !settingsBloc.state.allowMultipleActiveTimers) { + return FloatingActionButton( + key: Key("startTimerButton"), + child: Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 15, + left: 18, + child: Icon(FontAwesomeIcons.play), + ) + ], + ), + backgroundColor: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + onPressed: () { + final TimersBloc timers = BlocProvider.of(context); + assert(timers != null); + + final SettingsBloc settingsBloc = BlocProvider.of(context); + if (!settingsBloc.state.allowMultipleActiveTimers) { + timers.add(StopAllTimers()); + } + + timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject)); + bloc.add(TimerWasStartedEvent()); + }, + ); + } + else { + return StartTimerSpeedDial(); } - ); + } + ); } } diff --git a/lib/screens/dashboard/components/StartTimerSpeedDial.dart b/lib/screens/dashboard/components/StartTimerSpeedDial.dart index e6a9a21a..b484abb6 100644 --- a/lib/screens/dashboard/components/StartTimerSpeedDial.dart +++ b/lib/screens/dashboard/components/StartTimerSpeedDial.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -29,7 +29,7 @@ class _StartTimerSpeedDialState extends State with TickerPr AnimationController _controller; @override - void initState() { + void initState() { super.initState(); _controller = AnimationController( vsync: this, @@ -38,7 +38,7 @@ class _StartTimerSpeedDialState extends State with TickerPr } @override - void dispose() { + void dispose() { _controller.dispose(); super.dispose(); } @@ -47,133 +47,133 @@ class _StartTimerSpeedDialState extends State with TickerPr Widget build(BuildContext context) { final DashboardBloc bloc = BlocProvider.of(context); assert(bloc != null); - + // adapted from https://stackoverflow.com/a/46480722 return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.min, children: [ - Container( - height: 70.0, - width: 56.0, - alignment: FractionalOffset.topCenter, - child: ScaleTransition( - scale: CurvedAnimation( - parent: _controller, - curve: Interval( - 0.0, - 1.0, - curve: Curves.easeOut - ), + Container( + height: 70.0, + width: 56.0, + alignment: FractionalOffset.topCenter, + child: ScaleTransition( + scale: CurvedAnimation( + parent: _controller, + curve: Interval( + 0.0, + 1.0, + curve: Curves.easeOut ), - child: FloatingActionButton( - heroTag: null, - mini: true, - child: Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 7.5, - left: 8, - child: Icon(FontAwesomeIcons.plus), - ) - ], - ), - backgroundColor: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - onPressed: () { - _controller.reverse(); - final TimersBloc timers = BlocProvider.of(context); - assert(timers != null); - timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject)); - bloc.add(TimerWasStartedEvent()); - }, + ), + child: FloatingActionButton( + heroTag: null, + mini: true, + child: Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 7.5, + left: 8, + child: Icon(FontAwesomeIcons.plus), + ) + ], ), + backgroundColor: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + onPressed: () { + _controller.reverse(); + final TimersBloc timers = BlocProvider.of(context); + assert(timers != null); + timers.add(CreateTimer( description: bloc.state.newDescription, project: bloc.state.newProject)); + bloc.add(TimerWasStartedEvent()); + }, ), ), - Container( - height: 70.0, - width: 56.0, - alignment: FractionalOffset.topCenter, - child: ScaleTransition( - scale: CurvedAnimation( - parent: _controller, - curve: Interval( - 0.0, - 0.75, - curve: Curves.easeOut - ), + ), + Container( + height: 70.0, + width: 56.0, + alignment: FractionalOffset.topCenter, + child: ScaleTransition( + scale: CurvedAnimation( + parent: _controller, + curve: Interval( + 0.0, + 0.75, + curve: Curves.easeOut ), - child: FloatingActionButton( - heroTag: null, - mini: true, - child: Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 7, - left: 7.5, - child: Icon(FontAwesomeIcons.stop), - ) - ], - ), - backgroundColor: Colors.pink[600], - foregroundColor: Theme.of(context).accentIconTheme.color, - onPressed: () { - _controller.reverse(); - final TimersBloc timers = BlocProvider.of(context); - assert(timers != null); - timers.add(StopAllTimers()); - }, + ), + child: FloatingActionButton( + heroTag: null, + mini: true, + child: Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 7, + left: 7.5, + child: Icon(FontAwesomeIcons.stop), + ) + ], ), + backgroundColor: Colors.pink[600], + foregroundColor: Theme.of(context).accentIconTheme.color, + onPressed: () { + _controller.reverse(); + final TimersBloc timers = BlocProvider.of(context); + assert(timers != null); + timers.add(StopAllTimers()); + }, ), ), - AnimatedBuilder( - animation: _controller, - builder: (BuildContext conext, Widget child) { - return - FloatingActionButton( - heroTag: null, - backgroundColor: _controller.isDismissed ? Theme.of(context).accentColor : Theme.of(context).disabledColor, - child: _controller.isDismissed - ? Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 15, - left: 16, - child: Icon(FontAwesomeIcons.stopwatch,), - ) - ], - ) - : Stack( - // shenanigans to properly centre the icon (font awesome glyphs are variable - // width but the library currently doesn't deal with that) - fit: StackFit.expand, - children: [ - Positioned( - top: 15, - left: 16, - child: Icon(FontAwesomeIcons.times), - ) - ], - ), - onPressed: () { - if (_controller.isDismissed) { - _controller.forward(); - } else { - _controller.reverse(); - } - }, - ); - }, - ), - ] - ); + ), + AnimatedBuilder( + animation: _controller, + builder: (BuildContext conext, Widget child) { + return + FloatingActionButton( + heroTag: null, + backgroundColor: _controller.isDismissed ? Theme.of(context).accentColor : Theme.of(context).disabledColor, + child: _controller.isDismissed + ? Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 15, + left: 16, + child: Icon(FontAwesomeIcons.stopwatch,), + ) + ], + ) + : Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 15, + left: 16, + child: Icon(FontAwesomeIcons.times), + ) + ], + ), + onPressed: () { + if (_controller.isDismissed) { + _controller.forward(); + } else { + _controller.reverse(); + } + }, + ); + }, + ), + ] + ); } } \ No newline at end of file diff --git a/lib/screens/dashboard/components/StoppedTimerRow.dart b/lib/screens/dashboard/components/StoppedTimerRow.dart index edc1060e..55101b16 100644 --- a/lib/screens/dashboard/components/StoppedTimerRow.dart +++ b/lib/screens/dashboard/components/StoppedTimerRow.dart @@ -17,6 +17,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; @@ -27,18 +28,18 @@ import 'package:timecop/screens/timer/TimerEditor.dart'; class StoppedTimerRow extends StatelessWidget { final TimerEntry timer; const StoppedTimerRow({Key key, @required this.timer}) - : assert(timer != null), - super(key: key); + : assert(timer != null), + super(key: key); static String formatDescription(BuildContext context, String description) { - if(description == null || description.trim().isEmpty) { + if (description == null || description.trim().isEmpty) { return L10N.of(context).tr.noDescription; } return description; } static TextStyle styleDescription(BuildContext context, String description) { - if(description == null || description.trim().isEmpty) { + if (description == null || description.trim().isEmpty) { return TextStyle(color: Theme.of(context).disabledColor); } return null; @@ -53,15 +54,15 @@ class StoppedTimerRow extends StatelessWidget { actionPane: SlidableDrawerActionPane(), actionExtentRatio: 0.15, child: ListTile( - key: Key("stoppedTimer-" + timer.id.toString()), - leading: ProjectColour(project: BlocProvider.of(context).getProjectByID(timer.projectID)), - title: Text(formatDescription(context, timer.description), style: styleDescription(context, timer.description)), - trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => TimerEditor(timer: timer,), - fullscreenDialog: true, - )) - ), + key: Key("stoppedTimer-" + timer.id.toString()), + leading: ProjectColour(project: BlocProvider.of(context).getProjectByID(timer.projectID)), + title: Text(formatDescription(context, timer.description), style: styleDescription(context, timer.description)), + trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext context) => TimerEditor( timer: timer,), + fullscreenDialog: true, + )) + ), actions: [ IconSlideAction( color: Theme.of(context).errorColor, @@ -69,23 +70,23 @@ class StoppedTimerRow extends StatelessWidget { icon: FontAwesomeIcons.trash, onTap: () async { bool delete = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(L10N.of(context).tr.confirmDelete), - content: Text(L10N.of(context).tr.deleteTimerConfirm), - actions: [ - FlatButton( - child: Text(L10N.of(context).tr.cancel), - onPressed: () => Navigator.of(context).pop(false), - ), - FlatButton( - child: Text(L10N.of(context).tr.delete), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ) - ); - if(delete) { + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(L10N.of(context).tr.confirmDelete), + content: Text(L10N.of(context).tr.deleteTimerConfirm), + actions: [ + FlatButton( + child: Text(L10N.of(context).tr.cancel), + onPressed: () => Navigator.of(context).pop(false), + ), + FlatButton( + child: Text(L10N.of(context).tr.delete), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ) + ); + if (delete) { final TimersBloc timersBloc = BlocProvider.of(context); assert(timersBloc != null); timersBloc.add(DeleteTimer(timer)); @@ -95,18 +96,24 @@ class StoppedTimerRow extends StatelessWidget { ], secondaryActions: [ IconSlideAction( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.play, - onTap: () { + color: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.play, + onTap: () { final TimersBloc timersBloc = BlocProvider.of(context); assert(timersBloc != null); final ProjectsBloc projectsBloc = BlocProvider.of(context); assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(timer.projectID); - timersBloc.add(CreateTimer(description: timer.description, project: project)); - } - ) + + final SettingsBloc settingsBloc = BlocProvider.of(context); + if (!settingsBloc.state.allowMultipleActiveTimers) { + timersBloc.add(StopAllTimers()); + } + + timersBloc.add(CreateTimer( description: timer.description, project: project)); + } + ) ], ); } diff --git a/lib/screens/settings/SettingsScreen.dart b/lib/screens/settings/SettingsScreen.dart index 58035e56..4687bd74 100644 --- a/lib/screens/settings/SettingsScreen.dart +++ b/lib/screens/settings/SettingsScreen.dart @@ -84,8 +84,19 @@ class SettingsScreen extends StatelessWidget { activeColor: Theme.of(context).accentColor, ), ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( + title: Text(L10N.of(context).tr.allowMultipleActiveTimers), + value: settings.allowMultipleActiveTimers, + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(allowMultipleActiveTimers: value)), + activeColor: Theme.of(context).accentColor, + ), + ), ], ) - ); + ); } } \ No newline at end of file diff --git a/terms.flt b/terms.flt index c8e03b0d..90f3d4d3 100644 --- a/terms.flt +++ b/terms.flt @@ -121,6 +121,8 @@ autocompleteDescription = Autocomplete Descriptions defaultFilterStartDateToMonday = Default Filter Start Date to Monday +allowMultipleActiveTimers = Allow Multiple Active Timers + hours = Hours total = Total From 2c57448c0721cb366c0b0912d00b81ac65d4efc7 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 14:35:27 -0500 Subject: [PATCH 4/9] feat: added a work-type badge to each timer using a new work_type db table (e.g.: dev(elopment), test(ing), dep(loyment)); added db vers # 2; upgrade db when version # changes feat: added new setting defaultWorkTypeID: this determines the default work type to be given to a new timer feat: added new setting displayProjectNameInTimer: this controls whether the project name (along with work type) are shown in each timer row (running timer, stopped timer, grouped stop timers) --- l10n/ar.flt | 2 +- l10n/de.flt | 2 +- l10n/en.flt | 16 +- l10n/es.flt | 2 +- l10n/fr.flt | 2 +- l10n/hi.flt | 2 +- l10n/id.flt | 2 +- l10n/it.flt | 2 +- l10n/ja.flt | 2 +- l10n/ko.flt | 2 +- l10n/pt.flt | 2 +- l10n/ru.flt | 2 +- l10n/zh-CN.flt | 2 +- l10n/zh-TW.flt | 2 +- lib/blocs/settings/settings_bloc.dart | 13 +- lib/blocs/settings/settings_event.dart | 14 +- lib/blocs/settings/settings_state.dart | 16 +- lib/blocs/timers/timers_bloc.dart | 25 +- lib/blocs/timers/timers_event.dart | 6 +- lib/blocs/work_types/bloc.dart | 17 ++ lib/blocs/work_types/work_types_bloc.dart | 69 +++++ lib/blocs/work_types/work_types_event.dart | 50 ++++ lib/blocs/work_types/work_types_state.dart | 34 +++ lib/components/WorkTypeBadge.dart | 58 ++++ lib/data_providers/data/data_provider.dart | 11 +- .../data/database_provider.dart | 156 ++++++++-- .../data/mock_data_provider.dart | 180 ++++++------ .../l10n/fluent_l10n_provider.dart | 12 +- lib/data_providers/l10n/l10n_provider.dart | 10 +- lib/main.dart | 81 +++--- lib/models/WorkType.dart | 39 +++ lib/models/timer_entry.dart | 44 +-- lib/models/timer_group.dart | 26 ++ lib/screens/dashboard/DashboardScreen.dart | 13 +- .../dashboard/bloc/dashboard_bloc.dart | 49 ++-- .../dashboard/bloc/dashboard_event.dart | 6 + .../dashboard/bloc/dashboard_state.dart | 38 +-- .../components/GroupedStoppedTimersRow.dart | 37 ++- .../dashboard/components/PopupMenu.dart | 18 +- .../dashboard/components/RunningTimerRow.dart | 75 +++-- .../components/StartTimerButton.dart | 4 +- .../components/StartTimerSpeedDial.dart | 22 +- .../dashboard/components/StoppedTimerRow.dart | 37 ++- .../dashboard/components/StoppedTimers.dart | 104 +++---- .../components/TimerTileBuilder.dart | 75 +++++ .../components/WorkTypeSelectField.dart | 100 +++++++ lib/screens/export/ExportScreen.dart | 80 +++--- lib/screens/projects/ProjectsScreen.dart | 2 +- lib/screens/reports/ReportsScreen.dart | 16 +- lib/screens/settings/SettingsScreen.dart | 13 +- lib/screens/timer/TimerEditor.dart | 270 +++++++++++------- lib/screens/workTypes/WorkTypeEditor.dart | 115 ++++++++ lib/screens/workTypes/WorkTypesScreen.dart | 181 ++++++++++++ pubspec.lock | 115 +++++--- pubspec.yaml | 3 +- terms.flt | 18 +- 56 files changed, 1700 insertions(+), 594 deletions(-) create mode 100644 lib/blocs/work_types/bloc.dart create mode 100644 lib/blocs/work_types/work_types_bloc.dart create mode 100644 lib/blocs/work_types/work_types_event.dart create mode 100644 lib/blocs/work_types/work_types_state.dart create mode 100644 lib/components/WorkTypeBadge.dart create mode 100644 lib/models/WorkType.dart create mode 100644 lib/models/timer_group.dart create mode 100644 lib/screens/dashboard/components/TimerTileBuilder.dart create mode 100644 lib/screens/dashboard/components/WorkTypeSelectField.dart create mode 100644 lib/screens/workTypes/WorkTypeEditor.dart create mode 100644 lib/screens/workTypes/WorkTypesScreen.dart diff --git a/l10n/ar.flt b/l10n/ar.flt index 8ae4ffc2..c0cb6117 100644 --- a/l10n/ar.flt +++ b/l10n/ar.flt @@ -56,7 +56,7 @@ create = خلق save = حفظ -areYouSureYouWantToDelete = هل أنت متأكد أنك تريد حذف هذا المشروع؟ +areYouSureYouWantToDeleteProject = هل أنت متأكد أنك تريد حذف هذا المشروع؟ editTimer = تحرير الموقت diff --git a/l10n/de.flt b/l10n/de.flt index 1eb0205b..a6e048b1 100644 --- a/l10n/de.flt +++ b/l10n/de.flt @@ -56,7 +56,7 @@ create = Erstellen save = speichern -areYouSureYouWantToDelete = Möchten Sie dieses Projekt wirklich löschen? +areYouSureYouWantToDeleteProject = Möchten Sie dieses Projekt wirklich löschen? editTimer = Timer bearbeiten diff --git a/l10n/en.flt b/l10n/en.flt index 427504df..4f168271 100644 --- a/l10n/en.flt +++ b/l10n/en.flt @@ -14,10 +14,14 @@ whatAreYouDoing = What are you doing? projects = Projects +workTypes = Work Types + export = Export noProject = (no project) +noWorkType = (no work type) + confirmDelete = Confirm Delete deleteTimerConfirm = Are you sure you want to delete this timer? @@ -46,17 +50,25 @@ timeCopEntries = Time Cop Entries ({ $date }) createNewProject = Create New Project +createNewWorkType = Create New Work Type + editProject = Edit Project +editWorkType = Edit Work Type + pleaseEnterAName = Please enter a name projectName = Project Name +workTypeName = Work Type Name + create = Create save = Save -areYouSureYouWantToDelete = Are you sure you want to delete this project? +areYouSureYouWantToDeleteProject = Are you sure you want to delete this project? + +areYouSureYouWantToDeleteWorkType = Are you sure you want to delete this work type? editTimer = Edit Timer @@ -122,6 +134,8 @@ defaultFilterStartDateToMonday = Default Filter Start Date to Monday allowMultipleActiveTimers = Allow Multiple Active Timers +displayProjectNameInTimer = Display Project Name in Timer + hours = Hours total = Total diff --git a/l10n/es.flt b/l10n/es.flt index 58292a65..705eeca7 100644 --- a/l10n/es.flt +++ b/l10n/es.flt @@ -56,7 +56,7 @@ create = Crear save = Salvar -areYouSureYouWantToDelete = ¿Estás seguro de que deseas eliminar este proyecto? +areYouSureYouWantToDeleteProject = ¿Estás seguro de que deseas eliminar este proyecto? editTimer = Editar temporizador diff --git a/l10n/fr.flt b/l10n/fr.flt index 974f4e2f..f64a2014 100644 --- a/l10n/fr.flt +++ b/l10n/fr.flt @@ -56,7 +56,7 @@ create = Créer save = sauvegarder -areYouSureYouWantToDelete = Voulez-vous vraiment supprimer ce projet? +areYouSureYouWantToDeleteProject = Voulez-vous vraiment supprimer ce projet? editTimer = Modifier la minuterie diff --git a/l10n/hi.flt b/l10n/hi.flt index feada172..315d3624 100644 --- a/l10n/hi.flt +++ b/l10n/hi.flt @@ -56,7 +56,7 @@ create = सृजन करना save = सहेजें -areYouSureYouWantToDelete = क्या आप वाकई इस प्रोजेक्ट को हटाना चाहते हैं? +areYouSureYouWantToDeleteProject = क्या आप वाकई इस प्रोजेक्ट को हटाना चाहते हैं? editTimer = टाइमर संपादित करें diff --git a/l10n/id.flt b/l10n/id.flt index 752c4a1e..fb547d51 100644 --- a/l10n/id.flt +++ b/l10n/id.flt @@ -56,7 +56,7 @@ create = Membuat save = Menyimpan -areYouSureYouWantToDelete = Anda yakin ingin menghapus proyek ini? +areYouSureYouWantToDeleteProject = Anda yakin ingin menghapus proyek ini? editTimer = Edit Timer diff --git a/l10n/it.flt b/l10n/it.flt index 5a5cee93..7280fd96 100644 --- a/l10n/it.flt +++ b/l10n/it.flt @@ -86,7 +86,7 @@ create = Crea save = Salva # tt-hand-translated -areYouSureYouWantToDelete = Sei sicuro di voler cancellare questo progetto? +areYouSureYouWantToDeleteProject = Sei sicuro di voler cancellare questo progetto? # tt-hand-translated editTimer = Modifica timer diff --git a/l10n/ja.flt b/l10n/ja.flt index 69e20973..6464d78f 100644 --- a/l10n/ja.flt +++ b/l10n/ja.flt @@ -56,7 +56,7 @@ create = 作成 save = 保存 -areYouSureYouWantToDelete = このプロジェクトを削除しますか? +areYouSureYouWantToDeleteProject = このプロジェクトを削除しますか? editTimer = タイマーを編集 diff --git a/l10n/ko.flt b/l10n/ko.flt index 26ee28e6..b8f52d57 100644 --- a/l10n/ko.flt +++ b/l10n/ko.flt @@ -56,7 +56,7 @@ create = 창조하다 save = 저장 -areYouSureYouWantToDelete = 이 프로젝트를 삭제 하시겠습니까? +areYouSureYouWantToDeleteProject = 이 프로젝트를 삭제 하시겠습니까? editTimer = 타이머 편집 diff --git a/l10n/pt.flt b/l10n/pt.flt index f90a79b1..e12c8568 100644 --- a/l10n/pt.flt +++ b/l10n/pt.flt @@ -56,7 +56,7 @@ create = Crio save = Salve  -areYouSureYouWantToDelete = Tem certeza de que deseja excluir este projeto? +areYouSureYouWantToDeleteProject = Tem certeza de que deseja excluir este projeto? editTimer = Editar temporizador diff --git a/l10n/ru.flt b/l10n/ru.flt index a1d63814..67ebad40 100644 --- a/l10n/ru.flt +++ b/l10n/ru.flt @@ -59,7 +59,7 @@ create = Создать save = Сохранить -areYouSureYouWantToDelete = Вы уверены, что хотите удалить этот проект? +areYouSureYouWantToDeleteProject = Вы уверены, что хотите удалить этот проект? editTimer = Редактировать таймер diff --git a/l10n/zh-CN.flt b/l10n/zh-CN.flt index 3f23a0e8..4cca1940 100644 --- a/l10n/zh-CN.flt +++ b/l10n/zh-CN.flt @@ -56,7 +56,7 @@ create = 创建 save = 保存 -areYouSureYouWantToDelete = 您确定要删除此项目吗? +areYouSureYouWantToDeleteProject = 您确定要删除此项目吗? editTimer = 编辑计时器 diff --git a/l10n/zh-TW.flt b/l10n/zh-TW.flt index 81952d6d..07f61601 100644 --- a/l10n/zh-TW.flt +++ b/l10n/zh-TW.flt @@ -56,7 +56,7 @@ create = 創建 save = 保存 -areYouSureYouWantToDelete = 您確定要刪除此項目嗎? +areYouSureYouWantToDeleteProject = 您確定要刪除此項目嗎? editTimer = 編輯計時器 diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index f2c154be..3b915e67 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -38,11 +38,13 @@ class SettingsBloc extends Bloc { bool exportIncludeEndTime = await settings.getBool("exportIncludeEndTime") ?? state.exportIncludeEndTime; bool exportIncludeDurationHours = await settings.getBool("exportIncludeDurationHours") ?? state.exportIncludeDurationHours; int defaultProjectID = await settings.getInt("defaultProjectID") ?? state.defaultProjectID; + int defaultWorkTypeID = await settings.getInt("defaultWorkTypeID") ?? state.defaultWorkTypeID; bool groupTimers = await settings.getBool("groupTimers") ?? state.groupTimers; bool collapseDays = await settings.getBool("collapseDays") ?? state.collapseDays; bool autocompleteDescription = await settings.getBool("autocompleteDescription") ?? state.autocompleteDescription; bool defaultFilterStartDateToMonday = await settings.getBool("defaultFilterStartDateToMonday") ?? state.defaultFilterStartDateToMonday; bool allowMultipleActiveTimers = await settings.getBool("allowMultipleActiveTimers") ?? state.allowMultipleActiveTimers; + bool displayProjectNameInTimer = await settings.getBool("displayProjectNameInTimer") ?? state.displayProjectNameInTimer; yield SettingsState( exportGroupTimers: exportGroupTimers, exportIncludeDate: exportIncludeDate, @@ -53,11 +55,13 @@ class SettingsBloc extends Bloc { exportIncludeEndTime: exportIncludeEndTime, exportIncludeDurationHours: exportIncludeDurationHours, defaultProjectID: defaultProjectID, + defaultWorkTypeID: defaultWorkTypeID, groupTimers: groupTimers, collapseDays: collapseDays, autocompleteDescription: autocompleteDescription, defaultFilterStartDateToMonday: defaultFilterStartDateToMonday, - allowMultipleActiveTimers: allowMultipleActiveTimers); + allowMultipleActiveTimers: allowMultipleActiveTimers, + displayProjectNameInTimer: displayProjectNameInTimer); } /*else if(event is SetExportGroupTimers) { await settings.setBool("exportGroupTimers", event.value); @@ -94,6 +98,9 @@ class SettingsBloc extends Bloc { else if (event is SetDefaultProjectID) { await settings.setInt("defaultProjectID", event.projectID ?? -1); yield SettingsState.clone(state, defaultProjectID: event.projectID ?? -1); + } else if (event is SetDefaultWorkTypeID) { + await settings.setInt("defaultWorkTypeID", event.workTypeID ?? -1); + yield SettingsState.clone(state, defaultWorkTypeID: event.workTypeID ?? -1); } else if (event is SetBoolValueEvent) { if (event.exportGroupTimers != null) { await settings.setBool("exportGroupTimers", event.exportGroupTimers); @@ -134,6 +141,9 @@ class SettingsBloc extends Bloc { if (event.allowMultipleActiveTimers != null) { await settings.setBool("allowMultipleActiveTimers", event.allowMultipleActiveTimers); } + if (event.displayProjectNameInTimer != null) { + await settings.setBool("displayProjectNameInTimer", event.displayProjectNameInTimer); + } yield SettingsState.clone( state, exportGroupTimers: event.exportGroupTimers, @@ -149,6 +159,7 @@ class SettingsBloc extends Bloc { autocompleteDescription: event.autocompleteDescription, defaultFilterStartDateToMonday: event.defaultFilterStartDateToMonday, allowMultipleActiveTimers: event.allowMultipleActiveTimers, + displayProjectNameInTimer: event.displayProjectNameInTimer, ); } } diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index 9bee37c3..64f3c0f3 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -78,6 +78,13 @@ class SetDefaultProjectID extends SettingsEvent { List get props => [projectID]; } +class SetDefaultWorkTypeID extends SettingsEvent { + final int workTypeID; + const SetDefaultWorkTypeID(this.workTypeID); + @override + List get props => [workTypeID]; +} + class SetBoolValueEvent extends SettingsEvent { final bool exportGroupTimers; final bool exportIncludeDate; @@ -92,6 +99,7 @@ class SetBoolValueEvent extends SettingsEvent { final bool autocompleteDescription; final bool defaultFilterStartDateToMonday; final bool allowMultipleActiveTimers; + final bool displayProjectNameInTimer; const SetBoolValueEvent( {this.exportGroupTimers, @@ -106,7 +114,8 @@ class SetBoolValueEvent extends SettingsEvent { this.collapseDays, this.autocompleteDescription, this.defaultFilterStartDateToMonday, - this.allowMultipleActiveTimers}); + this.allowMultipleActiveTimers, + this.displayProjectNameInTimer}); @override List get props => [ @@ -122,6 +131,7 @@ class SetBoolValueEvent extends SettingsEvent { collapseDays, autocompleteDescription, defaultFilterStartDateToMonday, - allowMultipleActiveTimers + allowMultipleActiveTimers, + displayProjectNameInTimer ]; } diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index ce4e4231..05b1b85d 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -25,11 +25,13 @@ class SettingsState extends Equatable { final bool exportIncludeEndTime; final bool exportIncludeDurationHours; final int defaultProjectID; + final int defaultWorkTypeID; final bool groupTimers; final bool collapseDays; final bool autocompleteDescription; final bool defaultFilterStartDateToMonday; final bool allowMultipleActiveTimers; + final bool displayProjectNameInTimer; SettingsState({ @required this.exportGroupTimers, @@ -41,11 +43,13 @@ class SettingsState extends Equatable { @required this.exportIncludeEndTime, @required this.exportIncludeDurationHours, @required this.defaultProjectID, + @required this.defaultWorkTypeID, @required this.groupTimers, @required this.collapseDays, @required this.autocompleteDescription, @required this.defaultFilterStartDateToMonday, @required this.allowMultipleActiveTimers, + @required this.displayProjectNameInTimer, }) : assert(exportGroupTimers != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), @@ -55,11 +59,13 @@ class SettingsState extends Equatable { assert(exportIncludeEndTime != null), assert(exportIncludeDurationHours != null), assert(defaultProjectID != null), + assert(defaultWorkTypeID != null), assert(groupTimers != null), assert(collapseDays != null), assert(autocompleteDescription != null), assert(defaultFilterStartDateToMonday != null), - assert(allowMultipleActiveTimers != null); + assert(allowMultipleActiveTimers != null), + assert(displayProjectNameInTimer != null); static SettingsState initial() { return SettingsState( @@ -72,11 +78,13 @@ class SettingsState extends Equatable { exportIncludeEndTime: false, exportIncludeDurationHours: true, defaultProjectID: -1, + defaultWorkTypeID: -1, groupTimers: true, collapseDays: false, autocompleteDescription: true, defaultFilterStartDateToMonday: true, allowMultipleActiveTimers: true, + displayProjectNameInTimer: true, ); } @@ -91,11 +99,13 @@ class SettingsState extends Equatable { bool exportIncludeEndTime, bool exportIncludeDurationHours, int defaultProjectID, + int defaultWorkTypeID, bool groupTimers, bool collapseDays, bool autocompleteDescription, bool defaultFilterStartDateToMonday, bool allowMultipleActiveTimers, + bool displayProjectNameInTimer, }) : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, @@ -106,11 +116,13 @@ class SettingsState extends Equatable { exportIncludeEndTime: exportIncludeEndTime ?? project.exportIncludeEndTime, exportIncludeDurationHours: exportIncludeDurationHours ?? project.exportIncludeDurationHours, defaultProjectID: defaultProjectID ?? project.defaultProjectID, + defaultWorkTypeID: defaultWorkTypeID ?? project.defaultWorkTypeID, groupTimers: groupTimers ?? project.groupTimers, collapseDays: collapseDays ?? project.collapseDays, autocompleteDescription: autocompleteDescription ?? project.autocompleteDescription, defaultFilterStartDateToMonday: defaultFilterStartDateToMonday ?? project.defaultFilterStartDateToMonday, allowMultipleActiveTimers: allowMultipleActiveTimers ?? project.allowMultipleActiveTimers, + displayProjectNameInTimer: displayProjectNameInTimer ?? project.displayProjectNameInTimer, ); @override @@ -124,10 +136,12 @@ class SettingsState extends Equatable { exportIncludeEndTime, exportIncludeDurationHours, defaultProjectID, + defaultWorkTypeID, groupTimers, collapseDays, autocompleteDescription, defaultFilterStartDateToMonday, allowMultipleActiveTimers, + displayProjectNameInTimer, ]; } diff --git a/lib/blocs/timers/timers_bloc.dart b/lib/blocs/timers/timers_bloc.dart index 8ba4b41a..2dc9aef7 100644 --- a/lib/blocs/timers/timers_bloc.dart +++ b/lib/blocs/timers/timers_bloc.dart @@ -32,19 +32,20 @@ class TimersBloc extends Bloc { if (event is LoadTimers) { List timers = await data.listTimers(); yield TimersState(timers, DateTime.now()); - } + } else if (event is CreateTimer) { TimerEntry timer = await data.createTimer( - description: event.description, projectID: event.project?.id); - List timers = - state.timers.map((t) => TimerEntry.clone(t)).toList(); + description: event.description, projectID: event.project?.id, + workTypeID: event.workType?.id); + List timers = + state.timers.map((t) => TimerEntry.clone(t)).toList(); timers.add(timer); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); } else if (event is UpdateNow) { yield TimersState(state.timers, DateTime.now()); - } + } else if (event is StopTimer) { TimerEntry timer = TimerEntry.clone(event.timer, endTime: DateTime.now()); await data.editTimer(timer); @@ -54,7 +55,7 @@ class TimersBloc extends Bloc { }).toList(); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); - } + } else if (event is EditTimer) { await data.editTimer(event.timer); List timers = state.timers.map((t) { @@ -63,16 +64,16 @@ class TimersBloc extends Bloc { }).toList(); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); - } + } else if (event is DeleteTimer) { await data.deleteTimer(event.timer); List timers = state.timers - .where((t) => t.id != event.timer.id) - .map((t) => TimerEntry.clone(t)) - .toList(); + .where((t) => t.id != event.timer.id) + .map((t) => TimerEntry.clone(t)) + .toList(); yield TimersState(timers, DateTime.now()); - } - else if(event is StopAllTimers) { + } + else if (event is StopAllTimers) { List> timerEdits = state.timers.map((t) async { if (t.endTime == null) { TimerEntry timer = TimerEntry.clone(t, endTime: DateTime.now()); diff --git a/lib/blocs/timers/timers_event.dart b/lib/blocs/timers/timers_event.dart index db00a7b2..b8a68e7f 100644 --- a/lib/blocs/timers/timers_event.dart +++ b/lib/blocs/timers/timers_event.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'package:equatable/equatable.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_entry.dart'; @@ -27,8 +28,9 @@ class LoadTimers extends TimersEvent { class CreateTimer extends TimersEvent { final String description; final Project project; - CreateTimer({this.description, this.project}); - @override List get props => [description, project]; + final WorkType workType; + CreateTimer({this.description, this.project, this.workType}); + @override List get props => [description, project, workType]; } class UpdateNow extends TimersEvent { diff --git a/lib/blocs/work_types/bloc.dart b/lib/blocs/work_types/bloc.dart new file mode 100644 index 00000000..e1c37d55 --- /dev/null +++ b/lib/blocs/work_types/bloc.dart @@ -0,0 +1,17 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'work_types_bloc.dart'; +export 'work_types_event.dart'; +export 'work_types_state.dart'; diff --git a/lib/blocs/work_types/work_types_bloc.dart b/lib/blocs/work_types/work_types_bloc.dart new file mode 100644 index 00000000..a8b8a164 --- /dev/null +++ b/lib/blocs/work_types/work_types_bloc.dart @@ -0,0 +1,69 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:timecop/data_providers/data/data_provider.dart'; +import 'package:timecop/models/WorkType.dart'; +import './bloc.dart'; + +class WorkTypesBloc extends Bloc { + final DataProvider data; + WorkTypesBloc(this.data); + + @override + WorkTypesState get initialState => WorkTypesState.initial(); + + @override + Stream mapEventToState( + WorkTypesEvent event, + ) async* { + if (event is LoadWorkTypes) { + List workTypes = await data.listWorkTypes(); + yield WorkTypesState(workTypes); + } else if (event is CreateWorkType) { + WorkType newWorkType = + await data.createWorkType(name: event.name, colour: event.colour); + List workTypes = + state.workTypes.map((workType) => WorkType.clone(workType)).toList(); + workTypes.add(newWorkType); + workTypes.sort((a, b) => a.name.compareTo(b.name)); + yield WorkTypesState(workTypes); + } else if (event is EditWorkType) { + await data.editWorkType(event.workType); + List workTypes = state.workTypes.map((workType) { + if (workType.id == event.workType.id) + return WorkType.clone(event.workType); + return WorkType.clone(workType); + }).toList(); + workTypes.sort((a, b) => a.name.compareTo(b.name)); + yield WorkTypesState(workTypes); + } else if (event is DeleteWorkType) { + await data.deleteWorkType(event.workType); + List workTypes = state.workTypes + .where((p) => p.id != event.workType.id) + .map((p) => WorkType.clone(p)) + .toList(); + yield WorkTypesState(workTypes); + } + } + + WorkType getWorkTypeByID(int id) { + if (id == null) return null; + for (WorkType p in state.workTypes) { + if (p.id == id) return p; + } + return null; + } +} diff --git a/lib/blocs/work_types/work_types_event.dart b/lib/blocs/work_types/work_types_event.dart new file mode 100644 index 00000000..3a82077a --- /dev/null +++ b/lib/blocs/work_types/work_types_event.dart @@ -0,0 +1,50 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:timecop/models/WorkType.dart'; + +abstract class WorkTypesEvent extends Equatable { + const WorkTypesEvent(); +} + +class LoadWorkTypes extends WorkTypesEvent { + @override + List get props => []; +} + +class CreateWorkType extends WorkTypesEvent { + final String name; + final Color colour; + const CreateWorkType(this.name, this.colour) + : assert(name != null), + assert(colour != null); + @override + List get props => [name, colour]; +} + +class EditWorkType extends WorkTypesEvent { + final WorkType workType; + const EditWorkType(this.workType) : assert(workType != null); + @override + List get props => [workType]; +} + +class DeleteWorkType extends WorkTypesEvent { + final WorkType workType; + const DeleteWorkType(this.workType) : assert(workType != null); + @override + List get props => [workType]; +} diff --git a/lib/blocs/work_types/work_types_state.dart b/lib/blocs/work_types/work_types_state.dart new file mode 100644 index 00000000..f38e4d99 --- /dev/null +++ b/lib/blocs/work_types/work_types_state.dart @@ -0,0 +1,34 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:equatable/equatable.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; + +class WorkTypesState extends Equatable { + final List workTypes; + + WorkTypesState(this.workTypes) : assert(workTypes != null); + + static WorkTypesState initial() { + return WorkTypesState([]); + } + + WorkTypesState.clone(WorkTypesState state) : this(state.workTypes); + + @override + List get props => [workTypes]; + @override + bool get stringify => true; +} diff --git a/lib/components/WorkTypeBadge.dart b/lib/components/WorkTypeBadge.dart new file mode 100644 index 00000000..68c6e467 --- /dev/null +++ b/lib/components/WorkTypeBadge.dart @@ -0,0 +1,58 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:badges/badges.dart'; +import 'package:flutter/material.dart'; +import 'package:timecop/models/WorkType.dart'; + +class WorkTypeBadge extends StatelessWidget { + static const double SIZE = 22; + final bool mini = false; + final WorkType workType; + const WorkTypeBadge({Key key, this.workType}) : super(key: key); + + @override + Widget build(BuildContext context) { + bool m = mini ?? false; + double scale = m ? 0.75 : 1.0; + + return workType == null + ? Container( + key: Key("wtc-${workType?.id}-m"), + width: SIZE * scale * 1.5, + height: SIZE * scale, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(SIZE * 0.5 * scale), + border: Border.all( + color: Theme.of(context).disabledColor, + width: 3.0, + ), + shape: BoxShape.rectangle, + ), + ) + : Badge( + key: Key("wtb-${workType?.id}-m"), + badgeColor: workType?.colour ?? Colors.transparent, + shape: BadgeShape.square, + borderRadius: 20, + toAnimate: false, + badgeContent: Text(workType?.name ?? "?", + style: TextStyle( + color: workType?.name != null + ? Colors.white + : Theme.of(context).disabledColor)), + ); + } +} diff --git a/lib/data_providers/data/data_provider.dart b/lib/data_providers/data/data_provider.dart index 808f39d1..6687f0db 100644 --- a/lib/data_providers/data/data_provider.dart +++ b/lib/data_providers/data/data_provider.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'package:flutter/material.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_entry.dart'; @@ -21,8 +22,14 @@ abstract class DataProvider { Future> listProjects(); Future editProject(Project project); Future deleteProject(Project project); - Future createTimer({String description, int projectID, DateTime startTime, DateTime endTime}); + + Future createWorkType({@required String name, Color colour}); + Future> listWorkTypes(); + Future editWorkType(WorkType workType); + Future deleteWorkType(WorkType workType); + + Future createTimer({String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}); Future> listTimers(); Future editTimer(TimerEntry timer); Future deleteTimer(TimerEntry timer); -} \ No newline at end of file +} diff --git a/lib/data_providers/data/database_provider.dart b/lib/data_providers/data/database_provider.dart index 221082f2..f654194d 100644 --- a/lib/data_providers/data/database_provider.dart +++ b/lib/data_providers/data/database_provider.dart @@ -19,13 +19,14 @@ import 'package:path/path.dart' as p; import 'package:random_color/random_color.dart'; import 'package:sqflite/sqflite.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/project.dart'; class DatabaseProvider extends DataProvider { final Database _db; final RandomColor _randomColour = RandomColor(); - static const int DB_VERSION = 1; + static const int DB_VERSION = 2; // version 2: added work_types DatabaseProvider(this._db) : assert(_db != null); @@ -41,14 +42,23 @@ class DatabaseProvider extends DataProvider { colour int not null ) '''); + await db.execute(''' + create table work_types( + id integer not null primary key autoincrement, + name text not null, + colour int not null + ) + '''); await db.execute(''' create table timers( id integer not null primary key autoincrement, project_id integer default null, + work_type_id integer default null, description text not null, start_time int not null, end_time int default null, - foreign key(project_id) references projects(id) on delete set null + foreign key(project_id) references projects(id) on delete set null, + foreign key(work_type_id) references work_types(id) on delete set null ) '''); await db.execute(''' @@ -56,6 +66,54 @@ class DatabaseProvider extends DataProvider { '''); } + static void _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (newVersion >= 2) { + if (oldVersion <= 1) { + await db.transaction((txn) async { + await db.execute(''' + create table work_types( + id integer not null primary key autoincrement, + name text not null, + colour int not null + ) + '''); + await txn.execute('ALTER TABLE timers RENAME TO _timers_v1'); + await txn.execute(''' + create table timers( + id integer not null primary key autoincrement, + project_id integer default null, + work_type_id integer default null, + description text not null, + start_time int not null, + end_time int default null, + foreign key(project_id) references projects(id) on delete set null, + foreign key(work_type_id) references work_types(id) on delete set null + ) + '''); + await txn.execute(''' + INSERT INTO timers ( + id, + project_id, + work_type_id, + description, + start_time, + end_time + ) + SELECT + id, + project_id, + null, + description, + start_time, + end_time + FROM _timers_v1 + '''); + await txn.execute('COMMIT'); + }); + } + } + } + static Future open() async { // get a path to the database file var databasesPath = await getDatabasesPath(); @@ -64,7 +122,7 @@ class DatabaseProvider extends DataProvider { // open the database Database db = await openDatabase(path, - onConfigure: _onConfigure, onCreate: _onCreate, version: DB_VERSION); + onConfigure: _onConfigure, onCreate: _onCreate, version: DB_VERSION, onUpgrade: _onUpgrade); DatabaseProvider repo = DatabaseProvider(db); return repo; @@ -73,11 +131,11 @@ class DatabaseProvider extends DataProvider { /// the c in crud Future createProject({@required String name, Color colour}) async { assert(name != null); - if(colour == null) { + if (colour == null) { colour = _randomColour.randomColor(); } - int id = await _db.rawInsert("insert into projects(name, colour) values(?, ?)", [name, colour.value]); + int id = await _db.rawInsert( "insert into projects(name, colour) values(?, ?)", [name, colour.value]); return Project(id: id, name: name, colour: colour); } @@ -85,10 +143,10 @@ class DatabaseProvider extends DataProvider { Future> listProjects() async { List> rawProjects = await _db.rawQuery("select id, name, colour from projects order by name asc"); return rawProjects.map((Map row) => Project( - id: row["id"] as int, - name: row["name"] as String, - colour: Color(row["colour"] as int))) - .toList(); + id: row["id"] as int, + name: row["name"] as String, + colour: Color(row["colour"] as int))) + .toList(); } /// the u in crud @@ -105,30 +163,76 @@ class DatabaseProvider extends DataProvider { } /// the c in crud - Future createTimer({String description, int projectID, DateTime startTime, DateTime endTime}) async { - int st = startTime?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch; + Future createWorkType({@required String name, Color colour}) async { + assert(name != null); + if (colour == null) { + colour = _randomColour.randomColor(); + } + + int id = await _db.rawInsert("insert into work_types(name, colour) values(?, ?)",[name, colour.value]); + return WorkType(id: id, name: name, colour: colour); + } + + /// the r in crud + Future> listWorkTypes() async { + List> rawWorkTypes = await _db.rawQuery("select id, name, colour from work_types order by name asc"); + return rawWorkTypes + .map((Map row) => WorkType( + id: row["id"] as int, + name: row["name"] as String, + colour: Color(row["colour"] as int))) + .toList(); + } + + /// the u in crud + Future editWorkType(WorkType workType) async { + assert(workType != null); + int rows = await _db.rawUpdate( + "update work_types set name=?, colour=? where id=?", + [workType.name, workType.colour.value, workType.id]); + assert(rows == 1); + } + + /// the d in crud + Future deleteWorkType(WorkType workType) async { + assert(workType != null); + await _db + .rawDelete("delete from work_types where id=?", [workType.id]); + } + + /// the c in crud + Future createTimer( + {String description, + int projectID, + int workTypeID, + DateTime startTime, + DateTime endTime}) async { + int st = startTime?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch; assert(st != null); int et = endTime?.millisecondsSinceEpoch; - int id = await _db.rawInsert("insert into timers(project_id, description, start_time, end_time) values(?, ?, ?, ?)", [projectID, description, st, et]); + int id = await _db.rawInsert("insert into timers(project_id, work_type_id, description, start_time, end_time) values(?, ?, ?, ?, ?)", [projectID, workTypeID, description, st, et]); return TimerEntry( - id: id, - description: description, - projectID: projectID, - startTime: DateTime.fromMillisecondsSinceEpoch(st), - endTime: endTime - ); + id: id, + description: description, + projectID: projectID, + workTypeID: workTypeID, + startTime: DateTime.fromMillisecondsSinceEpoch(st), + endTime: endTime + ); } /// the r in crud Future> listTimers() async { - List> rawTimers = await _db.rawQuery("select id, project_id, description, start_time, end_time from timers order by start_time asc"); + List> rawTimers = await _db.rawQuery("select id, project_id, work_type_id, description, start_time, end_time from timers order by start_time asc"); return rawTimers.map((Map row) => TimerEntry( - id: row["id"] as int, - projectID: row["project_id"] as int, - description: row["description"] as String, - startTime: DateTime.fromMillisecondsSinceEpoch(row["start_time"] as int), - endTime: row["end_time"] != null ? DateTime.fromMillisecondsSinceEpoch(row["end_time"] as int) : null, - )).toList(); + id: row["id"] as int, + projectID: row["project_id"] as int, + workTypeID: row["work_type_id"] as int, + description: row["description"] as String, + startTime: DateTime.fromMillisecondsSinceEpoch(row["start_time"] as int), + endTime: row["end_time"] != null ? DateTime.fromMillisecondsSinceEpoch(row["end_time"] as int) : null, + )).toList(); } /// the u in crud @@ -136,7 +240,7 @@ class DatabaseProvider extends DataProvider { assert(timer != null); int st = timer.startTime?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch; int et = timer.endTime?.millisecondsSinceEpoch; - await _db.rawUpdate("update timers set project_id=?, description=?, start_time=?, end_time=? where id=?", [timer.projectID, timer.description, st, et, timer.id]); + await _db.rawUpdate("update timers set project_id=?, work_type_id=?, description=?, start_time=?, end_time=? where id=?", [timer.projectID, timer.workTypeID, timer.description, st, et, timer.id]); } /// the d in crud diff --git a/lib/data_providers/data/mock_data_provider.dart b/lib/data_providers/data/mock_data_provider.dart index 7f1fc675..3465ab8a 100644 --- a/lib/data_providers/data/mock_data_provider.dart +++ b/lib/data_providers/data/mock_data_provider.dart @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_entry.dart'; import 'dart:math'; @@ -22,108 +23,95 @@ class MockDataProvider extends DataProvider { String localeKey; static final Map> l10n = { "ar": { -"ui-layout": "تخطيط واجهة المستخدم", -"administration": "الادارة", -"coffee": "قهوة", -"mockups": "نموذج تجريبي", -"app-development": "تطوير التطبيق", - + "ui-layout": "تخطيط واجهة المستخدم", + "administration": "الادارة", + "coffee": "قهوة", + "mockups": "نموذج تجريبي", + "app-development": "تطوير التطبيق", }, "de": { -"app-development": "App-Entwicklung", -"administration": "Verwaltung", -"coffee": "Kaffee", -"ui-layout": "UI-Layout", -"mockups": "Modelle", - + "app-development": "App-Entwicklung", + "administration": "Verwaltung", + "coffee": "Kaffee", + "ui-layout": "UI-Layout", + "mockups": "Modelle", }, "en": { -"administration": "Administration", -"mockups": "Mockups", -"ui-layout": "UI Layout", -"coffee": "Coffee", -"app-development": "App development" - + "administration": "Administration", + "mockups": "Mockups", + "ui-layout": "UI Layout", + "coffee": "Coffee", + "app-development": "App development" }, "es": { -"administration": "Administración", -"ui-layout": "Diseño de interfaz de usuario", -"app-development": "Desarrollo de aplicaciones", -"coffee": "café", -"mockups": "Maquetas", - + "administration": "Administración", + "ui-layout": "Diseño de interfaz de usuario", + "app-development": "Desarrollo de aplicaciones", + "coffee": "café", + "mockups": "Maquetas", }, "fr": { -"ui-layout": "Disposition de l'interface utilisateur", -"coffee": "café", -"administration": "Administration", -"mockups": "Maquettes", -"app-development": "Développement d'applications", - + "ui-layout": "Disposition de l'interface utilisateur", + "coffee": "café", + "administration": "Administration", + "mockups": "Maquettes", + "app-development": "Développement d'applications", }, "hi": { -"mockups": "मॉक-अप", -"coffee": "कॉफ़ी", -"ui-layout": "यूआई लेआउट", -"administration": "शासन प्रबंध", -"app-development": "अनुप्रयोग विकास", - + "mockups": "मॉक-अप", + "coffee": "कॉफ़ी", + "ui-layout": "यूआई लेआउट", + "administration": "शासन प्रबंध", + "app-development": "अनुप्रयोग विकास", }, "id": { -"app-development": "Pengembangan aplikasi", -"coffee": "kopi", -"ui-layout": "Layout UI", -"mockups": "Maket", -"administration": "Administrasi", - + "app-development": "Pengembangan aplikasi", + "coffee": "kopi", + "ui-layout": "Layout UI", + "mockups": "Maket", + "administration": "Administrasi", }, "ja": { -"ui-layout": "UIレイアウト", -"mockups": "モックアップ", -"app-development": "アプリ開発", -"administration": "行政", -"coffee": "コーヒー", - + "ui-layout": "UIレイアウト", + "mockups": "モックアップ", + "app-development": "アプリ開発", + "administration": "行政", + "coffee": "コーヒー", }, "ko": { -"app-development": "앱 개발", -"coffee": "커피", -"mockups": "모형", -"ui-layout": "UI 레이아웃", -"administration": "관리", - + "app-development": "앱 개발", + "coffee": "커피", + "mockups": "모형", + "ui-layout": "UI 레이아웃", + "administration": "관리", }, "pt": { -"coffee": "Café", -"mockups": "Maquetes", -"ui-layout": "Layout da interface do usuário", -"app-development": "Desenvolvimento de aplicativos", -"administration": "Administração", - + "coffee": "Café", + "mockups": "Maquetes", + "ui-layout": "Layout da interface do usuário", + "app-development": "Desenvolvimento de aplicativos", + "administration": "Administração", }, "ru": { -"mockups": "Макеты", -"coffee": "Кофе", -"app-development": "Разработка приложений", -"ui-layout": "Макет пользовательского интерфейса", -"administration": "администрация", - + "mockups": "Макеты", + "coffee": "Кофе", + "app-development": "Разработка приложений", + "ui-layout": "Макет пользовательского интерфейса", + "administration": "администрация", }, "zh-CN": { -"ui-layout": "UI布局", -"administration": "管理", -"coffee": "咖啡", -"mockups": "样机", -"app-development": "应用程式开发", - + "ui-layout": "UI布局", + "administration": "管理", + "coffee": "咖啡", + "mockups": "样机", + "app-development": "应用程式开发", }, "zh-TW": { -"administration": "管理", -"mockups": "樣機", -"app-development": "應用程式開發", -"coffee": "咖啡", -"ui-layout": "UI佈局", - + "administration": "管理", + "mockups": "樣機", + "app-development": "應用程式開發", + "coffee": "咖啡", + "ui-layout": "UI佈局", }, "it": { "app-development": "Sviluppo di app", @@ -136,7 +124,7 @@ class MockDataProvider extends DataProvider { MockDataProvider(Locale locale) { localeKey = locale.languageCode; - if(locale.languageCode == "zh") { + if (locale.languageCode == "zh") { localeKey += "-" + locale.countryCode; } } @@ -147,6 +135,17 @@ class MockDataProvider extends DataProvider { Project(id: 2, name: l10n[localeKey]["administration"], colour: Colors.pink[600]), ]; } + + Future> listWorkTypes() async { + return [ + WorkType(id: 1, name: "Requirements", colour: Colors.cyan[600]), + WorkType(id: 2, name: "Documentation", colour: Colors.pink[600]), + WorkType(id: 3, name: "Development", colour: Colors.blue[600]), + WorkType(id: 4, name: "Testing", colour: Colors.green[600]), + WorkType(id: 5, name: "Deployment", colour: Colors.yellow[600]), + ]; + } + Future> listTimers() async { int tid = 1; Random rand = Random(42); @@ -157,6 +156,7 @@ class MockDataProvider extends DataProvider { id: tid++, description: l10n[localeKey]["ui-layout"], projectID: 1, + workTypeID: 1, startTime: DateTime.now().subtract(Duration(hours: 2, minutes: 10, seconds: 1)), endTime: null, ), @@ -164,24 +164,26 @@ class MockDataProvider extends DataProvider { id: tid++, description: l10n[localeKey]["coffee"], projectID: 2, + workTypeID: 2, startTime: DateTime.now().subtract(Duration(minutes: 3, seconds: 14)), endTime: null, ), ]; // add some fake March stuff - for(int w = 0; w < 4; w++) { - for(int d = 0; d < 5; d++) { + for (int w = 0; w < 4; w++) { + for (int d = 0; d < 5; d++) { String descriptionKey; double r = rand.nextDouble(); - if(r <= 0.2) { descriptionKey = 'mockups'; } - else if(r <= 0.5) { descriptionKey = 'ui-layout'; } + if (r <= 0.2) { descriptionKey = 'mockups'; } + else if (r <= 0.5) { descriptionKey = 'ui-layout'; } else { descriptionKey = 'app-development'; } entries.add(TimerEntry( id: tid++, description: l10n[localeKey][descriptionKey], projectID: 1, + workTypeID: 1, startTime: DateTime( 2020, 3, (w * 7) + d + 2, rand.nextInt(3) + 8, @@ -200,6 +202,7 @@ class MockDataProvider extends DataProvider { id: tid++, description: l10n[localeKey]['administration'], projectID: 2, + workTypeID: 2, startTime: DateTime( 2020, 3, (w * 7) + d + 2, 14, @@ -223,12 +226,21 @@ class MockDataProvider extends DataProvider { } Future editProject(Project project) async {} Future deleteProject(Project project) async {} - Future createTimer({String description, int projectID, DateTime startTime, DateTime endTime}) async { + + Future createWorkType({@required String name, Color colour}) async { + return WorkType(id: -1, name: name, colour: colour); + } + + Future editWorkType(WorkType worktype) async {} + Future deleteWorkType(WorkType worktype) async {} + + Future createTimer({String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}) async { DateTime st = startTime ?? DateTime.now(); return TimerEntry( id: -1, description: description, projectID: projectID, + workTypeID: workTypeID, startTime: st, endTime: endTime, ); diff --git a/lib/data_providers/l10n/fluent_l10n_provider.dart b/lib/data_providers/l10n/fluent_l10n_provider.dart index befa3cb7..7da5350b 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -31,7 +31,7 @@ class FluentL10NProvider extends L10NProvider { // special handling of zh-CN & zh-TW for now if (locale.languageCode == "zh" && locale.countryCode == "CN") { src = "l10n/zh-CN.flt"; - } + } else if (locale.languageCode == "zh" && locale.countryCode == "TW") { src = "l10n/zh-TW.flt"; } @@ -45,17 +45,20 @@ class FluentL10NProvider extends L10NProvider { String get appDescription => _bundle.format("appDescription", errors: _errors); String get appLegalese => _bundle.format("appLegalese", errors: _errors); String get appName => _bundle.format("appName", errors: _errors); - String get areYouSureYouWantToDelete => _bundle.format("areYouSureYouWantToDelete", errors: _errors); + String get areYouSureYouWantToDeleteProject => _bundle.format("areYouSureYouWantToDeleteProject", errors: _errors); + String get areYouSureYouWantToDeleteWorkType => _bundle.format("areYouSureYouWantToDeleteWorkType", errors: _errors); String get cancel => _bundle.format("cancel", errors: _errors); String get changeLog => _bundle.format("changeLog", errors: _errors); String get confirmDelete => _bundle.format("confirmDelete", errors: _errors); String get create => _bundle.format("create", errors: _errors); String get createNewProject => _bundle.format("createNewProject", errors: _errors); + String get createNewWorkType => _bundle.format("createNewWorkType", errors: _errors); String get delete => _bundle.format("delete", errors: _errors); String get deleteTimerConfirm => _bundle.format("deleteTimerConfirm", errors: _errors); String get description => _bundle.format("description", errors: _errors); String get duration => _bundle.format("duration", errors: _errors); String get editProject => _bundle.format("editProject", errors: _errors); + String get editWorkType => _bundle.format("editWorkType", errors: _errors); String get editTimer => _bundle.format("editTimer", errors: _errors); String get endTime => _bundle.format("endTime", errors: _errors); String get export => _bundle.format("export", errors: _errors); @@ -63,10 +66,14 @@ class FluentL10NProvider extends L10NProvider { String get from => _bundle.format("from", errors: _errors); String get logoSemantics => _bundle.format("logoSemantics", errors: _errors); String get noProject => _bundle.format("noProject", errors: _errors); + String get noWorkType => _bundle.format("noWorkType", errors: _errors); String get pleaseEnterAName => _bundle.format("pleaseEnterAName", errors: _errors); String get project => _bundle.format("project", errors: _errors); + String get workType => _bundle.format("workType", errors: _errors); String get projectName => _bundle.format("projectName", errors: _errors); + String get workTypeName => _bundle.format("workTypeName", errors: _errors); String get projects => _bundle.format("projects", errors: _errors); + String get workTypes => _bundle.format("workTypes", errors: _errors); String get readme => _bundle.format("readme", errors: _errors); String get runningTimers => _bundle.format("runningTimers", errors: _errors); String get save => _bundle.format("save", errors: _errors); @@ -132,6 +139,7 @@ class FluentL10NProvider extends L10NProvider { String get autocompleteDescription => _bundle.format("autocompleteDescription", errors: _errors) ?? "autocompleteDescription"; String get defaultFilterStartDateToMonday => _bundle.format("defaultFilterStartDateToMonday", errors: _errors) ?? "defaultFilterStartDateToMonday"; String get allowMultipleActiveTimers => _bundle.format("allowMultipleActiveTimers", errors: _errors) ?? "allowMultipleActiveTimers"; + String get displayProjectNameInTimer => _bundle.format("displayProjectNameInTimer", errors: _errors) ?? "displayProjectNameInTimer"; String get hours => _bundle.format("hours", errors: _errors) ?? "hours"; String get total => _bundle.format("total", errors: _errors) ?? "total"; } diff --git a/lib/data_providers/l10n/l10n_provider.dart b/lib/data_providers/l10n/l10n_provider.dart index 8aee066a..f488699e 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -19,17 +19,20 @@ abstract class L10NProvider { String get appDescription; String get appLegalese; String get appName; - String get areYouSureYouWantToDelete; + String get areYouSureYouWantToDeleteProject; + String get areYouSureYouWantToDeleteWorkType; String get cancel; String get changeLog; String get confirmDelete; String get create; String get createNewProject; + String get createNewWorkType; String get delete; String get deleteTimerConfirm; String get description; String get duration; String get editProject; + String get editWorkType; String get editTimer; String get endTime; String get export; @@ -37,10 +40,14 @@ abstract class L10NProvider { String get from; String get logoSemantics; String get noProject; + String get noWorkType; String get pleaseEnterAName; String get project; + String get workType; String get projectName; + String get workTypeName; String get projects; + String get workTypes; String get readme; String get runningTimers; String get save; @@ -77,6 +84,7 @@ abstract class L10NProvider { String get autocompleteDescription; String get defaultFilterStartDateToMonday; String get allowMultipleActiveTimers; + String get displayProjectNameInTimer; String get hours; String get total; } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c1cbf000..c3e1db1c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/theme/theme_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; import 'package:timecop/data_providers/settings/settings_provider.dart'; import 'package:timecop/fontlicenses.dart'; @@ -72,6 +73,9 @@ Future runMain(SettingsProvider settings, DataProvider data) async { BlocProvider( create: (_) => ProjectsBloc(data), ), + BlocProvider( + create: (_) => WorkTypesBloc(data), + ), ], child: TimeCopApp(settings: settings), )); @@ -102,6 +106,7 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { BlocProvider.of(context).add(LoadSettingsFromRepository()); BlocProvider.of(context).add(LoadTimers()); BlocProvider.of(context).add(LoadProjects()); + BlocProvider.of(context).add(LoadWorkTypes()); BlocProvider.of(context).add(LoadThemeEvent()); BlocProvider.of(context).add(LoadLocaleEvent()); } @@ -122,43 +127,43 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return MultiRepositoryProvider( - providers: [ - RepositoryProvider.value(value: widget.settings), - ], - child: BlocBuilder( - builder: (BuildContext context, ThemeState themeState) => - BlocBuilder( - builder: (BuildContext context, LocaleState localeState) => - MaterialApp( - title: 'Time Cop', - home: DashboardScreen(), - theme: themeState.themeData ?? (brightness == Brightness.dark ? darkTheme : lightTheme), - localizationsDelegates: [ - L10N.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - locale: localeState.locale, - supportedLocales: [ - const Locale('en'), - const Locale('fr'), - const Locale('de'), - const Locale('es'), - const Locale('hi'), - const Locale('id'), - const Locale('ja'), - const Locale('ko'), - const Locale('pt'), - const Locale('ru'), - const Locale('zh', 'CN'), - const Locale('zh', 'TW'), - const Locale('ar'), - const Locale('it'), - ], - ), - ) - ) - ); + providers: [ + RepositoryProvider.value(value: widget.settings), + ], + child: BlocBuilder( + builder: (BuildContext context, ThemeState themeState) => + BlocBuilder( + builder: (BuildContext context, LocaleState localeState) => + MaterialApp( + title: 'Time Cop', + home: DashboardScreen(), + theme: themeState.themeData ?? (brightness == Brightness.dark ? darkTheme : lightTheme), + localizationsDelegates: [ + L10N.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + locale: localeState.locale, + supportedLocales: [ + const Locale('en'), + const Locale('fr'), + const Locale('de'), + const Locale('es'), + const Locale('hi'), + const Locale('id'), + const Locale('ja'), + const Locale('ko'), + const Locale('pt'), + const Locale('ru'), + const Locale('zh', 'CN'), + const Locale('zh', 'TW'), + const Locale('ar'), + const Locale('it'), + ], + ), + ) + ) + ); } } diff --git a/lib/models/WorkType.dart b/lib/models/WorkType.dart new file mode 100644 index 00000000..aec37646 --- /dev/null +++ b/lib/models/WorkType.dart @@ -0,0 +1,39 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class WorkType extends Equatable { + final int id; + final String name; + final Color colour; + + WorkType({@required this.id, @required this.name, @required this.colour}) + : assert(id != null), + assert(name != null), + assert(colour != null); + + @override + List get props => [id, name, colour]; + @override + bool get stringify => true; + + WorkType.clone(WorkType workType, {String name, Color colour}) + : this( + id: workType.id, + name: name ?? workType.name, + colour: colour ?? workType.colour, + ); +} diff --git a/lib/models/timer_entry.dart b/lib/models/timer_entry.dart index edc4d3c2..f02c49e3 100644 --- a/lib/models/timer_entry.dart +++ b/lib/models/timer_entry.dart @@ -19,37 +19,39 @@ class TimerEntry extends Equatable { final int id; final String description; final int projectID; + final int workTypeID; final DateTime startTime; final DateTime endTime; - TimerEntry({@required this.id, @required this.description, @required this.projectID, @required this.startTime, @required this.endTime}) - : assert(id != null), - assert(startTime != null); + TimerEntry({@required this.id, @required this.description, @required this.projectID, @required this.workTypeID, @required this.startTime, @required this.endTime}) + : assert(id != null), + assert(startTime != null); - @override List get props => [id, description, projectID, startTime, endTime]; + @override List get props => [id, description, projectID, workTypeID, startTime, endTime]; @override bool get stringify => true; TimerEntry.clone(TimerEntry timer, - {String description, int projectID, DateTime startTime, DateTime endTime}) - : this( - id: timer.id, - description: description ?? timer.description, - projectID: projectID ?? timer.projectID, - startTime: startTime ?? timer.startTime, - endTime: endTime ?? timer.endTime, - ); + {String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}) + : this( + id: timer.id, + description: description ?? timer.description, + projectID: projectID ?? timer.projectID, + workTypeID: workTypeID ?? timer.workTypeID, + startTime: startTime ?? timer.startTime, + endTime: endTime ?? timer.endTime, + ); static String formatDuration(Duration d) { - if(d.inHours > 0) { - return - d.inHours.toString() + ":" - + (d.inMinutes - (d.inHours * 60)).toString().padLeft(2, "0") + ":" - + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); - } + if (d.inHours > 0) { + return + d.inHours.toString() + ":" + + (d.inMinutes - (d.inHours * 60)).toString().padLeft(2, "0") + ":" + + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); + } else { - return - d.inMinutes.toString().padLeft(2, "0") + ":" - + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); + return + d.inMinutes.toString().padLeft(2, "0") + ":" + + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); } } diff --git a/lib/models/timer_group.dart b/lib/models/timer_group.dart new file mode 100644 index 00000000..7069b286 --- /dev/null +++ b/lib/models/timer_group.dart @@ -0,0 +1,26 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:equatable/equatable.dart'; + +class TimerGroup extends Equatable { + final int project; + final int workType; + final String description; + + TimerGroup(this.project, this.workType, this.description); + + @override + List get props => [project, workType, description]; +} diff --git a/lib/screens/dashboard/DashboardScreen.dart b/lib/screens/dashboard/DashboardScreen.dart index 8d591761..4df8a9be 100644 --- a/lib/screens/dashboard/DashboardScreen.dart +++ b/lib/screens/dashboard/DashboardScreen.dart @@ -16,9 +16,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:timecop/screens/dashboard/components/DescriptionField.dart'; import 'package:timecop/screens/dashboard/components/ProjectSelectField.dart'; +import 'package:timecop/screens/dashboard/components/WorkTypeSelectField.dart'; import 'package:timecop/screens/dashboard/components/RunningTimers.dart'; import 'package:timecop/screens/dashboard/components/StartTimerButton.dart'; import 'package:timecop/screens/dashboard/components/StoppedTimers.dart'; @@ -30,11 +32,12 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { final ProjectsBloc projectsBloc = BlocProvider.of(context); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); final SettingsBloc settingsBloc = BlocProvider.of(context); return - BlocProvider( - create: (_) => DashboardBloc(projectsBloc, settingsBloc), + BlocProvider( + create: (_) => DashboardBloc(projectsBloc, workTypesBloc, settingsBloc), child: Scaffold( appBar: TopBar(), body: Column( @@ -59,12 +62,16 @@ class DashboardScreen extends StatelessWidget { children: [ ProjectSelectField(), Expanded( - flex: 1, + flex: 3, child: Padding( padding: EdgeInsets.fromLTRB(4.0, 0, 4.0, 0), child: DescriptionField(), ), ), + Expanded( + flex: 1, + child: WorkTypeSelectField(), + ), Container( width: 72, height: 72, diff --git a/lib/screens/dashboard/bloc/dashboard_bloc.dart b/lib/screens/dashboard/bloc/dashboard_bloc.dart index cd2cc20d..300ab27d 100644 --- a/lib/screens/dashboard/bloc/dashboard_bloc.dart +++ b/lib/screens/dashboard/bloc/dashboard_bloc.dart @@ -4,22 +4,27 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/models/project.dart'; +import 'package:timecop/models/WorkType.dart'; part 'dashboard_event.dart'; part 'dashboard_state.dart'; class DashboardBloc extends Bloc { final ProjectsBloc projectsBloc; + final WorkTypesBloc workTypesBloc; final SettingsBloc settingsBloc; - DashboardBloc(this.projectsBloc, this.settingsBloc); + DashboardBloc(this.projectsBloc, this.workTypesBloc, this.settingsBloc); @override DashboardState get initialState { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - return DashboardState("", newProject, false, settingsBloc.getFilterStartDate(), null, [], null); + WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + + return DashboardState("", newProject, newWorkType, false, settingsBloc.getFilterStartDate(), null, [], null); } @override @@ -27,35 +32,41 @@ class DashboardBloc extends Bloc { DashboardEvent event, ) async* { if (event is DescriptionChangedEvent) { - yield DashboardState(event.description, state.newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } + yield DashboardState(event.description, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); + } else if (event is ProjectChangedEvent) { - yield DashboardState(state.newDescription, event.project, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } + yield DashboardState(state.newDescription, event.project, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); + } + else if (event is WorkTypeChangedEvent) { + yield DashboardState(state.newDescription, state.newProject, event.workType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); + } else if (event is TimerWasStartedEvent) { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - yield DashboardState("", newProject, true, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } + WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + yield DashboardState("", newProject, newWorkType, true, state.filterStart, + state.filterEnd, state.hiddenProjects, state.searchString); + } else if (event is ResetEvent) { Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - yield DashboardState("", newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } + WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + yield DashboardState("", newProject, newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); + } else if (event is FilterStartChangedEvent) { DateTime end = state.filterEnd; - if(state.filterEnd != null && event.filterStart.isAfter(state.filterEnd)) { - end = event.filterStart.add( Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); + if (state.filterEnd != null && event.filterStart.isAfter(state.filterEnd)) { + end = event.filterStart.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } - yield DashboardState(state.newDescription, state.newProject, false, event.filterStart, end, state.hiddenProjects, state.searchString); - } + yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, event.filterStart, end, state.hiddenProjects, state.searchString); + } else if (event is FilterEndChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, event.filterEnd, state.hiddenProjects, state.searchString); - } + yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, event.filterEnd, state.hiddenProjects, state.searchString); + } else if (event is FilterProjectsChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, state.filterEnd, event.projects, state.searchString); - } + yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, event.projects, state.searchString); + } else if (event is SearchChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, event.search); + yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, event.search); } } } diff --git a/lib/screens/dashboard/bloc/dashboard_event.dart b/lib/screens/dashboard/bloc/dashboard_event.dart index 631e29bf..b3716d15 100644 --- a/lib/screens/dashboard/bloc/dashboard_event.dart +++ b/lib/screens/dashboard/bloc/dashboard_event.dart @@ -16,6 +16,12 @@ class ProjectChangedEvent extends DashboardEvent { @override List get props => [project]; } +class WorkTypeChangedEvent extends DashboardEvent { + final WorkType workType; + const WorkTypeChangedEvent(this.workType); + @override List get props => [workType]; +} + class ResetEvent extends DashboardEvent { const ResetEvent(); @override List get props => []; diff --git a/lib/screens/dashboard/bloc/dashboard_state.dart b/lib/screens/dashboard/bloc/dashboard_state.dart index c1a38409..6850ae76 100644 --- a/lib/screens/dashboard/bloc/dashboard_state.dart +++ b/lib/screens/dashboard/bloc/dashboard_state.dart @@ -3,6 +3,7 @@ part of 'dashboard_bloc.dart'; class DashboardState extends Equatable { final String newDescription; final Project newProject; + final WorkType newWorkType; final bool timerWasStarted; final DateTime filterStart; final DateTime filterEnd; @@ -15,6 +16,7 @@ class DashboardState extends Equatable { const DashboardState( this.newDescription, this.newProject, + this.newWorkType, this.timerWasStarted, this.filterStart, this.filterEnd, @@ -28,23 +30,25 @@ class DashboardState extends Equatable { DashboardState state, DateTime filterStart, DateTime filterEnd, - String searchString, - { - String newDescription, - Project newProject, - bool timerWasStarted, - List hiddenProjects, - }) - : this( - newDescription ?? state.newDescription, - newProject ?? state.newProject, - timerWasStarted ?? state.timerWasStarted, - filterStart, - filterEnd, - hiddenProjects ?? state.hiddenProjects, - searchString - ); + String searchString, + { + String newDescription, + Project newProject, + WorkType newWorkType, + bool timerWasStarted, + List hiddenProjects, + }) + : this( + newDescription ?? state.newDescription, + newProject ?? state.newProject, + newWorkType ?? state.newWorkType, + timerWasStarted ?? state.timerWasStarted, + filterStart, + filterEnd, + hiddenProjects ?? state.hiddenProjects, + searchString + ); @override - List get props => [newDescription, newProject, timerWasStarted, filterStart, filterEnd, hiddenProjects, searchString]; + List get props => [newDescription, newProject, newWorkType, timerWasStarted, filterStart, filterEnd, hiddenProjects, searchString]; } diff --git a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart index b6ba8d0a..c32f34a4 100644 --- a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart +++ b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart @@ -20,12 +20,16 @@ import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/blocs/timers/timers_bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/screens/dashboard/components/StoppedTimerRow.dart'; +import 'TimerTileBuilder.dart'; + class GroupedStoppedTimersRow extends StatefulWidget { final List timers; const GroupedStoppedTimersRow({Key key, @required this.timers}) @@ -59,22 +63,10 @@ class _GroupedStoppedTimersRowState extends State with super.dispose(); } - static String formatDescription(BuildContext context, String description) { - if (description == null || description.trim().isEmpty) { - return L10N.of(context).tr.noDescription; - } - return description; - } - - static TextStyle styleDescription(BuildContext context, String description) { - if (description == null || description.trim().isEmpty) { - return TextStyle(color: Theme.of(context).disabledColor); - } - return null; - } - @override Widget build(BuildContext context) { + final TimerTileBuilder timerTileBuilder = TimerTileBuilder(context); + return Slidable( actionPane: SlidableDrawerActionPane(), actionExtentRatio: 0.15, @@ -94,10 +86,9 @@ class _GroupedStoppedTimersRowState extends State with project: BlocProvider.of(context) .getProjectByID(widget.timers[0].projectID) ), - title: Text( - formatDescription(context, widget.timers[0].description), - style: styleDescription(context, widget.timers[0].description) - ), + title: timerTileBuilder.getTitleWidget(widget.timers[0]), + subtitle: timerTileBuilder.getSubTitleWidget(widget.timers[0] + ), trailing: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -113,7 +104,7 @@ class _GroupedStoppedTimersRowState extends State with Duration(), (Duration sum, TimerEntry timer) => sum + timer.endTime.difference(timer.startTime) ) - ), + ), style: TextStyle(fontFamily: "FiraMono") ), ], @@ -132,13 +123,17 @@ class _GroupedStoppedTimersRowState extends State with assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(widget.timers.first?.projectID); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + assert(workTypesBloc != null); + WorkType workType = workTypesBloc .getWorkTypeByID(widget.timers.first?.workTypeID); + final SettingsBloc settingsBloc = BlocProvider.of(context); if (!settingsBloc.state.allowMultipleActiveTimers) { timersBloc.add(StopAllTimers()); } - timersBloc.add(CreateTimer( description: widget.timers.first?.description ?? "", project: project)); + timersBloc.add(CreateTimer( description: widget.timers.first?.description ?? "", project: project, workType: workType)); } - ) + ) ], ); } diff --git a/lib/screens/dashboard/components/PopupMenu.dart b/lib/screens/dashboard/components/PopupMenu.dart index 970b2c4d..e8bf2819 100644 --- a/lib/screens/dashboard/components/PopupMenu.dart +++ b/lib/screens/dashboard/components/PopupMenu.dart @@ -21,9 +21,10 @@ import 'package:timecop/screens/export/ExportScreen.dart'; import 'package:timecop/screens/projects/ProjectsScreen.dart'; import 'package:timecop/screens/reports/ReportsScreen.dart'; import 'package:timecop/screens/settings/SettingsScreen.dart'; +import 'package:timecop/screens/workTypes/WorkTypesScreen.dart'; enum MenuItem { - projects, reports, export, settings, about, + projects, workTypes, reports, export, settings, about, } class PopupMenu extends StatelessWidget { @@ -40,12 +41,17 @@ class PopupMenu extends StatelessWidget { ), color: Theme.of(context).scaffoldBackgroundColor, onSelected: (MenuItem item) { - switch(item) { + switch (item) { case MenuItem.projects: Navigator.of(context).push(MaterialPageRoute( builder: (BuildContext _context) => ProjectsScreen(), )); break; + case MenuItem.workTypes: + Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext _context) => WorkTypesScreen(), + )); + break; case MenuItem.reports: Navigator.of(context).push(MaterialPageRoute( builder: (BuildContext _context) => ReportsScreen(), @@ -78,6 +84,14 @@ class PopupMenu extends StatelessWidget { ), value: MenuItem.projects, ), + PopupMenuItem( + key: Key("menuWorkTypes"), + child: ListTile( + leading: Icon(FontAwesomeIcons.hardHat), + title: Text(L10N.of(context).tr.workTypes), + ), + value: MenuItem.workTypes, + ), PopupMenuItem( key: Key("menuReports"), child: ListTile( diff --git a/lib/screens/dashboard/components/RunningTimerRow.dart b/lib/screens/dashboard/components/RunningTimerRow.dart index 0515d330..90ca79d6 100644 --- a/lib/screens/dashboard/components/RunningTimerRow.dart +++ b/lib/screens/dashboard/components/RunningTimerRow.dart @@ -23,42 +23,33 @@ import 'package:timecop/l10n.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/screens/timer/TimerEditor.dart'; +import 'TimerTileBuilder.dart'; + class RunningTimerRow extends StatelessWidget { final TimerEntry timer; final DateTime now; const RunningTimerRow({Key key, @required this.timer, @required this.now}) - : assert(timer != null), - assert(now != null), - super(key: key); - - static String formatDescription(BuildContext context, String description) { - if(description == null || description.trim().isEmpty) { - return L10N.of(context).tr.noDescription; - } - return description; - } - - static TextStyle styleDescription(BuildContext context, String description) { - if(description == null || description.trim().isEmpty) { - return TextStyle(color: Theme.of(context).disabledColor); - } - return null; - } + : assert(timer != null), + assert(now != null), + super(key: key); @override Widget build(BuildContext context) { + final TimerTileBuilder timerTileBuilder = TimerTileBuilder(context); + return Slidable( actionPane: SlidableDrawerActionPane(), actionExtentRatio: 0.15, child: ListTile( - leading: ProjectColour(project: BlocProvider.of(context).getProjectByID(timer.projectID)), - title: Text(formatDescription(context, timer.description), style: styleDescription(context, timer.description)), - trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => TimerEditor(timer: timer,), - fullscreenDialog: true, - )) - ), + leading: ProjectColour( project: BlocProvider.of(context).getProjectByID(timer.projectID)), + title: timerTileBuilder.getTitleWidget(timer), + subtitle: timerTileBuilder.getSubTitleWidget(timer), + trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext context) => TimerEditor(timer: timer,), + fullscreenDialog: true, + )) + ), actions: [ IconSlideAction( color: Theme.of(context).errorColor, @@ -66,23 +57,23 @@ class RunningTimerRow extends StatelessWidget { icon: FontAwesomeIcons.trash, onTap: () async { bool delete = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(L10N.of(context).tr.confirmDelete), - content: Text(L10N.of(context).tr.deleteTimerConfirm), - actions: [ - FlatButton( - child: Text(L10N.of(context).tr.cancel), - onPressed: () => Navigator.of(context).pop(false), - ), - FlatButton( - child: Text(L10N.of(context).tr.delete), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ) - ); - if(delete) { + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(L10N.of(context).tr.confirmDelete), + content: Text(L10N.of(context).tr.deleteTimerConfirm), + actions: [ + FlatButton( + child: Text(L10N.of(context).tr.cancel), + onPressed: () => Navigator.of(context).pop(false), + ), + FlatButton( + child: Text(L10N.of(context).tr.delete), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ) + ); + if (delete) { final TimersBloc timersBloc = BlocProvider.of(context); assert(timersBloc != null); timersBloc.add(DeleteTimer(timer)); diff --git a/lib/screens/dashboard/components/StartTimerButton.dart b/lib/screens/dashboard/components/StartTimerButton.dart index 49485174..1a010ab9 100644 --- a/lib/screens/dashboard/components/StartTimerButton.dart +++ b/lib/screens/dashboard/components/StartTimerButton.dart @@ -64,7 +64,7 @@ class _StartTimerButtonState extends State { timers.add(StopAllTimers()); } - timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject)); + timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject, workType: bloc.state.newWorkType)); bloc.add(TimerWasStartedEvent()); }, ); @@ -73,6 +73,6 @@ class _StartTimerButtonState extends State { return StartTimerSpeedDial(); } } - ); + ); } } diff --git a/lib/screens/dashboard/components/StartTimerSpeedDial.dart b/lib/screens/dashboard/components/StartTimerSpeedDial.dart index b484abb6..92082a2f 100644 --- a/lib/screens/dashboard/components/StartTimerSpeedDial.dart +++ b/lib/screens/dashboard/components/StartTimerSpeedDial.dart @@ -50,8 +50,8 @@ class _StartTimerSpeedDialState extends State with TickerPr // adapted from https://stackoverflow.com/a/46480722 return Column( - mainAxisSize: MainAxisSize.min, - children: [ + mainAxisSize: MainAxisSize.min, + children: [ Container( height: 70.0, width: 56.0, @@ -60,10 +60,10 @@ class _StartTimerSpeedDialState extends State with TickerPr scale: CurvedAnimation( parent: _controller, curve: Interval( - 0.0, - 1.0, - curve: Curves.easeOut - ), + 0.0, + 1.0, + curve: Curves.easeOut + ), ), child: FloatingActionButton( heroTag: null, @@ -86,7 +86,7 @@ class _StartTimerSpeedDialState extends State with TickerPr _controller.reverse(); final TimersBloc timers = BlocProvider.of(context); assert(timers != null); - timers.add(CreateTimer( description: bloc.state.newDescription, project: bloc.state.newProject)); + timers.add(CreateTimer( description: bloc.state.newDescription, project: bloc.state.newProject, workType: bloc.state.newWorkType)); bloc.add(TimerWasStartedEvent()); }, ), @@ -100,10 +100,10 @@ class _StartTimerSpeedDialState extends State with TickerPr scale: CurvedAnimation( parent: _controller, curve: Interval( - 0.0, - 0.75, - curve: Curves.easeOut - ), + 0.0, + 0.75, + curve: Curves.easeOut + ), ), child: FloatingActionButton( heroTag: null, diff --git a/lib/screens/dashboard/components/StoppedTimerRow.dart b/lib/screens/dashboard/components/StoppedTimerRow.dart index 55101b16..1247e357 100644 --- a/lib/screens/dashboard/components/StoppedTimerRow.dart +++ b/lib/screens/dashboard/components/StoppedTimerRow.dart @@ -19,50 +19,43 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/screens/timer/TimerEditor.dart'; +import 'TimerTileBuilder.dart'; + class StoppedTimerRow extends StatelessWidget { final TimerEntry timer; const StoppedTimerRow({Key key, @required this.timer}) : assert(timer != null), super(key: key); - static String formatDescription(BuildContext context, String description) { - if (description == null || description.trim().isEmpty) { - return L10N.of(context).tr.noDescription; - } - return description; - } - - static TextStyle styleDescription(BuildContext context, String description) { - if (description == null || description.trim().isEmpty) { - return TextStyle(color: Theme.of(context).disabledColor); - } - return null; - } - - @override Widget build(BuildContext context) { assert(timer.endTime != null); + final ProjectsBloc projects = BlocProvider.of(context); + final TimerTileBuilder timerTileBuilder = TimerTileBuilder(context); + return Slidable( actionPane: SlidableDrawerActionPane(), actionExtentRatio: 0.15, child: ListTile( key: Key("stoppedTimer-" + timer.id.toString()), - leading: ProjectColour(project: BlocProvider.of(context).getProjectByID(timer.projectID)), - title: Text(formatDescription(context, timer.description), style: styleDescription(context, timer.description)), + leading: ProjectColour(project: projects.getProjectByID(timer.projectID)), + title: timerTileBuilder.getTitleWidget(timer), + subtitle: timerTileBuilder.getSubTitleWidget(timer), trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (BuildContext context) => TimerEditor( timer: timer,), fullscreenDialog: true, )) - ), + ), actions: [ IconSlideAction( color: Theme.of(context).errorColor, @@ -85,7 +78,7 @@ class StoppedTimerRow extends StatelessWidget { ), ], ) - ); + ); if (delete) { final TimersBloc timersBloc = BlocProvider.of(context); assert(timersBloc != null); @@ -106,12 +99,16 @@ class StoppedTimerRow extends StatelessWidget { assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(timer.projectID); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + assert(workTypesBloc != null); + WorkType workType = workTypesBloc.getWorkTypeByID(timer.workTypeID); + final SettingsBloc settingsBloc = BlocProvider.of(context); if (!settingsBloc.state.allowMultipleActiveTimers) { timersBloc.add(StopAllTimers()); } - timersBloc.add(CreateTimer( description: timer.description, project: project)); + timersBloc.add(CreateTimer( description: timer.description, project: project, workType: workType)); } ) ], diff --git a/lib/screens/dashboard/components/StoppedTimers.dart b/lib/screens/dashboard/components/StoppedTimers.dart index e9eee263..40bd2ac7 100644 --- a/lib/screens/dashboard/components/StoppedTimers.dart +++ b/lib/screens/dashboard/components/StoppedTimers.dart @@ -19,7 +19,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; -import 'package:timecop/models/project_description_pair.dart'; +import 'package:timecop/models/timer_group.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:timecop/screens/dashboard/components/CollapsibleDayGrouping.dart'; @@ -37,33 +37,33 @@ class DayGrouping { final SettingsBloc settingsBloc = BlocProvider.of(context); Duration runningTotal = Duration(seconds: entries.fold(0, (int sum, TimerEntry t) => sum + t.endTime.difference(t.startTime).inSeconds)); - LinkedHashMap> pairedEntries = LinkedHashMap(); - for(TimerEntry entry in entries) { - ProjectDescriptionPair pair = ProjectDescriptionPair(entry.projectID, entry.description); - if(pairedEntries.containsKey(pair)) { + LinkedHashMap> pairedEntries = LinkedHashMap(); + for (TimerEntry entry in entries) { + TimerGroup pair = TimerGroup(entry.projectID, entry.workTypeID, entry.description); + if (pairedEntries.containsKey(pair)) { pairedEntries[pair].add(entry); - } + } else { pairedEntries[pair] = [entry]; } } Iterable theDaysTimers = pairedEntries.values.map((timers) { - if(settingsBloc.state.groupTimers) { - if(timers.length > 1) { + if (settingsBloc.state.groupTimers) { + if (timers.length > 1) { return [GroupedStoppedTimersRow(timers: timers)]; - } + } else { return [StoppedTimerRow(timer: timers[0])]; } - } + } else { return timers.map((t) => StoppedTimerRow(timer: t)).toList(); } }) .expand((l) => l); - if(settingsBloc.state.collapseDays) { + if (settingsBloc.state.collapseDays) { return CollapsibleDayGrouping( date: date, totalTime: runningTotal, @@ -84,19 +84,19 @@ class DayGrouping { mainAxisSize: MainAxisSize.max, children: [ Text( - _dateFormat.format(date), - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) + _dateFormat.format(date), + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700 + ) ), Text( TimerEntry.formatDuration(runningTotal), - style: TextStyle( - color: Theme.of(context).accentColor, - fontFamily: "FiraMono", - ) - ) + style: TextStyle( + color: Theme.of(context).accentColor, + fontFamily: "FiraMono", + ) + ) ], ), Divider(), @@ -113,19 +113,19 @@ class StoppedTimers extends StatelessWidget { const StoppedTimers({Key key}) : super(key: key); static List groupDays(List days, TimerEntry timer) { - bool newDay = days.isEmpty|| !days.any((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day); - if(newDay) { + bool newDay = days.isEmpty || !days.any((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day); + if (newDay) { days.add( DayGrouping( DateTime( - timer.startTime.year, - timer.startTime.month, - timer.startTime.day, - ) - ) - ); + timer.startTime.year, + timer.startTime.month, + timer.startTime.day, + ) + ) + ); } - days.firstWhere((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day).entries.add(timer); + days.firstWhere((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day) .entries .add(timer); return days; } @@ -138,41 +138,41 @@ class StoppedTimers extends StatelessWidget { builder: (BuildContext context, DashboardState dashboardState) { // start our list of timers var timers = timersState.timers.reversed - .where((timer) => timer.endTime != null); + .where((timer) => timer.endTime != null); // filter based on filters - if(dashboardState.filterStart != null) { + if (dashboardState.filterStart != null) { timers = timers.where((timer) => timer.startTime.isAfter(dashboardState.filterStart)); } - if(dashboardState.filterEnd != null) { + if (dashboardState.filterEnd != null) { timers = timers.where((timer) => timer.startTime.isBefore(dashboardState.filterEnd)); } - + // filter based on selected projects timers = timers.where((t) => !dashboardState.hiddenProjects.any((p) => p == t.projectID)); // filter based on search - if(dashboardState.searchString != null) { + if (dashboardState.searchString != null) { timers = timers - .where((timer) { - // allow searching using a regex if surrounded by `/` and `/` - if(dashboardState.searchString.length > 2 && dashboardState.searchString.startsWith("/") && dashboardState.searchString.endsWith("/")) { - return timer - .description - ?.contains( - RegExp( + .where((timer) { + // allow searching using a regex if surrounded by `/` and `/` + if (dashboardState.searchString.length > 2 && dashboardState.searchString.startsWith("/") && dashboardState.searchString.endsWith("/")) { + return timer + .description + ?.contains( + RegExp( dashboardState.searchString.substring( - 1, - dashboardState.searchString.length - 1 + 1, + dashboardState.searchString.length - 1 + ) ) - ) - ) - ?? true; - } - else { - return timer.description?.toLowerCase()?.contains(dashboardState.searchString.toLowerCase()) ?? true; - } - }); + ) + ?? true; + } + else { + return timer.description?.toLowerCase()?.contains( dashboardState.searchString.toLowerCase()) ?? true; + } + }); } List days = timers.fold([], groupDays); diff --git a/lib/screens/dashboard/components/TimerTileBuilder.dart b/lib/screens/dashboard/components/TimerTileBuilder.dart new file mode 100644 index 00000000..9f7f99e7 --- /dev/null +++ b/lib/screens/dashboard/components/TimerTileBuilder.dart @@ -0,0 +1,75 @@ +import 'package:badges/badges.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:timecop/blocs/projects/projects_bloc.dart'; +import 'package:timecop/blocs/settings/bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; +import 'package:timecop/components/WorkTypeBadge.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/timer_entry.dart'; + +import '../../../l10n.dart'; + +class TimerTileBuilder { + final BuildContext _ctx; + SettingsBloc _settingsBloc; + ProjectsBloc _projectsBloc; + WorkTypesBloc _workTypesBloc; + + TimerTileBuilder(this._ctx) { + this._settingsBloc = BlocProvider.of(_ctx); + this._projectsBloc = BlocProvider.of(_ctx); + this._workTypesBloc = BlocProvider.of(_ctx); + } + + static String _formatDescription(BuildContext ctx, String description) { + if (description == null || description.trim().isEmpty) { + return L10N.of(ctx).tr.noDescription; + } + return description; + } + + static TextStyle _styleDescription(BuildContext ctx, String description) { + if (description == null || description.trim().isEmpty) { + return TextStyle(color: Theme.of(ctx).disabledColor); + } + return null; + } + + Widget getTitleWidget(TimerEntry timerEntry) { + String projectName; + if (timerEntry.projectID != null) { + projectName = _projectsBloc.getProjectByID(timerEntry.projectID)?.name; + } + + if (_settingsBloc.state.displayProjectNameInTimer) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(projectName != null ? projectName : ""), + if (timerEntry?.workTypeID != null) getWorkTypeBadgeWidget(timerEntry) + ], + ); + } else { + return _getTimerDescriptionTextWidget(timerEntry); + } + } + + Widget getSubTitleWidget(TimerEntry timerEntry) { + if (_settingsBloc.state.displayProjectNameInTimer) { + return _getTimerDescriptionTextWidget(timerEntry); + } else { + return null; + } + } + + Widget _getTimerDescriptionTextWidget(TimerEntry timerEntry) { + return Text(_formatDescription(_ctx, timerEntry.description), + style: _styleDescription(_ctx, timerEntry.description)); + } + + Widget getWorkTypeBadgeWidget(TimerEntry timerEntry) { + return WorkTypeBadge( + workType: _workTypesBloc.getWorkTypeByID(timerEntry.workTypeID)); + } +} diff --git a/lib/screens/dashboard/components/WorkTypeSelectField.dart b/lib/screens/dashboard/components/WorkTypeSelectField.dart new file mode 100644 index 00000000..c07a1f07 --- /dev/null +++ b/lib/screens/dashboard/components/WorkTypeSelectField.dart @@ -0,0 +1,100 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; +import 'package:timecop/components/WorkTypeBadge.dart'; +import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; + +class WorkTypeSelectField extends StatefulWidget { + WorkTypeSelectField({Key key}) : super(key: key); + + @override + _WorkTypeSelectFieldState createState() => _WorkTypeSelectFieldState(); +} + +class _WorkTypeSelectFieldState extends State { + @override + Widget build(BuildContext context) { + final DashboardBloc bloc = BlocProvider.of(context); + assert(bloc != null); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + assert(workTypesBloc != null); + return BlocBuilder( + builder: (BuildContext context, WorkTypesState workTypesState) { + return BlocBuilder( + bloc: bloc, + builder: (BuildContext context, DashboardState state) { + // detect if the workType we had selected was deleted + if (state.newWorkType != null && + workTypesBloc.getWorkTypeByID(state.newWorkType.id) == null) { + bloc.add(WorkTypeChangedEvent(null)); + return IconButton( + alignment: Alignment.centerLeft, + icon: WorkTypeBadge(workType: null), + onPressed: null, + ); + } + + return IconButton( + alignment: Alignment.centerLeft, + icon: WorkTypeBadge(workType: state.newWorkType), + onPressed: () async { + WorkType chosenWorkType = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(L10N.of(context).tr.workTypes), + contentPadding: EdgeInsets.fromLTRB(8.0, 12.0, 8.0, 16.0), + children: [null] + .followedBy(workTypesState.workTypes) + .map((WorkType w) => FlatButton( + onPressed: () { + Navigator.of(context).pop(w); + }, + child: Row( + children: [ + WorkTypeBadge(workType: w), + Padding( + padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Text( + w?.name ?? + L10N.of(context).tr.noWorkType, + style: TextStyle( + color: w == null + ? Theme.of(context) + .disabledColor + : Theme.of(context) + .textTheme + .body1 + .color)), + ), + ], + ))) + .toList(), + ); + }); + bloc.add(WorkTypeChangedEvent(chosenWorkType)); + }, + ); + }, + ); + }); + } +} diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index bafdd0b7..90e93664 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -32,7 +32,7 @@ import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; -import 'package:timecop/models/project_description_pair.dart'; +import 'package:timecop/models/timer_group.dart'; import 'package:timecop/models/timer_entry.dart'; class ExportScreen extends StatefulWidget { @@ -46,7 +46,7 @@ class DayGroup { final DateTime date; List timers = []; - DayGroup(this.date) + DayGroup(this.date) : assert(date != null); } @@ -95,13 +95,13 @@ class _ExportScreenState extends State { dbPath = copiedDB.path; } await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopDatabase(_dateFormat.format(DateTime.now())), filePath: dbPath); - } - on Exception catch (e) { + } + on Exception catch (e) { _scaffoldKey.currentState.showSnackBar( SnackBar( - backgroundColor: Theme.of(context).errorColor, - content: Text(e.toString(), style: TextStyle(color: Colors.white),), - duration: Duration(seconds: 5), + backgroundColor: Theme.of(context).errorColor, + content: Text(e.toString(), style: TextStyle(color: Colors.white),), + duration: Duration(seconds: 5), ) ); } @@ -113,11 +113,11 @@ class _ExportScreenState extends State { children: [ ExpansionTile( title: Text( - L10N.of(context).tr.filter, + L10N.of(context).tr.filter, style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) + ) ), initiallyExpanded: true, children: [ @@ -133,21 +133,21 @@ class _ExportScreenState extends State { ), onTap: () async { await DatePicker.showDatePicker( - context, - currentTime: _startDate, - onChanged: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), - onConfirm: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), + context, + currentTime: _startDate, + onChanged: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), + onConfirm: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, backgroundColor: Theme.of(context).scaffoldBackgroundColor, ) - ); + ); }, ), secondaryActions: - _startDate == null + _startDate == null ? [] : [ IconSlideAction( @@ -174,10 +174,10 @@ class _ExportScreenState extends State { ), onTap: () async { await DatePicker.showDatePicker( - context, - currentTime: _endDate, - onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + context, + currentTime: _endDate, + onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, @@ -214,8 +214,8 @@ class _ExportScreenState extends State { style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) - ), + ) + ), children: [ SwitchListTile( title: Text(L10N.of(context).tr.date), @@ -264,12 +264,12 @@ class _ExportScreenState extends State { ), ExpansionTile( title: Text( - L10N.of(context).tr.projects, + L10N.of(context).tr.projects, style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) - ), + ) + ), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -315,12 +315,12 @@ class _ExportScreenState extends State { ), ExpansionTile( title: Text( - L10N.of(context).tr.options, + L10N.of(context).tr.options, style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) - ), + ) + ), children: [ BlocBuilder( bloc: settingsBloc, @@ -399,17 +399,17 @@ class _ExportScreenState extends State { filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); // now start grouping those suckers - LinkedHashMap>> derp = LinkedHashMap(); + LinkedHashMap>> derp = LinkedHashMap(); for (TimerEntry timer in filteredTimers) { String date = _exportDateFormat.format(timer.startTime); - LinkedHashMap> pairedEntries = derp.putIfAbsent(date, () => LinkedHashMap()); - List pairedList = pairedEntries.putIfAbsent(ProjectDescriptionPair(timer.projectID, timer.description), () => []); + LinkedHashMap> pairedEntries = derp.putIfAbsent(date, () => LinkedHashMap()); + List pairedList = pairedEntries.putIfAbsent(TimerGroup( timer.projectID, timer.workTypeID, timer.description), () => []); pairedList.add(timer); } // ok, now they're grouped based on date, then combined project + description pairs // time to get them back into a flat list - filteredTimers = derp.values.expand((LinkedHashMap> pairedEntries) { + filteredTimers = derp.values.expand((LinkedHashMap> pairedEntries) { return pairedEntries.values.map((List groupedEntries) { assert(groupedEntries.isNotEmpty); @@ -417,7 +417,7 @@ class _ExportScreenState extends State { if (groupedEntries.length == 1) return groupedEntries[0]; // yes a group entry, build a dummy timer entry - Duration totalTime = groupedEntries.fold(Duration(), (Duration d, TimerEntry t) => d + t.endTime.difference(t.startTime)); + Duration totalTime = groupedEntries.fold(Duration(),(Duration d, TimerEntry t) => d + t.endTime.difference(t.startTime)); return TimerEntry.clone(groupedEntries[0], endTime: groupedEntries[0].startTime.add(totalTime)); }); }) @@ -425,10 +425,10 @@ class _ExportScreenState extends State { } List> data = >[headers] - .followedBy( - filteredTimers - .map( - (timer) { + .followedBy( + filteredTimers + .map( + (timer) { List row = []; if (settingsBloc.state.exportIncludeDate) { row.add(_exportDateFormat.format(timer.startTime)); @@ -449,12 +449,12 @@ class _ExportScreenState extends State { row.add(timer.endTime.toUtc().toIso8601String()); } if (settingsBloc.state.exportIncludeDurationHours) { - row.add((timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600.0).toStringAsFixed(4)); + row.add((timer.endTime.difference(timer.startTime).inSeconds.toDouble() /3600.0).toStringAsFixed(4)); } return row; } - ) - ).toList(); + ) + ).toList(); String csv = ListToCsvConverter().convert(data); print('CSV:'); print(csv); @@ -462,7 +462,7 @@ class _ExportScreenState extends State { Directory directory; if (Platform.isAndroid) { directory = await getExternalStorageDirectory(); - } + } else { directory = await getApplicationDocumentsDirectory(); } diff --git a/lib/screens/projects/ProjectsScreen.dart b/lib/screens/projects/ProjectsScreen.dart index ed53aebc..e10596d4 100644 --- a/lib/screens/projects/ProjectsScreen.dart +++ b/lib/screens/projects/ProjectsScreen.dart @@ -72,7 +72,7 @@ class ProjectsScreen extends StatelessWidget { text: TextSpan( style: TextStyle(color: Theme.of(context).textTheme.body1.color), children: [ - TextSpan(text: L10N.of(context).tr.areYouSureYouWantToDelete + "\n\n"), + TextSpan(text: L10N.of(context).tr.areYouSureYouWantToDeleteProject + "\n\n"), TextSpan(text: "⬤ ", style: TextStyle(color: project.colour)), TextSpan(text: project.name, style: TextStyle(fontStyle: FontStyle.italic)), ] diff --git a/lib/screens/reports/ReportsScreen.dart b/lib/screens/reports/ReportsScreen.dart index 96dd8ddd..249385c4 100644 --- a/lib/screens/reports/ReportsScreen.dart +++ b/lib/screens/reports/ReportsScreen.dart @@ -118,12 +118,12 @@ class _ReportsScreenState extends State { ), ExpansionTile( title: Text( - L10N.of(context).tr.filter, + L10N.of(context).tr.filter, style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) - ), + ) + ), initiallyExpanded: false, children: [ Slidable( @@ -193,8 +193,8 @@ class _ReportsScreenState extends State { context, currentTime: _endDate, minTime: _startDate, - onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onChanged: (DateTime dt) => setState(() => _endDate = DateTime( dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => _endDate = DateTime( dt.year, dt.month, dt.day, 23, 59, 59, 999)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, @@ -235,8 +235,8 @@ class _ReportsScreenState extends State { style: TextStyle( color: Theme.of(context).accentColor, fontWeight: FontWeight.w700 - ) - ), + ) + ), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -289,6 +289,6 @@ class _ReportsScreenState extends State { ), ], ) - ); + ); } } \ No newline at end of file diff --git a/lib/screens/settings/SettingsScreen.dart b/lib/screens/settings/SettingsScreen.dart index 4687bd74..1b8bd34b 100644 --- a/lib/screens/settings/SettingsScreen.dart +++ b/lib/screens/settings/SettingsScreen.dart @@ -95,8 +95,19 @@ class SettingsScreen extends StatelessWidget { activeColor: Theme.of(context).accentColor, ), ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settings) => + SwitchListTile( + title: Text(L10N.of(context).tr.displayProjectNameInTimer), + value: settings.displayProjectNameInTimer, + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(displayProjectNameInTimer: value)), + activeColor: Theme.of(context).accentColor, + ), + ), ], ) - ); + ); } } \ No newline at end of file diff --git a/lib/screens/timer/TimerEditor.dart b/lib/screens/timer/TimerEditor.dart index 9830aea5..ce842168 100644 --- a/lib/screens/timer/TimerEditor.dart +++ b/lib/screens/timer/TimerEditor.dart @@ -22,19 +22,22 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; +import 'package:timecop/components/WorkTypeBadge.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/clone_time.dart'; class TimerEditor extends StatefulWidget { final TimerEntry timer; TimerEditor({Key key, @required this.timer}) - : assert(timer != null), - super(key: key); + : assert(timer != null), + super(key: key); @override _TimerEditorState createState() => _TimerEditorState(); @@ -50,6 +53,7 @@ class _TimerEditorState extends State { DateTime _oldEndTime; Project _project; + WorkType _workType; FocusNode _descriptionFocus; final _formKey = GlobalKey(); Timer _updateTimer; @@ -64,6 +68,7 @@ class _TimerEditorState extends State { _startTime = widget.timer.startTime; _endTime = widget.timer.endTime; _project = BlocProvider.of(context).getProjectByID(widget.timer.projectID); + _workType = BlocProvider.of(context).getWorkTypeByID(widget.timer.workTypeID); _descriptionFocus = FocusNode(); _updateTimerStreamController = StreamController(); _updateTimer = Timer.periodic(Duration(seconds: 1), (_) => _updateTimerStreamController.add(DateTime.now())); @@ -82,7 +87,7 @@ class _TimerEditorState extends State { assert(dt != null); setState(() { // adjust the end time to keep a constant duration if we would somehow make the time negative - if(_oldEndTime != null && dt.isAfter(_oldStartTime)) { + if (_oldEndTime != null && dt.isAfter(_oldStartTime)) { Duration d = _oldEndTime.difference(_oldStartTime); _endTime = dt.add(d); } @@ -105,45 +110,95 @@ class _TimerEditorState extends State { children: [ BlocBuilder( builder: (BuildContext context, ProjectsState projectsState) => Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: DropdownButton( - value: _project, - underline: Container(), - elevation: 0, - onChanged: (Project newProject) { - setState(() { - _project = newProject; - }); - }, - items: >[ - DropdownMenuItem( - child: Row( - children: [ - ProjectColour(project: null), - Padding( - padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), - child: Text(L10N.of(context).tr.noProject, style: TextStyle(color: Theme.of(context).disabledColor)), - ), - ], - ), - value: null, - ) - ].followedBy(projectsState.projects.map( - (Project project) => DropdownMenuItem( - child: Row( - children: [ - ProjectColour(project: project,), - Padding( - padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), - child: Text(project.name), - ), - ], - ), - value: project, - ) - )).toList(), - ) - ), + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: DropdownButton( + value: _project, + underline: Container(), + elevation: 0, + onChanged: (Project newProject) { + setState(() { + _project = newProject; + }); + }, + items: >[ + DropdownMenuItem( + child: Row( + children: [ + ProjectColour(project: null), + Padding( + padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Text(L10N.of(context).tr.noProject, style: TextStyle( color: Theme.of(context).disabledColor)), + ), + ], + ), + value: null, + ) + ].followedBy(projectsState.projects.map( + (Project project) => DropdownMenuItem( + child: Row( + children: [ + ProjectColour( project: project,), + Padding( + padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Text(project.name), + ), + ], + ), + value: project, + ) + )).toList(), + ) + ), + ), + BlocBuilder( + builder: (BuildContext context, WorkTypesState workTypesState) => + Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: DropdownButton( + value: _workType, + underline: Container(), + elevation: 0, + onChanged: (WorkType newWorkType) { + setState(() { + _workType = newWorkType; + }); + }, + items: >[ + DropdownMenuItem( + child: Row( + children: [ + WorkTypeBadge(workType: null), + Padding( + padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Text(L10N.of(context).tr.noWorkType, + style: TextStyle( + color: + Theme.of(context).disabledColor)), + ), + ], + ), + value: null, + ) + ] + .followedBy(workTypesState.workTypes.map( + (WorkType workType) => + DropdownMenuItem( + child: Row( + children: [ + WorkTypeBadge( + workType: workType, + ), + Padding( + padding: EdgeInsets.fromLTRB( + 8.0, 0, 0, 0), + child: Text(workType.name), + ), + ], + ), + value: workType, + ))) + .toList(), + )), ), Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 8), @@ -160,19 +215,19 @@ class _TimerEditorState extends State { ), ), itemBuilder: (BuildContext context, String desc) => - ListTile( - title: Text(desc) - ), + ListTile( + title: Text(desc) + ), onSuggestionSelected: (String description) => _descriptionController.text = description, suggestionsCallback: (pattern) async { - if(pattern.length < 2) return []; + if (pattern.length < 2) return []; List descriptions = timers.state.timers - .where((timer) => timer.description != null) - .where((timer) => timer.description.toLowerCase().contains(pattern.toLowerCase()) ?? false) - .map((timer) => timer.description) - .toSet() - .toList(); + .where((timer) => timer.description != null) + .where((timer) => timer.description.toLowerCase().contains(pattern.toLowerCase()) ?? false) + .map((timer) => timer.description) + .toSet() + .toList(); return descriptions; }, ) @@ -195,21 +250,21 @@ class _TimerEditorState extends State { _oldStartTime = _startTime.clone(); _oldEndTime = _endTime.clone(); DateTime newStartTime = await DatePicker.showDateTimePicker( - context, - currentTime: _startTime, - maxTime: _endTime == null ? DateTime.now() : null, - onChanged: (DateTime dt) => setStartTime(dt), - onConfirm: (DateTime dt) => setStartTime(dt), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + context, + currentTime: _startTime, + maxTime: _endTime == null ? DateTime.now() : null, + onChanged: (DateTime dt) => setStartTime(dt), + onConfirm: (DateTime dt) => setStartTime(dt), + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); // if the user cancelled, this should be null - if(newStartTime == null) { + if (newStartTime == null) { setState(() { _startTime = _oldStartTime; _endTime = _oldEndTime; @@ -237,23 +292,23 @@ class _TimerEditorState extends State { title: Text(L10N.of(context).tr.endTime), trailing: Text(_endTime == null ? "—" : _dateFormat.format(_endTime)), onTap: () async { - _oldEndTime = _endTime.clone(); - DateTime newEndTime = await DatePicker.showDateTimePicker( - context, - currentTime: _endTime, - minTime: _startTime, - onChanged: (DateTime dt) => setState(() => _endTime = dt), - onConfirm: (DateTime dt) => setState(() => _endTime = dt), - theme: DatePickerTheme( - cancelStyle: Theme.of(context).textTheme.button, - doneStyle: Theme.of(context).textTheme.button, - itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + _oldEndTime = _endTime.clone(); + DateTime newEndTime = await DatePicker.showDateTimePicker( + context, + currentTime: _endTime, + minTime: _startTime, + onChanged: (DateTime dt) => setState(() => _endTime = dt), + onConfirm: (DateTime dt) => setState(() => _endTime = dt), + theme: DatePickerTheme( + cancelStyle: Theme.of(context).textTheme.button, + doneStyle: Theme.of(context).textTheme.button, + itemStyle: Theme.of(context).textTheme.body1, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + ) + ); // if the user cancelled, this should be null - if(newEndTime == null) { + if (newEndTime == null) { setState(() { _endTime = _oldEndTime; }); @@ -261,33 +316,33 @@ class _TimerEditorState extends State { }, ), secondaryActions: - _endTime == null + _endTime == null ? [ - IconSlideAction( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.clock, - onTap: () => setState(() => _endTime = DateTime.now()), - ), - ] + IconSlideAction( + color: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.clock, + onTap: () => setState(() => _endTime = DateTime.now()), + ), + ] : [ - IconSlideAction( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.clock, - onTap: () => setState(() => _endTime = DateTime.now()), - ), - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.minusCircle, - onTap: () { - setState(() { - _endTime = null; - }); - }, - ) - ], + IconSlideAction( + color: Theme.of(context).accentColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.clock, + onTap: () => setState(() => _endTime = DateTime.now()), + ), + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.minusCircle, + onTap: () { + setState(() { + _endTime = null; + }); + }, + ) + ], ), StreamBuilder( initialData: DateTime.now(), @@ -295,10 +350,10 @@ class _TimerEditorState extends State { builder: (BuildContext context, AsyncSnapshot snapshot) => ListTile( title: Text(L10N.of(context).tr.duration), trailing: Text(TimerEntry.formatDuration( - _endTime == null + _endTime == null ? snapshot.data.difference(_startTime) : _endTime.difference(_startTime) - )), + )), ), ), ], @@ -320,13 +375,14 @@ class _TimerEditorState extends State { ), onPressed: () async { bool valid = _formKey.currentState.validate(); - if(!valid) return; + if (!valid) return; TimerEntry timer = TimerEntry( id: widget.timer.id, startTime: _startTime, endTime: _endTime, projectID: _project?.id, + workTypeID: _workType?.id, description: _descriptionController.text.trim(), ); diff --git a/lib/screens/workTypes/WorkTypeEditor.dart b/lib/screens/workTypes/WorkTypeEditor.dart new file mode 100644 index 00000000..bcbc5ce5 --- /dev/null +++ b/lib/screens/workTypes/WorkTypeEditor.dart @@ -0,0 +1,115 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; +import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; + +class WorkTypeEditor extends StatefulWidget { + final WorkType workType; + WorkTypeEditor({Key key, @required this.workType}) : super(key: key); + + @override + _WorkTypeEditorState createState() => _WorkTypeEditorState(); +} + +class _WorkTypeEditorState extends State { + TextEditingController _nameController; + Color _colour; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.workType?.name); + _colour = widget.workType?.colour ?? Colors.grey[50]; + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(16), + child: Form( + key: _formKey, + child: ListView( + shrinkWrap: true, + children: [ + Text( + widget.workType == null + ? L10N.of(context).tr.createNewWorkType + : L10N.of(context).tr.editWorkType, + style: Theme.of(context).textTheme.title, + ), + TextFormField( + controller: _nameController, + validator: (String value) => value.trim().isEmpty + ? L10N.of(context).tr.pleaseEnterAName + : null, + decoration: InputDecoration( + hintText: L10N.of(context).tr.workTypeName, + ), + ), + MaterialColorPicker( + selectedColor: _colour, + shrinkWrap: true, + onColorChange: (Color colour) => _colour = colour, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + child: Text(L10N.of(context).tr.cancel), + onPressed: () => Navigator.of(context).pop(), + ), + FlatButton( + child: Text(widget.workType == null + ? L10N.of(context).tr.create + : L10N.of(context).tr.save), + onPressed: () async { + bool valid = _formKey.currentState.validate(); + if (!valid) return; + + final WorkTypesBloc workTypes = + BlocProvider.of(context); + assert(workTypes != null); + if (widget.workType == null) { + workTypes.add( + CreateWorkType(_nameController.text.trim(), _colour)); + } else { + WorkType w = WorkType.clone(widget.workType, + name: _nameController.text.trim(), colour: _colour); + workTypes.add(EditWorkType(w)); + } + Navigator.of(context).pop(); + }, + ) + ], + ) + ], + ), + ), + )); + } +} diff --git a/lib/screens/workTypes/WorkTypesScreen.dart b/lib/screens/workTypes/WorkTypesScreen.dart new file mode 100644 index 00000000..20a44f27 --- /dev/null +++ b/lib/screens/workTypes/WorkTypesScreen.dart @@ -0,0 +1,181 @@ +// Copyright 2020 Kenton Hamaluik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; +import 'package:timecop/blocs/settings/bloc.dart'; +import 'package:timecop/blocs/settings/settings_bloc.dart'; +import 'package:timecop/components/WorkTypeBadge.dart'; +import 'package:timecop/l10n.dart'; +import 'package:timecop/screens/workTypes/WorkTypeEditor.dart'; + +class WorkTypesScreen extends StatelessWidget { + const WorkTypesScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + assert(workTypesBloc != null); + final SettingsBloc settingsBloc = BlocProvider.of(context); + assert(settingsBloc != null); + + return Scaffold( + appBar: AppBar( + title: Text(L10N.of(context).tr.workTypes), + ), + body: BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settingsState) => + BlocBuilder( + bloc: workTypesBloc, + builder: (BuildContext context, WorkTypesState state) { + return ListView( + children: state.workTypes + .map((workType) => Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: WorkTypeBadge(workType: workType), + title: Text(workType.name), + trailing: settingsState.defaultWorkTypeID == + workType.id + ? Icon(FontAwesomeIcons.thumbtack) + : null, + onTap: () => showDialog( + context: context, + builder: (BuildContext context) => + WorkTypeEditor( + workType: workType, + )), + ), + actions: [ + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: + Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.trash, + onTap: () async { + bool delete = await showDialog( + context: context, + builder: (BuildContext context) => + AlertDialog( + title: Text(L10N + .of(context) + .tr + .confirmDelete), + content: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + style: TextStyle( + color: + Theme.of(context) + .textTheme + .body1 + .color), + children: [ + TextSpan( + text: L10N + .of(context) + .tr + .areYouSureYouWantToDeleteWorkType + + "\n\n"), + TextSpan( + text: "⬤ ", + style: TextStyle( + color: workType + .colour)), + TextSpan( + text: workType.name, + style: TextStyle( + fontStyle: + FontStyle + .italic)), + ])), + actions: [ + FlatButton( + child: Text(L10N + .of(context) + .tr + .cancel), + onPressed: () => + Navigator.of(context) + .pop(false), + ), + FlatButton( + child: Text(L10N + .of(context) + .tr + .delete), + onPressed: () => + Navigator.of(context) + .pop(true), + ), + ], + )); + if (delete) { + workTypesBloc + .add(DeleteWorkType(workType)); + } + }, + ) + ], + secondaryActions: [ + IconSlideAction( + color: workType.id == + settingsState.defaultWorkTypeID + ? Theme.of(context).errorColor + : Theme.of(context).accentColor, + foregroundColor: + Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.thumbtack, + onTap: () { + settingsBloc.add(SetDefaultWorkTypeID( + workType.id == + settingsState + .defaultWorkTypeID + ? null + : workType.id)); + }) + ], + )) + .toList(), + ); + }), + ), + floatingActionButton: FloatingActionButton( + key: Key("addWorkType"), + child: Stack( + // shenanigans to properly centre the icon (font awesome glyphs are variable + // width but the library currently doesn't deal with that) + fit: StackFit.expand, + children: [ + Positioned( + top: 15, + left: 16, + child: Icon(FontAwesomeIcons.plus), + ) + ], + ), + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => WorkTypeEditor( + workType: null, + )), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4cf81e1d..1427792b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,21 +7,21 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "3.0.0" about: dependency: "direct main" description: name: about url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.1.2" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "0.39.4" + version: "0.39.8" archive: dependency: transitive description: @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.4.1" + badges: + dependency: "direct main" + description: + name: badges + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" bloc: dependency: transitive description: @@ -84,7 +91,7 @@ packages: name: coverage url: "https://pub.dartlang.org" source: hosted - version: "0.13.8" + version: "0.13.9" crypto: dependency: transitive description: @@ -119,7 +126,7 @@ packages: name: equatable url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" file: dependency: transitive description: @@ -178,7 +185,7 @@ packages: name: flutter_launcher_icons url: "https://pub.dartlang.org" source: hosted - version: "0.7.4" + version: "0.7.5" flutter_localizations: dependency: "direct main" description: flutter @@ -190,7 +197,7 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.3.4" + version: "0.4.0" flutter_material_color_picker: dependency: "direct main" description: @@ -244,7 +251,7 @@ packages: name: flutter_typeahead url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" flutter_web_plugins: dependency: transitive description: flutter @@ -256,7 +263,7 @@ packages: name: font_awesome_flutter url: "https://pub.dartlang.org" source: hosted - version: "8.7.0" + version: "8.8.1" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -282,7 +289,7 @@ packages: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.12.0+4" + version: "0.12.1" http_multi_server: dependency: transitive description: @@ -296,7 +303,7 @@ packages: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.1.4" image: dependency: transitive description: @@ -317,7 +324,7 @@ packages: name: io url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.4" js: dependency: transitive description: @@ -387,42 +394,35 @@ packages: name: node_interop url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.1.1" node_io: dependency: transitive description: name: node_io url: "https://pub.dartlang.org" source: hosted - version: "1.0.1+2" + version: "1.1.1" node_preamble: dependency: transitive description: name: node_preamble url: "https://pub.dartlang.org" source: hosted - version: "1.4.8" + version: "1.4.9" package_config: dependency: transitive description: name: package_config url: "https://pub.dartlang.org" source: hosted - version: "1.9.1" + version: "1.9.3" package_info: dependency: transitive description: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "0.4.0+14" - package_resolver: - dependency: transitive - description: - name: package_resolver - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.10" + version: "0.4.0+18" path: dependency: transitive description: @@ -450,14 +450,28 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "1.6.1" + version: "1.6.9" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+3" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" pedantic: dependency: "direct dev" description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.8.0+1" + version: "1.9.0" petitparser: dependency: transitive description: @@ -472,6 +486,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.1" + platform_detect: + dependency: transitive + description: + name: platform_detect + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" plugin_platform_interface: dependency: transitive description: @@ -499,7 +520,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.0.4" + version: "4.1.2" pub_cache: dependency: transitive description: @@ -527,7 +548,7 @@ packages: name: random_color url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.5" resource: dependency: transitive description: @@ -562,21 +583,21 @@ packages: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+6" + version: "0.0.1+9" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" shared_preferences_web: dependency: transitive description: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.2+4" + version: "0.1.2+7" shelf: dependency: transitive description: @@ -590,7 +611,7 @@ packages: name: shelf_packages_handler url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.0" shelf_static: dependency: transitive description: @@ -605,6 +626,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.3" + simple_mustache: + dependency: transitive + description: + name: simple_mustache + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" sky_engine: dependency: transitive description: flutter @@ -637,7 +665,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "1.2.1" + version: "1.3.0+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" stack_trace: dependency: transitive description: @@ -742,21 +777,21 @@ packages: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+4" + version: "0.0.1+7" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.6" + version: "1.0.7" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.1+1" + version: "0.1.1+6" vector_math: dependency: transitive description: @@ -770,7 +805,7 @@ packages: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "2.3.1" + version: "4.0.4" vm_service_client: dependency: transitive description: @@ -784,7 +819,7 @@ packages: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "0.9.7+14" + version: "0.9.7+15" web_socket_channel: dependency: transitive description: @@ -819,7 +854,7 @@ packages: name: yaml url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.1" sdks: dart: ">=2.7.0 <3.0.0" - flutter: ">=1.12.13+hotfix.5 <2.0.0" + flutter: ">=1.17.1 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index a2db9495..88c9c071 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: sdk: flutter flutter_bloc: ^3.2.0 equatable: ^1.1.0 - sqflite: ^1.2.0 + sqflite: ^1.3.0 intl: font_awesome_flutter: ^8.7.0 random_color: ^1.0.3 @@ -30,6 +30,7 @@ dependencies: fl_chart: ^0.8.3 flutter_swiper: ^1.1.6 flutter_typeahead: ^1.8.0 + badges: ^1.1.1 dev_dependencies: pedantic: ^1.0.0 diff --git a/terms.flt b/terms.flt index 90f3d4d3..f1cf8992 100644 --- a/terms.flt +++ b/terms.flt @@ -14,10 +14,14 @@ whatAreYouDoing = What are you doing? projects = Projects +workTypes = Work Types + export = Export noProject = (no project) +noWorkType = (no work type) + confirmDelete = Confirm Delete deleteTimerConfirm = Are you sure you want to delete this timer? @@ -38,6 +42,8 @@ to = To project = Project +workType = Work Type + description = Description timeH = Time (hours) @@ -46,17 +52,25 @@ timeCopEntries = Time Cop Entries ({$date}) createNewProject = Create New Project +createNewWorkType = Create New Work Type + editProject = Edit Project +editWorkType = Edit Work Type + pleaseEnterAName = Please enter a name projectName = Project Name +workTypeName = Work Type Name + create = Create save = Save -areYouSureYouWantToDelete = Are you sure you want to delete this project? +areYouSureYouWantToDeleteProject = Are you sure you want to delete this project? + +areYouSureYouWantToDeleteWorkType = Are you sure you want to delete this work type? editTimer = Edit Timer @@ -123,6 +137,8 @@ defaultFilterStartDateToMonday = Default Filter Start Date to Monday allowMultipleActiveTimers = Allow Multiple Active Timers +displayProjectNameInTimer = Display Project Name in Timer + hours = Hours total = Total From 97165e8cffda1fc044aedf59ab6acfb8a6fba289 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 15:22:03 -0500 Subject: [PATCH 5/9] doc: added images of work-type screen, adding a new work-type and dashboard showing timers with work-type badges applied --- ...p-dashboard-timers-with-work-type-badges.png | Bin 0 -> 55645 bytes doc/images/TimeCop-work-types-add.png | Bin 0 -> 64798 bytes doc/images/TimeCop-work-types.png | Bin 0 -> 48669 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/images/TimeCop-dashboard-timers-with-work-type-badges.png create mode 100644 doc/images/TimeCop-work-types-add.png create mode 100644 doc/images/TimeCop-work-types.png diff --git a/doc/images/TimeCop-dashboard-timers-with-work-type-badges.png b/doc/images/TimeCop-dashboard-timers-with-work-type-badges.png new file mode 100644 index 0000000000000000000000000000000000000000..96b1db1f5c4ebeab72d737667ab4cdfde18413d6 GIT binary patch literal 55645 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 JPDHLkV1kCns@wno literal 0 HcmV?d00001 diff --git a/doc/images/TimeCop-work-types-add.png b/doc/images/TimeCop-work-types-add.png new file mode 100644 index 0000000000000000000000000000000000000000..eb44057ad85b40095548618f3c65bfe7e56c4934 GIT binary patch literal 64798 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 Wl8Ob1T%ke%eiUR>rOPCZgZ>A#?HYLi literal 0 HcmV?d00001 diff --git a/doc/images/TimeCop-work-types.png b/doc/images/TimeCop-work-types.png new file mode 100644 index 0000000000000000000000000000000000000000..8d1753c4539bfa6aa1ae7c43a718596d6a8f7b63 GIT binary patch literal 48669 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 v#mg literal 0 HcmV?d00001 From 9a2abd1e2ea387362d25156d5ecb52e3e92ef9b6 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 15:44:05 -0500 Subject: [PATCH 6/9] formatted via Android Studio --- lib/blocs/locale/locale_bloc.dart | 8 +- lib/blocs/locale/locale_event.dart | 9 +- lib/blocs/locale/locale_state.dart | 2 + lib/blocs/projects/projects_bloc.dart | 12 +- lib/blocs/projects/projects_event.dart | 28 +- lib/blocs/projects/projects_state.dart | 15 +- lib/blocs/settings/bloc.dart | 6 +- lib/blocs/settings/settings_bloc.dart | 100 ++++-- lib/blocs/settings/settings_event.dart | 4 + lib/blocs/settings/settings_state.dart | 30 +- lib/blocs/theme/theme_bloc.dart | 6 +- lib/blocs/theme/theme_event.dart | 9 +- lib/blocs/theme/theme_state.dart | 19 +- lib/blocs/timers/timers_bloc.dart | 36 +-- lib/blocs/timers/timers_event.dart | 31 +- lib/blocs/timers/timers_state.dart | 14 +- lib/blocs/work_types/work_types_bloc.dart | 3 + lib/blocs/work_types/work_types_event.dart | 6 + lib/blocs/work_types/work_types_state.dart | 2 +- lib/components/ProjectColour.dart | 13 +- lib/components/WorkTypeBadge.dart | 1 + lib/data_providers/data/data_provider.dart | 16 +- .../data/database_provider.dart | 69 ++-- .../data/mock_data_provider.dart | 48 ++- .../l10n/fluent_l10n_provider.dart | 206 +++++++++--- lib/data_providers/l10n/l10n_provider.dart | 73 ++++- .../settings/mock_settings_provider.dart | 5 +- .../settings/settings_provider.dart | 5 + .../shared_prefs_settings_provider.dart | 11 +- lib/fontlicenses.dart | 8 +- lib/l10n.dart | 24 +- lib/main.dart | 24 +- lib/models/WorkType.dart | 1 + lib/models/clone_time.dart | 22 +- lib/models/project.dart | 28 +- lib/models/project_description_pair.dart | 11 +- lib/models/start_of_week.dart | 8 +- lib/models/theme_type.dart | 55 ++-- lib/models/timer_entry.dart | 42 ++- lib/screens/dashboard/DashboardScreen.dart | 10 +- .../dashboard/bloc/dashboard_bloc.dart | 135 ++++++-- .../dashboard/bloc/dashboard_event.dart | 43 ++- .../dashboard/bloc/dashboard_state.dart | 30 +- .../components/CollapsibleDayGrouping.dart | 73 +++-- .../components/DescriptionField.dart | 100 +++--- .../dashboard/components/FilterButton.dart | 12 +- .../dashboard/components/FilterSheet.dart | 249 +++++++------- .../components/GroupedStoppedTimersRow.dart | 66 ++-- .../dashboard/components/PopupMenu.dart | 9 +- .../components/ProjectSelectField.dart | 99 +++--- .../dashboard/components/RunningTimerRow.dart | 26 +- .../dashboard/components/RunningTimers.dart | 46 +-- .../components/StartTimerButton.dart | 14 +- .../components/StartTimerSpeedDial.dart | 40 ++- .../dashboard/components/StoppedTimerRow.dart | 48 +-- .../dashboard/components/StoppedTimers.dart | 103 +++--- .../components/TimerTileBuilder.dart | 2 - lib/screens/dashboard/components/TopBar.dart | 87 +++-- lib/screens/export/ExportScreen.dart | 263 ++++++++------- lib/screens/projects/ProjectEditor.dart | 112 ++++--- lib/screens/projects/ProjectsScreen.dart | 201 +++++++----- lib/screens/reports/ReportsScreen.dart | 119 ++++--- lib/screens/reports/components/Legend.dart | 35 +- .../reports/components/ProjectBreakdown.dart | 157 +++++---- lib/screens/reports/components/TimeTable.dart | 150 +++++---- .../reports/components/WeekdayAverages.dart | 304 +++++++++--------- .../reports/components/WeeklyTotals.dart | 257 ++++++++------- lib/screens/settings/SettingsScreen.dart | 14 +- .../settings/components/locale_options.dart | 130 ++++---- .../settings/components/theme_options.dart | 119 +++---- lib/screens/timer/TimerEditor.dart | 103 +++--- lib/screens/workTypes/WorkTypeEditor.dart | 1 + lib/screens/workTypes/WorkTypesScreen.dart | 2 +- 73 files changed, 2444 insertions(+), 1725 deletions(-) diff --git a/lib/blocs/locale/locale_bloc.dart b/lib/blocs/locale/locale_bloc.dart index 188a0d26..771a7adf 100644 --- a/lib/blocs/locale/locale_bloc.dart +++ b/lib/blocs/locale/locale_bloc.dart @@ -10,8 +10,9 @@ part 'locale_state.dart'; class LocaleBloc extends Bloc { final SettingsProvider settings; + LocaleBloc(this.settings); - + @override LocaleState get initialState => LocaleState(null); @@ -19,10 +20,9 @@ class LocaleBloc extends Bloc { Stream mapEventToState( LocaleEvent event, ) async* { - if(event is LoadLocaleEvent) { + if (event is LoadLocaleEvent) { yield LocaleState(settings.getLocale()); - } - else if(event is ChangeLocaleEvent) { + } else if (event is ChangeLocaleEvent) { settings.setLocale(event.locale); yield LocaleState(event.locale); } diff --git a/lib/blocs/locale/locale_event.dart b/lib/blocs/locale/locale_event.dart index 462d7d5b..e2fc0f2e 100644 --- a/lib/blocs/locale/locale_event.dart +++ b/lib/blocs/locale/locale_event.dart @@ -6,11 +6,16 @@ abstract class LocaleEvent extends Equatable { class LoadLocaleEvent extends LocaleEvent { const LoadLocaleEvent(); - @override List get props => []; + + @override + List get props => []; } class ChangeLocaleEvent extends LocaleEvent { final Locale locale; + const ChangeLocaleEvent(this.locale); - @override List get props => [locale]; + + @override + List get props => [locale]; } diff --git a/lib/blocs/locale/locale_state.dart b/lib/blocs/locale/locale_state.dart index c4f91458..adfbdd30 100644 --- a/lib/blocs/locale/locale_state.dart +++ b/lib/blocs/locale/locale_state.dart @@ -2,7 +2,9 @@ part of 'locale_bloc.dart'; class LocaleState extends Equatable { final Locale locale; + const LocaleState(this.locale); + @override List get props => [locale]; } diff --git a/lib/blocs/projects/projects_bloc.dart b/lib/blocs/projects/projects_bloc.dart index faeda170..7a8e4dcf 100644 --- a/lib/blocs/projects/projects_bloc.dart +++ b/lib/blocs/projects/projects_bloc.dart @@ -13,13 +13,16 @@ // limitations under the License. import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; import 'package:timecop/models/project.dart'; + import './bloc.dart'; class ProjectsBloc extends Bloc { final DataProvider data; + ProjectsBloc(this.data); @override @@ -32,8 +35,7 @@ class ProjectsBloc extends Bloc { if (event is LoadProjects) { List projects = await data.listProjects(); yield ProjectsState(projects); - } - else if (event is CreateProject) { + } else if (event is CreateProject) { Project newProject = await data.createProject(name: event.name, colour: event.colour); List projects = @@ -41,8 +43,7 @@ class ProjectsBloc extends Bloc { projects.add(newProject); projects.sort((a, b) => a.name.compareTo(b.name)); yield ProjectsState(projects); - } - else if (event is EditProject) { + } else if (event is EditProject) { await data.editProject(event.project); List projects = state.projects.map((project) { if (project.id == event.project.id) return Project.clone(event.project); @@ -50,8 +51,7 @@ class ProjectsBloc extends Bloc { }).toList(); projects.sort((a, b) => a.name.compareTo(b.name)); yield ProjectsState(projects); - } - else if (event is DeleteProject) { + } else if (event is DeleteProject) { await data.deleteProject(event.project); List projects = state.projects .where((p) => p.id != event.project.id) diff --git a/lib/blocs/projects/projects_event.dart b/lib/blocs/projects/projects_event.dart index ee143bed..63ebbb3d 100644 --- a/lib/blocs/projects/projects_event.dart +++ b/lib/blocs/projects/projects_event.dart @@ -21,28 +21,36 @@ abstract class ProjectsEvent extends Equatable { } class LoadProjects extends ProjectsEvent { - @override List get props => []; + @override + List get props => []; } class CreateProject extends ProjectsEvent { final String name; final Color colour; + const CreateProject(this.name, this.colour) - : assert(name != null), - assert(colour != null); - @override List get props => [name, colour]; + : assert(name != null), + assert(colour != null); + + @override + List get props => [name, colour]; } class EditProject extends ProjectsEvent { final Project project; - const EditProject(this.project) - : assert(project != null); - @override List get props => [project]; + + const EditProject(this.project) : assert(project != null); + + @override + List get props => [project]; } class DeleteProject extends ProjectsEvent { final Project project; - const DeleteProject(this.project) - : assert(project != null); - @override List get props => [project]; + + const DeleteProject(this.project) : assert(project != null); + + @override + List get props => [project]; } diff --git a/lib/blocs/projects/projects_state.dart b/lib/blocs/projects/projects_state.dart index 74f6cb07..10f5d100 100644 --- a/lib/blocs/projects/projects_state.dart +++ b/lib/blocs/projects/projects_state.dart @@ -18,16 +18,17 @@ import 'package:timecop/models/project.dart'; class ProjectsState extends Equatable { final List projects; - ProjectsState(this.projects) - : assert(projects != null); + ProjectsState(this.projects) : assert(projects != null); static ProjectsState initial() { return ProjectsState([]); } - ProjectsState.clone(ProjectsState state) - : this(state.projects); + ProjectsState.clone(ProjectsState state) : this(state.projects); - @override List get props => [projects]; - @override bool get stringify => true; -} \ No newline at end of file + @override + List get props => [projects]; + + @override + bool get stringify => true; +} diff --git a/lib/blocs/settings/bloc.dart b/lib/blocs/settings/bloc.dart index 8d381fc3..319cf3eb 100644 --- a/lib/blocs/settings/bloc.dart +++ b/lib/blocs/settings/bloc.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index 3b915e67..323bf25f 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -13,12 +13,15 @@ // limitations under the License. import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:timecop/data_providers/settings/settings_provider.dart'; + import './bloc.dart'; class SettingsBloc extends Bloc { final SettingsProvider settings; + SettingsBloc(this.settings); @override @@ -29,22 +32,48 @@ class SettingsBloc extends Bloc { SettingsEvent event, ) async* { if (event is LoadSettingsFromRepository) { - bool exportGroupTimers = await settings.getBool("exportGroupTimers") ?? state.exportGroupTimers; - bool exportIncludeProject = await settings.getBool("exportIncludeProject") ?? state.exportIncludeProject; - bool exportIncludeDate = await settings.getBool("exportIncludeDate") ?? state.exportIncludeDate; - bool exportIncludeDescription = await settings.getBool("exportIncludeDescription") ?? state.exportIncludeDescription; - bool exportIncludeProjectDescription = await settings.getBool("exportIncludeProjectDescription") ?? state.exportIncludeProjectDescription; - bool exportIncludeStartTime = await settings.getBool("exportIncludeStartTime") ?? state.exportIncludeStartTime; - bool exportIncludeEndTime = await settings.getBool("exportIncludeEndTime") ?? state.exportIncludeEndTime; - bool exportIncludeDurationHours = await settings.getBool("exportIncludeDurationHours") ?? state.exportIncludeDurationHours; - int defaultProjectID = await settings.getInt("defaultProjectID") ?? state.defaultProjectID; - int defaultWorkTypeID = await settings.getInt("defaultWorkTypeID") ?? state.defaultWorkTypeID; - bool groupTimers = await settings.getBool("groupTimers") ?? state.groupTimers; - bool collapseDays = await settings.getBool("collapseDays") ?? state.collapseDays; - bool autocompleteDescription = await settings.getBool("autocompleteDescription") ?? state.autocompleteDescription; - bool defaultFilterStartDateToMonday = await settings.getBool("defaultFilterStartDateToMonday") ?? state.defaultFilterStartDateToMonday; - bool allowMultipleActiveTimers = await settings.getBool("allowMultipleActiveTimers") ?? state.allowMultipleActiveTimers; - bool displayProjectNameInTimer = await settings.getBool("displayProjectNameInTimer") ?? state.displayProjectNameInTimer; + bool exportGroupTimers = await settings.getBool("exportGroupTimers") ?? + state.exportGroupTimers; + bool exportIncludeProject = + await settings.getBool("exportIncludeProject") ?? + state.exportIncludeProject; + bool exportIncludeDate = await settings.getBool("exportIncludeDate") ?? + state.exportIncludeDate; + bool exportIncludeDescription = + await settings.getBool("exportIncludeDescription") ?? + state.exportIncludeDescription; + bool exportIncludeProjectDescription = + await settings.getBool("exportIncludeProjectDescription") ?? + state.exportIncludeProjectDescription; + bool exportIncludeStartTime = + await settings.getBool("exportIncludeStartTime") ?? + state.exportIncludeStartTime; + bool exportIncludeEndTime = + await settings.getBool("exportIncludeEndTime") ?? + state.exportIncludeEndTime; + bool exportIncludeDurationHours = + await settings.getBool("exportIncludeDurationHours") ?? + state.exportIncludeDurationHours; + int defaultProjectID = + await settings.getInt("defaultProjectID") ?? state.defaultProjectID; + int defaultWorkTypeID = + await settings.getInt("defaultWorkTypeID") ?? state.defaultWorkTypeID; + bool groupTimers = + await settings.getBool("groupTimers") ?? state.groupTimers; + bool collapseDays = + await settings.getBool("collapseDays") ?? state.collapseDays; + bool autocompleteDescription = + await settings.getBool("autocompleteDescription") ?? + state.autocompleteDescription; + bool defaultFilterStartDateToMonday = + await settings.getBool("defaultFilterStartDateToMonday") ?? + state.defaultFilterStartDateToMonday; + bool allowMultipleActiveTimers = + await settings.getBool("allowMultipleActiveTimers") ?? + state.allowMultipleActiveTimers; + bool displayProjectNameInTimer = + await settings.getBool("displayProjectNameInTimer") ?? + state.displayProjectNameInTimer; yield SettingsState( exportGroupTimers: exportGroupTimers, exportIncludeDate: exportIncludeDate, @@ -100,7 +129,8 @@ class SettingsBloc extends Bloc { yield SettingsState.clone(state, defaultProjectID: event.projectID ?? -1); } else if (event is SetDefaultWorkTypeID) { await settings.setInt("defaultWorkTypeID", event.workTypeID ?? -1); - yield SettingsState.clone(state, defaultWorkTypeID: event.workTypeID ?? -1); + yield SettingsState.clone(state, + defaultWorkTypeID: event.workTypeID ?? -1); } else if (event is SetBoolValueEvent) { if (event.exportGroupTimers != null) { await settings.setBool("exportGroupTimers", event.exportGroupTimers); @@ -109,22 +139,28 @@ class SettingsBloc extends Bloc { await settings.setBool("exportIncludeDate", event.exportIncludeDate); } if (event.exportIncludeProject != null) { - await settings.setBool("exportIncludeProject", event.exportIncludeProject); + await settings.setBool( + "exportIncludeProject", event.exportIncludeProject); } if (event.exportIncludeDescription != null) { - await settings.setBool("exportIncludeDescription", event.exportIncludeDescription); + await settings.setBool( + "exportIncludeDescription", event.exportIncludeDescription); } if (event.exportIncludeProjectDescription != null) { - await settings.setBool("exportIncludeProjectDescription", event.exportIncludeProjectDescription); + await settings.setBool("exportIncludeProjectDescription", + event.exportIncludeProjectDescription); } if (event.exportIncludeStartTime != null) { - await settings.setBool("exportIncludeStartTime", event.exportIncludeStartTime); + await settings.setBool( + "exportIncludeStartTime", event.exportIncludeStartTime); } if (event.exportIncludeEndTime != null) { - await settings.setBool("exportIncludeEndTime", event.exportIncludeEndTime); + await settings.setBool( + "exportIncludeEndTime", event.exportIncludeEndTime); } if (event.exportIncludeDurationHours != null) { - await settings.setBool("exportIncludeDurationHours", event.exportIncludeDurationHours); + await settings.setBool( + "exportIncludeDurationHours", event.exportIncludeDurationHours); } if (event.groupTimers != null) { await settings.setBool("groupTimers", event.groupTimers); @@ -133,16 +169,20 @@ class SettingsBloc extends Bloc { await settings.setBool("collapseDays", event.collapseDays); } if (event.autocompleteDescription != null) { - await settings.setBool("autocompleteDescription", event.autocompleteDescription); + await settings.setBool( + "autocompleteDescription", event.autocompleteDescription); } if (event.defaultFilterStartDateToMonday != null) { - await settings.setBool("defaultFilterStartDateToMonday", event.defaultFilterStartDateToMonday); + await settings.setBool("defaultFilterStartDateToMonday", + event.defaultFilterStartDateToMonday); } if (event.allowMultipleActiveTimers != null) { - await settings.setBool("allowMultipleActiveTimers", event.allowMultipleActiveTimers); + await settings.setBool( + "allowMultipleActiveTimers", event.allowMultipleActiveTimers); } if (event.displayProjectNameInTimer != null) { - await settings.setBool("displayProjectNameInTimer", event.displayProjectNameInTimer); + await settings.setBool( + "displayProjectNameInTimer", event.displayProjectNameInTimer); } yield SettingsState.clone( state, @@ -172,11 +212,13 @@ class SettingsBloc extends Bloc { */ DateTime getFilterStartDate() { DateTime now = DateTime.now(); - DateTime todayZerothHour = DateTime(now.year, now.month, now.day, 0, 0, 0, 0, 0); + DateTime todayZerothHour = + DateTime(now.year, now.month, now.day, 0, 0, 0, 0, 0); DateTime startDate; if (state.defaultFilterStartDateToMonday) { var dayOfWeek = 1; // Monday=1, Tuesday=2... - startDate = todayZerothHour.subtract(Duration(days: todayZerothHour.weekday - dayOfWeek)); + startDate = todayZerothHour + .subtract(Duration(days: todayZerothHour.weekday - dayOfWeek)); } else { startDate = todayZerothHour.subtract(Duration(days: 30)); } diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index 64f3c0f3..67606163 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -73,14 +73,18 @@ class SetExportIncludeDurationHours extends SettingsEvent { class SetDefaultProjectID extends SettingsEvent { final int projectID; + const SetDefaultProjectID(this.projectID); + @override List get props => [projectID]; } class SetDefaultWorkTypeID extends SettingsEvent { final int workTypeID; + const SetDefaultWorkTypeID(this.workTypeID); + @override List get props => [workTypeID]; } diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index 05b1b85d..19ef6135 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -109,20 +109,30 @@ class SettingsState extends Equatable { }) : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, - exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, - exportIncludeDescription: exportIncludeDescription ?? project.exportIncludeDescription, - exportIncludeProjectDescription: exportIncludeProjectDescription ?? project.exportIncludeProjectDescription, - exportIncludeStartTime: exportIncludeStartTime ?? project.exportIncludeStartTime, - exportIncludeEndTime: exportIncludeEndTime ?? project.exportIncludeEndTime, - exportIncludeDurationHours: exportIncludeDurationHours ?? project.exportIncludeDurationHours, + exportIncludeProject: + exportIncludeProject ?? project.exportIncludeProject, + exportIncludeDescription: + exportIncludeDescription ?? project.exportIncludeDescription, + exportIncludeProjectDescription: exportIncludeProjectDescription ?? + project.exportIncludeProjectDescription, + exportIncludeStartTime: + exportIncludeStartTime ?? project.exportIncludeStartTime, + exportIncludeEndTime: + exportIncludeEndTime ?? project.exportIncludeEndTime, + exportIncludeDurationHours: + exportIncludeDurationHours ?? project.exportIncludeDurationHours, defaultProjectID: defaultProjectID ?? project.defaultProjectID, defaultWorkTypeID: defaultWorkTypeID ?? project.defaultWorkTypeID, groupTimers: groupTimers ?? project.groupTimers, collapseDays: collapseDays ?? project.collapseDays, - autocompleteDescription: autocompleteDescription ?? project.autocompleteDescription, - defaultFilterStartDateToMonday: defaultFilterStartDateToMonday ?? project.defaultFilterStartDateToMonday, - allowMultipleActiveTimers: allowMultipleActiveTimers ?? project.allowMultipleActiveTimers, - displayProjectNameInTimer: displayProjectNameInTimer ?? project.displayProjectNameInTimer, + autocompleteDescription: + autocompleteDescription ?? project.autocompleteDescription, + defaultFilterStartDateToMonday: defaultFilterStartDateToMonday ?? + project.defaultFilterStartDateToMonday, + allowMultipleActiveTimers: + allowMultipleActiveTimers ?? project.allowMultipleActiveTimers, + displayProjectNameInTimer: + displayProjectNameInTimer ?? project.displayProjectNameInTimer, ); @override diff --git a/lib/blocs/theme/theme_bloc.dart b/lib/blocs/theme/theme_bloc.dart index c72cc43a..55266746 100644 --- a/lib/blocs/theme/theme_bloc.dart +++ b/lib/blocs/theme/theme_bloc.dart @@ -12,6 +12,7 @@ part 'theme_state.dart'; class ThemeBloc extends Bloc { final SettingsProvider settings; + ThemeBloc(this.settings); @override @@ -21,10 +22,9 @@ class ThemeBloc extends Bloc { Stream mapEventToState( ThemeEvent event, ) async* { - if(event is LoadThemeEvent) { + if (event is LoadThemeEvent) { yield ThemeState(settings.getTheme()); - } - else if(event is ChangeThemeEvent) { + } else if (event is ChangeThemeEvent) { settings.setTheme(event.theme); yield ThemeState(event.theme); } diff --git a/lib/blocs/theme/theme_event.dart b/lib/blocs/theme/theme_event.dart index ac75c1af..3e20f814 100644 --- a/lib/blocs/theme/theme_event.dart +++ b/lib/blocs/theme/theme_event.dart @@ -6,11 +6,16 @@ abstract class ThemeEvent extends Equatable { class LoadThemeEvent extends ThemeEvent { const LoadThemeEvent(); - @override List get props => []; + + @override + List get props => []; } class ChangeThemeEvent extends ThemeEvent { final ThemeType theme; + const ChangeThemeEvent(this.theme); - @override List get props => [theme]; + + @override + List get props => [theme]; } diff --git a/lib/blocs/theme/theme_state.dart b/lib/blocs/theme/theme_state.dart index 8bf7ca1e..fe031766 100644 --- a/lib/blocs/theme/theme_state.dart +++ b/lib/blocs/theme/theme_state.dart @@ -2,15 +2,22 @@ part of 'theme_bloc.dart'; class ThemeState extends Equatable { final ThemeType theme; + ThemeState(this.theme); - @override List get props => [theme]; + + @override + List get props => [theme]; ThemeData get themeData { - switch(theme) { - case ThemeType.auto: return null; - case ThemeType.light: return lightTheme; - case ThemeType.dark: return darkTheme; - case ThemeType.black: return blackTheme; + switch (theme) { + case ThemeType.auto: + return null; + case ThemeType.light: + return lightTheme; + case ThemeType.dark: + return darkTheme; + case ThemeType.black: + return blackTheme; } return null; } diff --git a/lib/blocs/timers/timers_bloc.dart b/lib/blocs/timers/timers_bloc.dart index 2dc9aef7..fdde231a 100644 --- a/lib/blocs/timers/timers_bloc.dart +++ b/lib/blocs/timers/timers_bloc.dart @@ -13,13 +13,16 @@ // limitations under the License. import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; import 'package:timecop/models/timer_entry.dart'; + import './bloc.dart'; class TimersBloc extends Bloc { final DataProvider data; + TimersBloc(this.data); @override @@ -32,21 +35,19 @@ class TimersBloc extends Bloc { if (event is LoadTimers) { List timers = await data.listTimers(); yield TimersState(timers, DateTime.now()); - } - else if (event is CreateTimer) { + } else if (event is CreateTimer) { TimerEntry timer = await data.createTimer( - description: event.description, projectID: event.project?.id, - workTypeID: event.workType?.id); - List timers = - state.timers.map((t) => TimerEntry.clone(t)).toList(); + description: event.description, + projectID: event.project?.id, + workTypeID: event.workType?.id); + List timers = + state.timers.map((t) => TimerEntry.clone(t)).toList(); timers.add(timer); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); - } - else if (event is UpdateNow) { + } else if (event is UpdateNow) { yield TimersState(state.timers, DateTime.now()); - } - else if (event is StopTimer) { + } else if (event is StopTimer) { TimerEntry timer = TimerEntry.clone(event.timer, endTime: DateTime.now()); await data.editTimer(timer); List timers = state.timers.map((t) { @@ -55,8 +56,7 @@ class TimersBloc extends Bloc { }).toList(); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); - } - else if (event is EditTimer) { + } else if (event is EditTimer) { await data.editTimer(event.timer); List timers = state.timers.map((t) { if (t.id == event.timer.id) return TimerEntry.clone(event.timer); @@ -64,16 +64,14 @@ class TimersBloc extends Bloc { }).toList(); timers.sort((a, b) => a.startTime.compareTo(b.startTime)); yield TimersState(timers, DateTime.now()); - } - else if (event is DeleteTimer) { + } else if (event is DeleteTimer) { await data.deleteTimer(event.timer); List timers = state.timers - .where((t) => t.id != event.timer.id) - .map((t) => TimerEntry.clone(t)) - .toList(); + .where((t) => t.id != event.timer.id) + .map((t) => TimerEntry.clone(t)) + .toList(); yield TimersState(timers, DateTime.now()); - } - else if (event is StopAllTimers) { + } else if (event is StopAllTimers) { List> timerEdits = state.timers.map((t) async { if (t.endTime == null) { TimerEntry timer = TimerEntry.clone(t, endTime: DateTime.now()); diff --git a/lib/blocs/timers/timers_event.dart b/lib/blocs/timers/timers_event.dart index b8a68e7f..d1f4db5b 100644 --- a/lib/blocs/timers/timers_event.dart +++ b/lib/blocs/timers/timers_event.dart @@ -22,41 +22,58 @@ abstract class TimersEvent extends Equatable { } class LoadTimers extends TimersEvent { - @override List get props => []; + @override + List get props => []; } class CreateTimer extends TimersEvent { final String description; final Project project; final WorkType workType; + CreateTimer({this.description, this.project, this.workType}); - @override List get props => [description, project, workType]; + + @override + List get props => [description, project, workType]; } class UpdateNow extends TimersEvent { const UpdateNow(); - @override List get props => []; + + @override + List get props => []; } class StopTimer extends TimersEvent { final TimerEntry timer; + StopTimer(this.timer); - @override List get props => [timer]; + + @override + List get props => [timer]; } class EditTimer extends TimersEvent { final TimerEntry timer; + EditTimer(this.timer); - @override List get props => [timer]; + + @override + List get props => [timer]; } class DeleteTimer extends TimersEvent { final TimerEntry timer; + DeleteTimer(this.timer); - @override List get props => [timer]; + + @override + List get props => [timer]; } class StopAllTimers extends TimersEvent { const StopAllTimers(); - @override List get props => []; + + @override + List get props => []; } diff --git a/lib/blocs/timers/timers_state.dart b/lib/blocs/timers/timers_state.dart index eed1eb74..91be2429 100644 --- a/lib/blocs/timers/timers_state.dart +++ b/lib/blocs/timers/timers_state.dart @@ -20,16 +20,18 @@ class TimersState extends Equatable { final DateTime now; TimersState(this.timers, this.now) - : assert(timers != null), - assert(now != null); + : assert(timers != null), + assert(now != null); static TimersState initial() { return TimersState([], DateTime.now()); } - TimersState.clone(TimersState state) - : this(state.timers, DateTime.now()); + TimersState.clone(TimersState state) : this(state.timers, DateTime.now()); - @override List get props => [timers, now]; - @override bool get stringify => true; + @override + List get props => [timers, now]; + + @override + bool get stringify => true; } diff --git a/lib/blocs/work_types/work_types_bloc.dart b/lib/blocs/work_types/work_types_bloc.dart index a8b8a164..63a909b4 100644 --- a/lib/blocs/work_types/work_types_bloc.dart +++ b/lib/blocs/work_types/work_types_bloc.dart @@ -13,13 +13,16 @@ // limitations under the License. import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; import 'package:timecop/models/WorkType.dart'; + import './bloc.dart'; class WorkTypesBloc extends Bloc { final DataProvider data; + WorkTypesBloc(this.data); @override diff --git a/lib/blocs/work_types/work_types_event.dart b/lib/blocs/work_types/work_types_event.dart index 3a82077a..1304a044 100644 --- a/lib/blocs/work_types/work_types_event.dart +++ b/lib/blocs/work_types/work_types_event.dart @@ -28,23 +28,29 @@ class LoadWorkTypes extends WorkTypesEvent { class CreateWorkType extends WorkTypesEvent { final String name; final Color colour; + const CreateWorkType(this.name, this.colour) : assert(name != null), assert(colour != null); + @override List get props => [name, colour]; } class EditWorkType extends WorkTypesEvent { final WorkType workType; + const EditWorkType(this.workType) : assert(workType != null); + @override List get props => [workType]; } class DeleteWorkType extends WorkTypesEvent { final WorkType workType; + const DeleteWorkType(this.workType) : assert(workType != null); + @override List get props => [workType]; } diff --git a/lib/blocs/work_types/work_types_state.dart b/lib/blocs/work_types/work_types_state.dart index f38e4d99..4db76af3 100644 --- a/lib/blocs/work_types/work_types_state.dart +++ b/lib/blocs/work_types/work_types_state.dart @@ -14,7 +14,6 @@ import 'package:equatable/equatable.dart'; import 'package:timecop/models/WorkType.dart'; -import 'package:timecop/models/project.dart'; class WorkTypesState extends Equatable { final List workTypes; @@ -29,6 +28,7 @@ class WorkTypesState extends Equatable { @override List get props => [workTypes]; + @override bool get stringify => true; } diff --git a/lib/components/ProjectColour.dart b/lib/components/ProjectColour.dart index 203ff73e..73367063 100644 --- a/lib/components/ProjectColour.dart +++ b/lib/components/ProjectColour.dart @@ -19,6 +19,7 @@ class ProjectColour extends StatelessWidget { static const double SIZE = 22; final Project project; final bool mini; + const ProjectColour({Key key, this.project, this.mini}) : super(key: key); @override @@ -34,13 +35,13 @@ class ProjectColour extends StatelessWidget { color: project?.colour ?? Colors.transparent, //borderRadius: BorderRadius.circular(SIZE * 0.5 * scale), border: project == null - ? Border.all( - color: Theme.of(context).disabledColor, - width: 3.0, - ) - : null, + ? Border.all( + color: Theme.of(context).disabledColor, + width: 3.0, + ) + : null, shape: BoxShape.circle, ), ); } -} \ No newline at end of file +} diff --git a/lib/components/WorkTypeBadge.dart b/lib/components/WorkTypeBadge.dart index 68c6e467..a5e0ca03 100644 --- a/lib/components/WorkTypeBadge.dart +++ b/lib/components/WorkTypeBadge.dart @@ -20,6 +20,7 @@ class WorkTypeBadge extends StatelessWidget { static const double SIZE = 22; final bool mini = false; final WorkType workType; + const WorkTypeBadge({Key key, this.workType}) : super(key: key); @override diff --git a/lib/data_providers/data/data_provider.dart b/lib/data_providers/data/data_provider.dart index 6687f0db..2fdc84c7 100644 --- a/lib/data_providers/data/data_provider.dart +++ b/lib/data_providers/data/data_provider.dart @@ -19,17 +19,31 @@ import 'package:timecop/models/timer_entry.dart'; abstract class DataProvider { Future createProject({@required String name, Color colour}); + Future> listProjects(); + Future editProject(Project project); + Future deleteProject(Project project); Future createWorkType({@required String name, Color colour}); + Future> listWorkTypes(); + Future editWorkType(WorkType workType); + Future deleteWorkType(WorkType workType); - Future createTimer({String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}); + Future createTimer( + {String description, + int projectID, + int workTypeID, + DateTime startTime, + DateTime endTime}); + Future> listTimers(); + Future editTimer(TimerEntry timer); + Future deleteTimer(TimerEntry timer); } diff --git a/lib/data_providers/data/database_provider.dart b/lib/data_providers/data/database_provider.dart index f654194d..5c6f6f9e 100644 --- a/lib/data_providers/data/database_provider.dart +++ b/lib/data_providers/data/database_provider.dart @@ -14,14 +14,15 @@ import 'dart:async'; import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:random_color/random_color.dart'; import 'package:sqflite/sqflite.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; import 'package:timecop/models/WorkType.dart'; -import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.dart'; class DatabaseProvider extends DataProvider { final Database _db; @@ -122,7 +123,10 @@ class DatabaseProvider extends DataProvider { // open the database Database db = await openDatabase(path, - onConfigure: _onConfigure, onCreate: _onCreate, version: DB_VERSION, onUpgrade: _onUpgrade); + onConfigure: _onConfigure, + onCreate: _onCreate, + version: DB_VERSION, + onUpgrade: _onUpgrade); DatabaseProvider repo = DatabaseProvider(db); return repo; @@ -135,14 +139,18 @@ class DatabaseProvider extends DataProvider { colour = _randomColour.randomColor(); } - int id = await _db.rawInsert( "insert into projects(name, colour) values(?, ?)", [name, colour.value]); + int id = await _db.rawInsert( + "insert into projects(name, colour) values(?, ?)", + [name, colour.value]); return Project(id: id, name: name, colour: colour); } /// the r in crud Future> listProjects() async { - List> rawProjects = await _db.rawQuery("select id, name, colour from projects order by name asc"); - return rawProjects.map((Map row) => Project( + List> rawProjects = await _db + .rawQuery("select id, name, colour from projects order by name asc"); + return rawProjects + .map((Map row) => Project( id: row["id"] as int, name: row["name"] as String, colour: Color(row["colour"] as int))) @@ -152,14 +160,17 @@ class DatabaseProvider extends DataProvider { /// the u in crud Future editProject(Project project) async { assert(project != null); - int rows = await _db.rawUpdate("update projects set name=?, colour=? where id=?", [project.name, project.colour.value, project.id]); + int rows = await _db.rawUpdate( + "update projects set name=?, colour=? where id=?", + [project.name, project.colour.value, project.id]); assert(rows == 1); } /// the d in crud Future deleteProject(Project project) async { assert(project != null); - await _db.rawDelete("delete from projects where id=?", [project.id]); + await _db + .rawDelete("delete from projects where id=?", [project.id]); } /// the c in crud @@ -169,13 +180,16 @@ class DatabaseProvider extends DataProvider { colour = _randomColour.randomColor(); } - int id = await _db.rawInsert("insert into work_types(name, colour) values(?, ?)",[name, colour.value]); + int id = await _db.rawInsert( + "insert into work_types(name, colour) values(?, ?)", + [name, colour.value]); return WorkType(id: id, name: name, colour: colour); } /// the r in crud Future> listWorkTypes() async { - List> rawWorkTypes = await _db.rawQuery("select id, name, colour from work_types order by name asc"); + List> rawWorkTypes = await _db + .rawQuery("select id, name, colour from work_types order by name asc"); return rawWorkTypes .map((Map row) => WorkType( id: row["id"] as int, @@ -211,36 +225,53 @@ class DatabaseProvider extends DataProvider { DateTime.now().millisecondsSinceEpoch; assert(st != null); int et = endTime?.millisecondsSinceEpoch; - int id = await _db.rawInsert("insert into timers(project_id, work_type_id, description, start_time, end_time) values(?, ?, ?, ?, ?)", [projectID, workTypeID, description, st, et]); + int id = await _db.rawInsert( + "insert into timers(project_id, work_type_id, description, start_time, end_time) values(?, ?, ?, ?, ?)", + [projectID, workTypeID, description, st, et]); return TimerEntry( id: id, description: description, projectID: projectID, workTypeID: workTypeID, startTime: DateTime.fromMillisecondsSinceEpoch(st), - endTime: endTime - ); + endTime: endTime); } /// the r in crud Future> listTimers() async { - List> rawTimers = await _db.rawQuery("select id, project_id, work_type_id, description, start_time, end_time from timers order by start_time asc"); - return rawTimers.map((Map row) => TimerEntry( + List> rawTimers = await _db.rawQuery( + "select id, project_id, work_type_id, description, start_time, end_time from timers order by start_time asc"); + return rawTimers + .map((Map row) => TimerEntry( id: row["id"] as int, projectID: row["project_id"] as int, workTypeID: row["work_type_id"] as int, description: row["description"] as String, - startTime: DateTime.fromMillisecondsSinceEpoch(row["start_time"] as int), - endTime: row["end_time"] != null ? DateTime.fromMillisecondsSinceEpoch(row["end_time"] as int) : null, - )).toList(); + startTime: + DateTime.fromMillisecondsSinceEpoch(row["start_time"] as int), + endTime: row["end_time"] != null + ? DateTime.fromMillisecondsSinceEpoch(row["end_time"] as int) + : null, + )) + .toList(); } /// the u in crud Future editTimer(TimerEntry timer) async { assert(timer != null); - int st = timer.startTime?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch; + int st = timer.startTime?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch; int et = timer.endTime?.millisecondsSinceEpoch; - await _db.rawUpdate("update timers set project_id=?, work_type_id=?, description=?, start_time=?, end_time=? where id=?", [timer.projectID, timer.workTypeID, timer.description, st, et, timer.id]); + await _db.rawUpdate( + "update timers set project_id=?, work_type_id=?, description=?, start_time=?, end_time=? where id=?", + [ + timer.projectID, + timer.workTypeID, + timer.description, + st, + et, + timer.id + ]); } /// the d in crud diff --git a/lib/data_providers/data/mock_data_provider.dart b/lib/data_providers/data/mock_data_provider.dart index 3465ab8a..16f8ee78 100644 --- a/lib/data_providers/data/mock_data_provider.dart +++ b/lib/data_providers/data/mock_data_provider.dart @@ -132,7 +132,10 @@ class MockDataProvider extends DataProvider { Future> listProjects() async { return [ Project(id: 1, name: "Time Cop", colour: Colors.cyan[600]), - Project(id: 2, name: l10n[localeKey]["administration"], colour: Colors.pink[600]), + Project( + id: 2, + name: l10n[localeKey]["administration"], + colour: Colors.pink[600]), ]; } @@ -157,7 +160,8 @@ class MockDataProvider extends DataProvider { description: l10n[localeKey]["ui-layout"], projectID: 1, workTypeID: 1, - startTime: DateTime.now().subtract(Duration(hours: 2, minutes: 10, seconds: 1)), + startTime: DateTime.now() + .subtract(Duration(hours: 2, minutes: 10, seconds: 1)), endTime: null, ), TimerEntry( @@ -175,9 +179,13 @@ class MockDataProvider extends DataProvider { for (int d = 0; d < 5; d++) { String descriptionKey; double r = rand.nextDouble(); - if (r <= 0.2) { descriptionKey = 'mockups'; } - else if (r <= 0.5) { descriptionKey = 'ui-layout'; } - else { descriptionKey = 'app-development'; } + if (r <= 0.2) { + descriptionKey = 'mockups'; + } else if (r <= 0.5) { + descriptionKey = 'ui-layout'; + } else { + descriptionKey = 'app-development'; + } entries.add(TimerEntry( id: tid++, @@ -185,13 +193,17 @@ class MockDataProvider extends DataProvider { projectID: 1, workTypeID: 1, startTime: DateTime( - 2020, 3, (w * 7) + d + 2, + 2020, + 3, + (w * 7) + d + 2, rand.nextInt(3) + 8, rand.nextInt(60), rand.nextInt(60), ), endTime: DateTime( - 2020, 3, (w * 7) + d + 2, + 2020, + 3, + (w * 7) + d + 2, rand.nextInt(3) + 13, rand.nextInt(60), rand.nextInt(60), @@ -204,13 +216,17 @@ class MockDataProvider extends DataProvider { projectID: 2, workTypeID: 2, startTime: DateTime( - 2020, 3, (w * 7) + d + 2, + 2020, + 3, + (w * 7) + d + 2, 14, rand.nextInt(30), rand.nextInt(60), ), endTime: DateTime( - 2020, 3, (w * 7) + d + 2, + 2020, + 3, + (w * 7) + d + 2, 15, rand.nextInt(30), rand.nextInt(60), @@ -224,7 +240,9 @@ class MockDataProvider extends DataProvider { Future createProject({@required String name, Color colour}) async { return Project(id: -1, name: name, colour: colour); } + Future editProject(Project project) async {} + Future deleteProject(Project project) async {} Future createWorkType({@required String name, Color colour}) async { @@ -232,9 +250,15 @@ class MockDataProvider extends DataProvider { } Future editWorkType(WorkType worktype) async {} + Future deleteWorkType(WorkType worktype) async {} - Future createTimer({String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}) async { + Future createTimer( + {String description, + int projectID, + int workTypeID, + DateTime startTime, + DateTime endTime}) async { DateTime st = startTime ?? DateTime.now(); return TimerEntry( id: -1, @@ -245,6 +269,8 @@ class MockDataProvider extends DataProvider { endTime: endTime, ); } + Future editTimer(TimerEntry timer) async {} + Future deleteTimer(TimerEntry timer) async {} -} \ No newline at end of file +} diff --git a/lib/data_providers/l10n/fluent_l10n_provider.dart b/lib/data_providers/l10n/fluent_l10n_provider.dart index 7da5350b..fbf3c8de 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -21,8 +21,7 @@ class FluentL10NProvider extends L10NProvider { final FluentBundle _bundle; List _errors = []; - FluentL10NProvider._internal(this._bundle) - : assert(_bundle != null); + FluentL10NProvider._internal(this._bundle) : assert(_bundle != null); static Future load(Locale locale) async { final FluentBundle bundle = FluentBundle(locale.toLanguageTag()); @@ -31,8 +30,7 @@ class FluentL10NProvider extends L10NProvider { // special handling of zh-CN & zh-TW for now if (locale.languageCode == "zh" && locale.countryCode == "CN") { src = "l10n/zh-CN.flt"; - } - else if (locale.languageCode == "zh" && locale.countryCode == "TW") { + } else if (locale.languageCode == "zh" && locale.countryCode == "TW") { src = "l10n/zh-TW.flt"; } String messages = await rootBundle.loadString(src); @@ -42,104 +40,222 @@ class FluentL10NProvider extends L10NProvider { } String get about => _bundle.format("about", errors: _errors); - String get appDescription => _bundle.format("appDescription", errors: _errors); + + String get appDescription => + _bundle.format("appDescription", errors: _errors); + String get appLegalese => _bundle.format("appLegalese", errors: _errors); + String get appName => _bundle.format("appName", errors: _errors); - String get areYouSureYouWantToDeleteProject => _bundle.format("areYouSureYouWantToDeleteProject", errors: _errors); - String get areYouSureYouWantToDeleteWorkType => _bundle.format("areYouSureYouWantToDeleteWorkType", errors: _errors); + + String get areYouSureYouWantToDeleteProject => + _bundle.format("areYouSureYouWantToDeleteProject", errors: _errors); + + String get areYouSureYouWantToDeleteWorkType => + _bundle.format("areYouSureYouWantToDeleteWorkType", errors: _errors); + String get cancel => _bundle.format("cancel", errors: _errors); + String get changeLog => _bundle.format("changeLog", errors: _errors); + String get confirmDelete => _bundle.format("confirmDelete", errors: _errors); + String get create => _bundle.format("create", errors: _errors); - String get createNewProject => _bundle.format("createNewProject", errors: _errors); - String get createNewWorkType => _bundle.format("createNewWorkType", errors: _errors); + + String get createNewProject => + _bundle.format("createNewProject", errors: _errors); + + String get createNewWorkType => + _bundle.format("createNewWorkType", errors: _errors); + String get delete => _bundle.format("delete", errors: _errors); - String get deleteTimerConfirm => _bundle.format("deleteTimerConfirm", errors: _errors); + + String get deleteTimerConfirm => + _bundle.format("deleteTimerConfirm", errors: _errors); + String get description => _bundle.format("description", errors: _errors); + String get duration => _bundle.format("duration", errors: _errors); + String get editProject => _bundle.format("editProject", errors: _errors); + String get editWorkType => _bundle.format("editWorkType", errors: _errors); + String get editTimer => _bundle.format("editTimer", errors: _errors); + String get endTime => _bundle.format("endTime", errors: _errors); + String get export => _bundle.format("export", errors: _errors); + String get filter => _bundle.format("filter", errors: _errors); + String get from => _bundle.format("from", errors: _errors); + String get logoSemantics => _bundle.format("logoSemantics", errors: _errors); + String get noProject => _bundle.format("noProject", errors: _errors); + String get noWorkType => _bundle.format("noWorkType", errors: _errors); - String get pleaseEnterAName => _bundle.format("pleaseEnterAName", errors: _errors); + + String get pleaseEnterAName => + _bundle.format("pleaseEnterAName", errors: _errors); + String get project => _bundle.format("project", errors: _errors); + String get workType => _bundle.format("workType", errors: _errors); + String get projectName => _bundle.format("projectName", errors: _errors); + String get workTypeName => _bundle.format("workTypeName", errors: _errors); + String get projects => _bundle.format("projects", errors: _errors); + String get workTypes => _bundle.format("workTypes", errors: _errors); + String get readme => _bundle.format("readme", errors: _errors); + String get runningTimers => _bundle.format("runningTimers", errors: _errors); + String get save => _bundle.format("save", errors: _errors); + String get sourceCode => _bundle.format("sourceCode", errors: _errors); + String get startTime => _bundle.format("startTime", errors: _errors); + String get timeH => _bundle.format("timeH", errors: _errors); + String get to => _bundle.format("to", errors: _errors); - String get whatAreYouDoing => _bundle.format("whatAreYouDoing", errors: _errors); - String get whatWereYouDoing => _bundle.format("whatWereYouDoing", errors: _errors); + + String get whatAreYouDoing => + _bundle.format("whatAreYouDoing", errors: _errors); + + String get whatWereYouDoing => + _bundle.format("whatWereYouDoing", errors: _errors); + String get noDescription => _bundle.format("noDescription", errors: _errors); - String timeCopDatabase(String date) => _bundle.format("timeCopDatabase", args: {"date": date}, errors: _errors); - String timeCopEntries(String date) => _bundle.format("timeCopEntries", args: {"date": date}, errors: _errors); + + String timeCopDatabase(String date) => _bundle.format("timeCopDatabase", + args: {"date": date}, errors: _errors); + + String timeCopEntries(String date) => _bundle.format("timeCopEntries", + args: {"date": date}, errors: _errors); + String get options => _bundle.format("options", errors: _errors); + String get groupTimers => _bundle.format("groupTimers", errors: _errors); + String get columns => _bundle.format("columns", errors: _errors); + String get date => _bundle.format("date", errors: _errors); - String get combinedProjectDescription => _bundle.format("combinedProjectDescription", errors: _errors); + + String get combinedProjectDescription => + _bundle.format("combinedProjectDescription", errors: _errors); + String get reports => _bundle.format("reports", errors: _errors) ?? "reports"; - String nHours(String hours) => _bundle.format("nHours", args: {"hours": hours}, errors: _errors); - String get averageDailyHours => _bundle.format("averageDailyHours", errors: _errors) ?? "averageDailyHours"; - String get totalProjectShare => _bundle.format("totalProjectShare", errors: _errors) ?? "totalProjectShare"; - String get weeklyHours => _bundle.format("weeklyHours", errors: _errors) ?? "weeklyHours"; - String get contributors => _bundle.format("contributors", errors: _errors) ?? "contributors"; - String get settings => _bundle.format("settings", errors: _errors) ?? "settings"; + + String nHours(String hours) => _bundle.format("nHours", + args: {"hours": hours}, errors: _errors); + + String get averageDailyHours => + _bundle.format("averageDailyHours", errors: _errors) ?? + "averageDailyHours"; + + String get totalProjectShare => + _bundle.format("totalProjectShare", errors: _errors) ?? + "totalProjectShare"; + + String get weeklyHours => + _bundle.format("weeklyHours", errors: _errors) ?? "weeklyHours"; + + String get contributors => + _bundle.format("contributors", errors: _errors) ?? "contributors"; + + String get settings => + _bundle.format("settings", errors: _errors) ?? "settings"; + String get theme => _bundle.format("theme", errors: _errors) ?? "theme"; + String get auto => _bundle.format("auto", errors: _errors) ?? "auto"; + String get light => _bundle.format("light", errors: _errors) ?? "light"; + String get dark => _bundle.format("dark", errors: _errors) ?? "dark"; + String get black => _bundle.format("black", errors: _errors) ?? "black"; + String langName(Locale locale) { if (locale == null) { return auto; } switch (locale.languageCode) { - case "ar": return "العربية"; - case "de": return "Deutsch"; - case "en": return "English"; - case "es": return "Español"; - case "fr": return "Français"; - case "hi": return "हिन्दी"; - case "id": return "Indonesia"; - case "it": return "Italiano"; - case "ja": return "日本語"; - case "ko": return "한국어"; - case "pt": return "Português"; - case "ru": return "русский"; - case "zh": { + case "ar": + return "العربية"; + case "de": + return "Deutsch"; + case "en": + return "English"; + case "es": + return "Español"; + case "fr": + return "Français"; + case "hi": + return "हिन्दी"; + case "id": + return "Indonesia"; + case "it": + return "Italiano"; + case "ja": + return "日本語"; + case "ko": + return "한국어"; + case "pt": + return "Português"; + case "ru": + return "русский"; + case "zh": + { switch (locale.countryCode) { - case "CN": return "中文(简体)"; - case "TW": return "中文(繁體)"; - default: return "中文"; + case "CN": + return "中文(简体)"; + case "TW": + return "中文(繁體)"; + default: + return "中文"; } } } return ""; } - String get language => _bundle.format("language", errors: _errors) ?? "language"; + + String get language => + _bundle.format("language", errors: _errors) ?? "language"; + String get automaticLanguage { String langName = _bundle.format("langName", errors: _errors) ?? "langName"; - return _bundle.format("automaticLanguage", args: {"langName": langName}, errors: _errors); + return _bundle.format("automaticLanguage", + args: {"langName": langName}, errors: _errors); } - String get collapseDays => _bundle.format("collapseDays", errors: _errors) ?? "collapseDays"; - String get autocompleteDescription => _bundle.format("autocompleteDescription", errors: _errors) ?? "autocompleteDescription"; - String get defaultFilterStartDateToMonday => _bundle.format("defaultFilterStartDateToMonday", errors: _errors) ?? "defaultFilterStartDateToMonday"; - String get allowMultipleActiveTimers => _bundle.format("allowMultipleActiveTimers", errors: _errors) ?? "allowMultipleActiveTimers"; - String get displayProjectNameInTimer => _bundle.format("displayProjectNameInTimer", errors: _errors) ?? "displayProjectNameInTimer"; + + String get collapseDays => + _bundle.format("collapseDays", errors: _errors) ?? "collapseDays"; + + String get autocompleteDescription => + _bundle.format("autocompleteDescription", errors: _errors) ?? + "autocompleteDescription"; + + String get defaultFilterStartDateToMonday => + _bundle.format("defaultFilterStartDateToMonday", errors: _errors) ?? + "defaultFilterStartDateToMonday"; + + String get allowMultipleActiveTimers => + _bundle.format("allowMultipleActiveTimers", errors: _errors) ?? + "allowMultipleActiveTimers"; + + String get displayProjectNameInTimer => + _bundle.format("displayProjectNameInTimer", errors: _errors) ?? + "displayProjectNameInTimer"; + String get hours => _bundle.format("hours", errors: _errors) ?? "hours"; + String get total => _bundle.format("total", errors: _errors) ?? "total"; } diff --git a/lib/data_providers/l10n/l10n_provider.dart b/lib/data_providers/l10n/l10n_provider.dart index f488699e..8161fe2c 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -16,75 +16,146 @@ import 'package:flutter/widgets.dart'; abstract class L10NProvider { String get about; + String get appDescription; + String get appLegalese; + String get appName; + String get areYouSureYouWantToDeleteProject; + String get areYouSureYouWantToDeleteWorkType; + String get cancel; + String get changeLog; + String get confirmDelete; + String get create; + String get createNewProject; + String get createNewWorkType; + String get delete; + String get deleteTimerConfirm; + String get description; + String get duration; + String get editProject; + String get editWorkType; + String get editTimer; + String get endTime; + String get export; + String get filter; + String get from; + String get logoSemantics; + String get noProject; + String get noWorkType; + String get pleaseEnterAName; + String get project; + String get workType; + String get projectName; + String get workTypeName; + String get projects; + String get workTypes; + String get readme; + String get runningTimers; + String get save; + String get sourceCode; + String get startTime; + String get timeH; + String get to; + String get whatAreYouDoing; + String get whatWereYouDoing; + String get noDescription; + String timeCopDatabase(String date); + String timeCopEntries(String date); + String get options; + String get groupTimers; + String get columns; + String get date; + String get combinedProjectDescription; + String get reports; + String nHours(String hours); + String get averageDailyHours; + String get totalProjectShare; + String get weeklyHours; + String get contributors; + String get settings; + String get theme; + String get auto; + String get light; + String get dark; + String get black; + String langName(Locale locale); + String get language; + String get automaticLanguage; + String get collapseDays; + String get autocompleteDescription; + String get defaultFilterStartDateToMonday; + String get allowMultipleActiveTimers; + String get displayProjectNameInTimer; + String get hours; + String get total; -} \ No newline at end of file +} diff --git a/lib/data_providers/settings/mock_settings_provider.dart b/lib/data_providers/settings/mock_settings_provider.dart index 81750bac..b9edd483 100644 --- a/lib/data_providers/settings/mock_settings_provider.dart +++ b/lib/data_providers/settings/mock_settings_provider.dart @@ -27,7 +27,8 @@ class MockSettingsProvider extends SettingsProvider { } @override - bool getBool(String key) => values.containsKey(key) ? values[key] as bool : true; + bool getBool(String key) => + values.containsKey(key) ? values[key] as bool : true; @override void setBool(String key, bool value) => values[key] = value; @@ -49,4 +50,4 @@ class MockSettingsProvider extends SettingsProvider { @override void setLocale(Locale l) => locale = l; -} \ No newline at end of file +} diff --git a/lib/data_providers/settings/settings_provider.dart b/lib/data_providers/settings/settings_provider.dart index ef1291e4..3a2e1ef9 100644 --- a/lib/data_providers/settings/settings_provider.dart +++ b/lib/data_providers/settings/settings_provider.dart @@ -17,13 +17,18 @@ import 'package:timecop/models/theme_type.dart'; abstract class SettingsProvider { bool getBool(String key); + void setBool(String key, bool value); + int getInt(String key); + void setInt(String key, int value); ThemeType getTheme(); + void setTheme(ThemeType theme); Locale getLocale(); + void setLocale(Locale locale); } diff --git a/lib/data_providers/settings/shared_prefs_settings_provider.dart b/lib/data_providers/settings/shared_prefs_settings_provider.dart index 076075c8..205aeb01 100644 --- a/lib/data_providers/settings/shared_prefs_settings_provider.dart +++ b/lib/data_providers/settings/shared_prefs_settings_provider.dart @@ -15,12 +15,13 @@ import 'package:flutter/rendering.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:timecop/models/theme_type.dart'; + import 'settings_provider.dart'; class SharedPrefsSettingsProvider extends SettingsProvider { final SharedPreferences _prefs; - SharedPrefsSettingsProvider(this._prefs) - : assert(_prefs != null); + + SharedPrefsSettingsProvider(this._prefs) : assert(_prefs != null); @override bool getBool(String key) => _prefs.getBool(key); @@ -52,14 +53,12 @@ class SharedPrefsSettingsProvider extends SettingsProvider { @override Locale getLocale() { String languageCode = _prefs.getString("languageCode"); - if(languageCode == null) { + if (languageCode == null) { return null; } String countryCode = _prefs.getString("countryCode"); return Locale.fromSubtags( - languageCode: languageCode, - countryCode: countryCode - ); + languageCode: languageCode, countryCode: countryCode); } @override diff --git a/lib/fontlicenses.dart b/lib/fontlicenses.dart index 80a776d8..18faee47 100644 --- a/lib/fontlicenses.dart +++ b/lib/fontlicenses.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -26,4 +26,4 @@ Stream getFontLicenses() { _license("PublicSans", "fonts/LICENSE-PublicSans.md"), _license("FiraMono", "fonts/LICENSE-FiraMono.txt"), ]); -} \ No newline at end of file +} diff --git a/lib/l10n.dart b/lib/l10n.dart index 80336acc..217f3f59 100644 --- a/lib/l10n.dart +++ b/lib/l10n.dart @@ -23,9 +23,9 @@ class L10N { final bool rtl; const L10N._internal(this.locale, this.tr, this.rtl) - : assert(locale != null), - assert(tr != null), - assert(rtl != null); + : assert(locale != null), + assert(tr != null), + assert(rtl != null); static Future load(Locale locale) async { Intl.defaultLocale = locale.languageCode; @@ -44,11 +44,25 @@ class _L10NDelegate extends LocalizationsDelegate { const _L10NDelegate(); @override - bool isSupported(Locale locale) => ['de', 'en', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt', 'ru', 'zh', 'ar', 'it'].contains(locale.languageCode); + bool isSupported(Locale locale) => [ + 'de', + 'en', + 'es', + 'fr', + 'hi', + 'id', + 'ja', + 'ko', + 'pt', + 'ru', + 'zh', + 'ar', + 'it' + ].contains(locale.languageCode); @override Future load(Locale locale) => L10N.load(locale); @override bool shouldReload(_L10NDelegate old) => false; -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index c3e1db1c..ae1eb00f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,25 +15,25 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:timecop/blocs/locale/locale_bloc.dart'; import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/settings/settings_event.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/theme/theme_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/data_providers/data/data_provider.dart'; +import 'package:timecop/data_providers/data/database_provider.dart'; import 'package:timecop/data_providers/settings/settings_provider.dart'; +import 'package:timecop/data_providers/settings/shared_prefs_settings_provider.dart'; import 'package:timecop/fontlicenses.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/screens/dashboard/DashboardScreen.dart'; import 'package:timecop/themes.dart'; -import 'package:timecop/data_providers/data/database_provider.dart'; -import 'package:timecop/data_providers/settings/shared_prefs_settings_provider.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final SettingsProvider settings = await SharedPrefsSettingsProvider.load(); @@ -83,6 +83,7 @@ Future runMain(SettingsProvider settings, DataProvider data) async { class TimeCopApp extends StatefulWidget { final SettingsProvider settings; + const TimeCopApp({Key key, @required this.settings}) : assert(settings != null), super(key: key); @@ -97,7 +98,8 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { @override void initState() { - _updateTimersTimer = Timer.periodic(Duration(seconds: 1), (_) => BlocProvider.of(context).add(UpdateNow())); + _updateTimersTimer = Timer.periodic(Duration(seconds: 1), + (_) => BlocProvider.of(context).add(UpdateNow())); super.initState(); WidgetsBinding.instance.addObserver(this); brightness = WidgetsBinding.instance.window.platformBrightness; @@ -121,7 +123,8 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { @override void didChangePlatformBrightness() { print(WidgetsBinding.instance.window.platformBrightness.toString()); - setState(() => brightness = WidgetsBinding.instance.window.platformBrightness); + setState( + () => brightness = WidgetsBinding.instance.window.platformBrightness); } @override @@ -137,7 +140,10 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { MaterialApp( title: 'Time Cop', home: DashboardScreen(), - theme: themeState.themeData ?? (brightness == Brightness.dark ? darkTheme : lightTheme), + theme: themeState.themeData ?? + (brightness == Brightness.dark + ? darkTheme + : lightTheme), localizationsDelegates: [ L10N.delegate, GlobalMaterialLocalizations.delegate, @@ -162,8 +168,6 @@ class _TimeCopAppState extends State with WidgetsBindingObserver { const Locale('it'), ], ), - ) - ) - ); + ))); } } diff --git a/lib/models/WorkType.dart b/lib/models/WorkType.dart index aec37646..cf7d4894 100644 --- a/lib/models/WorkType.dart +++ b/lib/models/WorkType.dart @@ -27,6 +27,7 @@ class WorkType extends Equatable { @override List get props => [id, name, colour]; + @override bool get stringify => true; diff --git a/lib/models/clone_time.dart b/lib/models/clone_time.dart index d0dd2786..2b335cb6 100644 --- a/lib/models/clone_time.dart +++ b/lib/models/clone_time.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -14,16 +14,8 @@ extension CloneTime on DateTime { DateTime clone() { - if(this == null) return null; - return DateTime( - this.year, - this.month, - this.day, - this.hour, - this.minute, - this.second, - this.millisecond, - this.microsecond - ); + if (this == null) return null; + return DateTime(this.year, this.month, this.day, this.hour, this.minute, + this.second, this.millisecond, this.microsecond); } -} \ No newline at end of file +} diff --git a/lib/models/project.dart b/lib/models/project.dart index dcabb311..181e3837 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -21,18 +21,20 @@ class Project extends Equatable { final Color colour; Project({@required this.id, @required this.name, @required this.colour}) - : assert(id != null), - assert(name != null), - assert(colour != null); + : assert(id != null), + assert(name != null), + assert(colour != null); - @override List get props => [id, name, colour]; - @override bool get stringify => true; + @override + List get props => [id, name, colour]; - Project.clone(Project project, - {String name, Color colour}) - : this( - id: project.id, - name: name ?? project.name, - colour: colour ?? project.colour, - ); -} \ No newline at end of file + @override + bool get stringify => true; + + Project.clone(Project project, {String name, Color colour}) + : this( + id: project.id, + name: name ?? project.name, + colour: colour ?? project.colour, + ); +} diff --git a/lib/models/project_description_pair.dart b/lib/models/project_description_pair.dart index 96feccdf..d2209758 100644 --- a/lib/models/project_description_pair.dart +++ b/lib/models/project_description_pair.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,5 +20,6 @@ class ProjectDescriptionPair extends Equatable { ProjectDescriptionPair(this.project, this.description); - @override List get props => [project, description]; -} \ No newline at end of file + @override + List get props => [project, description]; +} diff --git a/lib/models/start_of_week.dart b/lib/models/start_of_week.dart index 547a7246..79a32653 100644 --- a/lib/models/start_of_week.dart +++ b/lib/models/start_of_week.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,4 +19,4 @@ extension StartOfWeek on DateTime { DateTime dt = this.add(Duration(days: -diff)); return DateTime(dt.year, dt.month, dt.day); } -} \ No newline at end of file +} diff --git a/lib/models/theme_type.dart b/lib/models/theme_type.dart index d65359d6..ff95f059 100644 --- a/lib/models/theme_type.dart +++ b/lib/models/theme_type.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,34 +18,47 @@ import 'package:timecop/l10n.dart'; enum ThemeType { auto, light, dark, black } ThemeType themeFromString(String type) { - if(type == null) return ThemeType.auto; - switch(type) { - case "auto": return ThemeType.auto; - case "light": return ThemeType.light; - case "dark": return ThemeType.dark; - case "black": return ThemeType.black; - default: return ThemeType.auto; + if (type == null) return ThemeType.auto; + switch (type) { + case "auto": + return ThemeType.auto; + case "light": + return ThemeType.light; + case "dark": + return ThemeType.dark; + case "black": + return ThemeType.black; + default: + return ThemeType.auto; } } extension ThemeTypeStr on ThemeType { String get stringify { - switch(this) { - case ThemeType.auto: return "auto"; - case ThemeType.light: return "light"; - case ThemeType.dark: return "dark"; - case ThemeType.black: return "black"; + switch (this) { + case ThemeType.auto: + return "auto"; + case ThemeType.light: + return "light"; + case ThemeType.dark: + return "dark"; + case ThemeType.black: + return "black"; } return null; } String display(BuildContext context) { - switch(this) { - case ThemeType.auto: return L10N.of(context).tr.auto; - case ThemeType.light: return L10N.of(context).tr.light; - case ThemeType.dark: return L10N.of(context).tr.dark; - case ThemeType.black: return L10N.of(context).tr.black; + switch (this) { + case ThemeType.auto: + return L10N.of(context).tr.auto; + case ThemeType.light: + return L10N.of(context).tr.light; + case ThemeType.dark: + return L10N.of(context).tr.dark; + case ThemeType.black: + return L10N.of(context).tr.black; } return null; } -} \ No newline at end of file +} diff --git a/lib/models/timer_entry.dart b/lib/models/timer_entry.dart index f02c49e3..8ffce28d 100644 --- a/lib/models/timer_entry.dart +++ b/lib/models/timer_entry.dart @@ -23,15 +23,29 @@ class TimerEntry extends Equatable { final DateTime startTime; final DateTime endTime; - TimerEntry({@required this.id, @required this.description, @required this.projectID, @required this.workTypeID, @required this.startTime, @required this.endTime}) + TimerEntry( + {@required this.id, + @required this.description, + @required this.projectID, + @required this.workTypeID, + @required this.startTime, + @required this.endTime}) : assert(id != null), assert(startTime != null); - @override List get props => [id, description, projectID, workTypeID, startTime, endTime]; - @override bool get stringify => true; + @override + List get props => + [id, description, projectID, workTypeID, startTime, endTime]; + + @override + bool get stringify => true; TimerEntry.clone(TimerEntry timer, - {String description, int projectID, int workTypeID, DateTime startTime, DateTime endTime}) + {String description, + int projectID, + int workTypeID, + DateTime startTime, + DateTime endTime}) : this( id: timer.id, description: description ?? timer.description, @@ -43,15 +57,15 @@ class TimerEntry extends Equatable { static String formatDuration(Duration d) { if (d.inHours > 0) { - return - d.inHours.toString() + ":" - + (d.inMinutes - (d.inHours * 60)).toString().padLeft(2, "0") + ":" - + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); - } - else { - return - d.inMinutes.toString().padLeft(2, "0") + ":" - + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); + return d.inHours.toString() + + ":" + + (d.inMinutes - (d.inHours * 60)).toString().padLeft(2, "0") + + ":" + + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); + } else { + return d.inMinutes.toString().padLeft(2, "0") + + ":" + + (d.inSeconds - (d.inMinutes * 60)).toString().padLeft(2, "0"); } } @@ -59,4 +73,4 @@ class TimerEntry extends Equatable { Duration d = (endTime ?? DateTime.now()).difference(startTime); return formatDuration(d); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/DashboardScreen.dart b/lib/screens/dashboard/DashboardScreen.dart index 4df8a9be..433d489f 100644 --- a/lib/screens/dashboard/DashboardScreen.dart +++ b/lib/screens/dashboard/DashboardScreen.dart @@ -20,11 +20,11 @@ import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:timecop/screens/dashboard/components/DescriptionField.dart'; import 'package:timecop/screens/dashboard/components/ProjectSelectField.dart'; -import 'package:timecop/screens/dashboard/components/WorkTypeSelectField.dart'; import 'package:timecop/screens/dashboard/components/RunningTimers.dart'; import 'package:timecop/screens/dashboard/components/StartTimerButton.dart'; import 'package:timecop/screens/dashboard/components/StoppedTimers.dart'; import 'package:timecop/screens/dashboard/components/TopBar.dart'; +import 'package:timecop/screens/dashboard/components/WorkTypeSelectField.dart'; class DashboardScreen extends StatelessWidget { const DashboardScreen({Key key}) : super(key: key); @@ -35,8 +35,7 @@ class DashboardScreen extends StatelessWidget { final WorkTypesBloc workTypesBloc = BlocProvider.of(context); final SettingsBloc settingsBloc = BlocProvider.of(context); - return - BlocProvider( + return BlocProvider( create: (_) => DashboardBloc(projectsBloc, workTypesBloc, settingsBloc), child: Scaffold( appBar: TopBar(), @@ -84,7 +83,6 @@ class DashboardScreen extends StatelessWidget { ], ), floatingActionButton: StartTimerButton(), - ) - ); + )); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/bloc/dashboard_bloc.dart b/lib/screens/dashboard/bloc/dashboard_bloc.dart index 300ab27d..526017d2 100644 --- a/lib/screens/dashboard/bloc/dashboard_bloc.dart +++ b/lib/screens/dashboard/bloc/dashboard_bloc.dart @@ -4,10 +4,10 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/projects/bloc.dart'; -import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; -import 'package:timecop/models/project.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; part 'dashboard_event.dart'; part 'dashboard_state.dart'; @@ -21,10 +21,13 @@ class DashboardBloc extends Bloc { @override DashboardState get initialState { - Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + Project newProject = + projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); + WorkType newWorkType = + workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); - return DashboardState("", newProject, newWorkType, false, settingsBloc.getFilterStartDate(), null, [], null); + return DashboardState("", newProject, newWorkType, false, + settingsBloc.getFilterStartDate(), null, [], null); } @override @@ -32,41 +35,103 @@ class DashboardBloc extends Bloc { DashboardEvent event, ) async* { if (event is DescriptionChangedEvent) { - yield DashboardState(event.description, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is ProjectChangedEvent) { - yield DashboardState(state.newDescription, event.project, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is WorkTypeChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, event.workType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is TimerWasStartedEvent) { - Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + yield DashboardState( + event.description, + state.newProject, + state.newWorkType, + false, + state.filterStart, + state.filterEnd, + state.hiddenProjects, + state.searchString); + } else if (event is ProjectChangedEvent) { + yield DashboardState( + state.newDescription, + event.project, + state.newWorkType, + false, + state.filterStart, + state.filterEnd, + state.hiddenProjects, + state.searchString); + } else if (event is WorkTypeChangedEvent) { + yield DashboardState( + state.newDescription, + state.newProject, + event.workType, + false, + state.filterStart, + state.filterEnd, + state.hiddenProjects, + state.searchString); + } else if (event is TimerWasStartedEvent) { + Project newProject = + projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); + WorkType newWorkType = + workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); yield DashboardState("", newProject, newWorkType, true, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is ResetEvent) { - Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - WorkType newWorkType = workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); - yield DashboardState("", newProject, newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is FilterStartChangedEvent) { + } else if (event is ResetEvent) { + Project newProject = + projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); + WorkType newWorkType = + workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + yield DashboardState( + "", + newProject, + newWorkType, + false, + state.filterStart, + state.filterEnd, + state.hiddenProjects, + state.searchString); + } else if (event is FilterStartChangedEvent) { DateTime end = state.filterEnd; - if (state.filterEnd != null && event.filterStart.isAfter(state.filterEnd)) { - end = event.filterStart.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); + if (state.filterEnd != null && + event.filterStart.isAfter(state.filterEnd)) { + end = event.filterStart.add( + Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } - yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, event.filterStart, end, state.hiddenProjects, state.searchString); - } - else if (event is FilterEndChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, event.filterEnd, state.hiddenProjects, state.searchString); - } - else if (event is FilterProjectsChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, event.projects, state.searchString); - } - else if (event is SearchChangedEvent) { - yield DashboardState(state.newDescription, state.newProject, state.newWorkType, false, state.filterStart, state.filterEnd, state.hiddenProjects, event.search); + yield DashboardState( + state.newDescription, + state.newProject, + state.newWorkType, + false, + event.filterStart, + end, + state.hiddenProjects, + state.searchString); + } else if (event is FilterEndChangedEvent) { + yield DashboardState( + state.newDescription, + state.newProject, + state.newWorkType, + false, + state.filterStart, + event.filterEnd, + state.hiddenProjects, + state.searchString); + } else if (event is FilterProjectsChangedEvent) { + yield DashboardState( + state.newDescription, + state.newProject, + state.newWorkType, + false, + state.filterStart, + state.filterEnd, + event.projects, + state.searchString); + } else if (event is SearchChangedEvent) { + yield DashboardState( + state.newDescription, + state.newProject, + state.newWorkType, + false, + state.filterStart, + state.filterEnd, + state.hiddenProjects, + event.search); } } } diff --git a/lib/screens/dashboard/bloc/dashboard_event.dart b/lib/screens/dashboard/bloc/dashboard_event.dart index b3716d15..07003f5e 100644 --- a/lib/screens/dashboard/bloc/dashboard_event.dart +++ b/lib/screens/dashboard/bloc/dashboard_event.dart @@ -6,52 +6,77 @@ abstract class DashboardEvent extends Equatable { class DescriptionChangedEvent extends DashboardEvent { final String description; + const DescriptionChangedEvent(this.description); - @override List get props => [description]; + + @override + List get props => [description]; } class ProjectChangedEvent extends DashboardEvent { final Project project; + const ProjectChangedEvent(this.project); - @override List get props => [project]; + + @override + List get props => [project]; } class WorkTypeChangedEvent extends DashboardEvent { final WorkType workType; + const WorkTypeChangedEvent(this.workType); - @override List get props => [workType]; + + @override + List get props => [workType]; } class ResetEvent extends DashboardEvent { const ResetEvent(); - @override List get props => []; + + @override + List get props => []; } class TimerWasStartedEvent extends DashboardEvent { const TimerWasStartedEvent(); - @override List get props => []; + + @override + List get props => []; } class FilterStartChangedEvent extends DashboardEvent { final DateTime filterStart; + const FilterStartChangedEvent(this.filterStart); - @override List get props => [filterStart]; + + @override + List get props => [filterStart]; } class FilterEndChangedEvent extends DashboardEvent { final DateTime filterEnd; + const FilterEndChangedEvent(this.filterEnd); - @override List get props => [filterEnd]; + + @override + List get props => [filterEnd]; } class FilterProjectsChangedEvent extends DashboardEvent { final List projects; + const FilterProjectsChangedEvent(this.projects); - @override List get props => [projects]; + + @override + List get props => [projects]; } class SearchChangedEvent extends DashboardEvent { final String search; + const SearchChangedEvent(this.search); - @override List get props => [search]; + + @override + List get props => [search]; } diff --git a/lib/screens/dashboard/bloc/dashboard_state.dart b/lib/screens/dashboard/bloc/dashboard_state.dart index 6850ae76..14aa020e 100644 --- a/lib/screens/dashboard/bloc/dashboard_state.dart +++ b/lib/screens/dashboard/bloc/dashboard_state.dart @@ -30,15 +30,13 @@ class DashboardState extends Equatable { DashboardState state, DateTime filterStart, DateTime filterEnd, - String searchString, - { - String newDescription, - Project newProject, - WorkType newWorkType, - bool timerWasStarted, - List hiddenProjects, - }) - : this( + String searchString, { + String newDescription, + Project newProject, + WorkType newWorkType, + bool timerWasStarted, + List hiddenProjects, + }) : this( newDescription ?? state.newDescription, newProject ?? state.newProject, newWorkType ?? state.newWorkType, @@ -46,9 +44,17 @@ class DashboardState extends Equatable { filterStart, filterEnd, hiddenProjects ?? state.hiddenProjects, - searchString - ); + searchString); @override - List get props => [newDescription, newProject, newWorkType, timerWasStarted, filterStart, filterEnd, hiddenProjects, searchString]; + List get props => [ + newDescription, + newProject, + newWorkType, + timerWasStarted, + filterStart, + filterEnd, + hiddenProjects, + searchString + ]; } diff --git a/lib/screens/dashboard/components/CollapsibleDayGrouping.dart b/lib/screens/dashboard/components/CollapsibleDayGrouping.dart index a6dcd2d6..7b3316ba 100644 --- a/lib/screens/dashboard/components/CollapsibleDayGrouping.dart +++ b/lib/screens/dashboard/components/CollapsibleDayGrouping.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,19 +20,27 @@ class CollapsibleDayGrouping extends StatefulWidget { final DateTime date; final Iterable children; final Duration totalTime; - CollapsibleDayGrouping({Key key, @required this.date, @required this.children, @required this.totalTime}) - : assert(date != null), - assert(children != null), - assert(totalTime != null), - super(key: key); + + CollapsibleDayGrouping( + {Key key, + @required this.date, + @required this.children, + @required this.totalTime}) + : assert(date != null), + assert(children != null), + assert(totalTime != null), + super(key: key); @override _CollapsibleDayGroupingState createState() => _CollapsibleDayGroupingState(); } -class _CollapsibleDayGroupingState extends State with SingleTickerProviderStateMixin { - static final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); - static final Animatable _halfTween = Tween(begin: 0.0, end: -0.5); +class _CollapsibleDayGroupingState extends State + with SingleTickerProviderStateMixin { + static final Animatable _easeInTween = + CurveTween(curve: Curves.easeIn); + static final Animatable _halfTween = + Tween(begin: 0.0, end: -0.5); static DateFormat _dateFormat = DateFormat.yMMMMEEEEd(); bool _expanded; @@ -40,19 +48,20 @@ class _CollapsibleDayGroupingState extends State with Si Animation _iconTurns; @override - void initState() { + void initState() { super.initState(); _expanded = DateTime.now().difference(widget.date).inDays.abs() <= 1; _controller = AnimationController( duration: Duration(milliseconds: 200), vsync: this, - value: DateTime.now().difference(widget.date).inDays.abs() <= 1 ? 1.0 : 0.0, + value: + DateTime.now().difference(widget.date).inDays.abs() <= 1 ? 1.0 : 0.0, ); _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); } @override - void dispose() { + void dispose() { _controller.dispose(); super.dispose(); } @@ -60,26 +69,24 @@ class _CollapsibleDayGroupingState extends State with Si @override Widget build(BuildContext context) { return ExpansionTile( - initiallyExpanded: DateTime.now().difference(widget.date).inDays.abs() <= 1, + initiallyExpanded: + DateTime.now().difference(widget.date).inDays.abs() <= 1, onExpansionChanged: (expanded) { setState(() { _expanded = expanded; - if(_expanded) { + if (_expanded) { _controller.forward(); - } - else { + } else { _controller.reverse(); } }); }, - title: Text( - _dateFormat.format(widget.date), - style: TextStyle( - //color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700, - fontSize: Theme.of(context).textTheme.body1.fontSize, - ) - ), + title: Text(_dateFormat.format(widget.date), + style: TextStyle( + //color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700, + fontSize: Theme.of(context).textTheme.body1.fontSize, + )), trailing: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -89,16 +96,14 @@ class _CollapsibleDayGroupingState extends State with Si child: const Icon(Icons.expand_more), ), Container(width: 8), - Text( - TimerEntry.formatDuration(widget.totalTime), - style: TextStyle( - color: _expanded ? Theme.of(context).accentColor : null, - fontFamily: "FiraMono", - ) - ), + Text(TimerEntry.formatDuration(widget.totalTime), + style: TextStyle( + color: _expanded ? Theme.of(context).accentColor : null, + fontFamily: "FiraMono", + )), ], ), children: widget.children.toList(), ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/DescriptionField.dart b/lib/screens/dashboard/components/DescriptionField.dart index 5b1ddf2e..59d6a9d1 100644 --- a/lib/screens/dashboard/components/DescriptionField.dart +++ b/lib/screens/dashboard/components/DescriptionField.dart @@ -14,11 +14,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; class DescriptionField extends StatefulWidget { DescriptionField({Key key}) : super(key: key); @@ -54,67 +54,67 @@ class _DescriptionFieldState extends State { final SettingsBloc settings = BlocProvider.of(context); return BlocBuilder( - builder: (BuildContext context, DashboardState state) { - if(state.timerWasStarted) { - _controller.clear(); - _focus.unfocus(); - bloc.add(ResetEvent()); - } + builder: (BuildContext context, DashboardState state) { + if (state.timerWasStarted) { + _controller.clear(); + _focus.unfocus(); + bloc.add(ResetEvent()); + } - if(settings.state.autocompleteDescription) { - return TypeAheadField( - direction: AxisDirection.up, - textFieldConfiguration: TextFieldConfiguration( + if (settings.state.autocompleteDescription) { + return TypeAheadField( + direction: AxisDirection.up, + textFieldConfiguration: TextFieldConfiguration( focusNode: _focus, controller: _controller, autocorrect: true, decoration: InputDecoration( - hintText: L10N.of(context).tr.whatAreYouDoing - ), - onChanged: (dynamic description) => bloc.add(DescriptionChangedEvent(description as String)), + hintText: L10N.of(context).tr.whatAreYouDoing), + onChanged: (dynamic description) => + bloc.add(DescriptionChangedEvent(description as String)), onSubmitted: (dynamic description) { _focus.unfocus(); bloc.add(DescriptionChangedEvent(description as String)); - } - ), - itemBuilder: (BuildContext context, String desc) => - ListTile( - title: Text(desc) - ), - onSuggestionSelected: (String description) { - _controller.text = description; - bloc.add(DescriptionChangedEvent(description)); - }, - suggestionsCallback: (pattern) async { - if(pattern.length < 2) return []; + }), + itemBuilder: (BuildContext context, String desc) => + ListTile(title: Text(desc)), + onSuggestionSelected: (String description) { + _controller.text = description; + bloc.add(DescriptionChangedEvent(description)); + }, + suggestionsCallback: (pattern) async { + if (pattern.length < 2) return []; - List descriptions = timers.state.timers + List descriptions = timers.state.timers .where((timer) => timer.description != null) - .where((timer) => timer.description.toLowerCase().contains(pattern.toLowerCase()) ?? false) + .where((timer) => + timer.description + .toLowerCase() + .contains(pattern.toLowerCase()) ?? + false) .map((timer) => timer.description) .toSet() .toList(); - return descriptions; - }, - ); - } - else { - return TextField( - key: Key("descriptionField"), - focusNode: _focus, - controller: _controller, - autocorrect: true, - decoration: InputDecoration( - hintText: L10N.of(context).tr.whatAreYouDoing, - ), - onChanged: (String description) => bloc.add(DescriptionChangedEvent(description)), - onSubmitted: (String description) { - _focus.unfocus(); - bloc.add(DescriptionChangedEvent(description)); - }, - ); - } + return descriptions; + }, + ); + } else { + return TextField( + key: Key("descriptionField"), + focusNode: _focus, + controller: _controller, + autocorrect: true, + decoration: InputDecoration( + hintText: L10N.of(context).tr.whatAreYouDoing, + ), + onChanged: (String description) => + bloc.add(DescriptionChangedEvent(description)), + onSubmitted: (String description) { + _focus.unfocus(); + bloc.add(DescriptionChangedEvent(description)); + }, + ); } - ); + }); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/FilterButton.dart b/lib/screens/dashboard/components/FilterButton.dart index a12b47bc..8e900116 100644 --- a/lib/screens/dashboard/components/FilterButton.dart +++ b/lib/screens/dashboard/components/FilterButton.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -28,8 +28,10 @@ class FilterButton extends StatelessWidget { icon: Icon(FontAwesomeIcons.filter), onPressed: () => showModalBottomSheet( context: context, - builder: (BuildContext context) => FilterSheet(dashboardBloc: bloc,), + builder: (BuildContext context) => FilterSheet( + dashboardBloc: bloc, + ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/FilterSheet.dart b/lib/screens/dashboard/components/FilterSheet.dart index 6d966764..d98e6d38 100644 --- a/lib/screens/dashboard/components/FilterSheet.dart +++ b/lib/screens/dashboard/components/FilterSheet.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -27,154 +27,173 @@ import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; class FilterSheet extends StatelessWidget { final DashboardBloc dashboardBloc; static DateFormat _dateFormat = DateFormat("EE, MMM d, yyyy"); + const FilterSheet({Key key, @required this.dashboardBloc}) - : assert(dashboardBloc != null), - super(key: key); + : assert(dashboardBloc != null), + super(key: key); @override Widget build(BuildContext context) { final ProjectsBloc projectsBloc = BlocProvider.of(context); - + return BlocBuilder( bloc: dashboardBloc, builder: (BuildContext context, DashboardState state) { return ListView( shrinkWrap: true, children: [ - ExpansionTile( - title: Text( - L10N.of(context).tr.filter, + ExpansionTile( + title: Text(L10N.of(context).tr.filter, style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), - initiallyExpanded: true, - children: [ - Slidable( - actionPane: SlidableDrawerActionPane(), - actionExtentRatio: 0.15, - child: ListTile( - leading: Icon(FontAwesomeIcons.calendar), - title: Text(L10N.of(context).tr.from), - trailing: Padding( - padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(state.filterStart == null ? "—" : _dateFormat.format(state.filterStart)), - ), - onTap: () async { - await DatePicker.showDatePicker( - context, + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), + initiallyExpanded: true, + children: [ + Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: Icon(FontAwesomeIcons.calendar), + title: Text(L10N.of(context).tr.from), + trailing: Padding( + padding: EdgeInsets.fromLTRB(0, 0, 18, 0), + child: Text(state.filterStart == null + ? "—" + : _dateFormat.format(state.filterStart)), + ), + onTap: () async { + await DatePicker.showDatePicker(context, currentTime: state.filterStart, - onChanged: (DateTime dt) => dashboardBloc.add(FilterStartChangedEvent(DateTime(dt.year, dt.month, dt.day))), - onConfirm: (DateTime dt) => dashboardBloc.add(FilterStartChangedEvent(DateTime(dt.year, dt.month, dt.day))), + onChanged: (DateTime dt) => dashboardBloc.add( + FilterStartChangedEvent( + DateTime(dt.year, dt.month, dt.day))), + onConfirm: (DateTime dt) => dashboardBloc.add( + FilterStartChangedEvent( + DateTime(dt.year, dt.month, dt.day))), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); - }, - ), - secondaryActions: - state.filterStart == null - ? [] - : [ + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); + }, + ), + secondaryActions: state.filterStart == null + ? [] + : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, - onTap: () => dashboardBloc.add(FilterStartChangedEvent(null)), + onTap: () => dashboardBloc + .add(FilterStartChangedEvent(null)), ) ], - ), - Slidable( - actionPane: SlidableDrawerActionPane(), - actionExtentRatio: 0.15, - child: ListTile( - leading: Icon(FontAwesomeIcons.calendar), - title: Text(L10N.of(context).tr.to), - trailing: Padding( - padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(state.filterEnd == null ? "—" : _dateFormat.format(state.filterEnd)), - ), - onTap: () async { - await DatePicker.showDatePicker( - context, + ), + Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: Icon(FontAwesomeIcons.calendar), + title: Text(L10N.of(context).tr.to), + trailing: Padding( + padding: EdgeInsets.fromLTRB(0, 0, 18, 0), + child: Text(state.filterEnd == null + ? "—" + : _dateFormat.format(state.filterEnd)), + ), + onTap: () async { + await DatePicker.showDatePicker(context, currentTime: state.filterEnd, - onChanged: (DateTime dt) => dashboardBloc.add(FilterEndChangedEvent(DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999))), - onConfirm: (DateTime dt) => dashboardBloc.add(FilterEndChangedEvent(DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999))), + onChanged: (DateTime dt) => dashboardBloc.add( + FilterEndChangedEvent(DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999))), + onConfirm: (DateTime dt) => dashboardBloc.add( + FilterEndChangedEvent(DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999))), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); - }, - ), - secondaryActions: - state.filterEnd == null - ? [] - : [ + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); + }, + ), + secondaryActions: state.filterEnd == null + ? [] + : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, - onTap: () => dashboardBloc.add(FilterEndChangedEvent(null)), + onTap: () => + dashboardBloc.add(FilterEndChangedEvent(null)), ) ], - ), - ], - ), - ExpansionTile( - title: Text( - L10N.of(context).tr.projects, + ), + ], + ), + ExpansionTile( + title: Text(L10N.of(context).tr.projects, style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + RaisedButton( + child: Text("Select None"), + onPressed: () => dashboardBloc.add( + FilterProjectsChangedEvent([null] + .followedBy( + projectsBloc.state.projects.map((p) => p.id)) + .toList())), + ), + RaisedButton( + child: Text("Select All"), + onPressed: () => dashboardBloc + .add(FilterProjectsChangedEvent([])), + ), + ], ), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.max, - children: [ - RaisedButton( - child: Text("Select None"), - onPressed: () => dashboardBloc.add(FilterProjectsChangedEvent([null].followedBy(projectsBloc.state.projects.map((p) => p.id)).toList())), - ), - RaisedButton( - child: Text("Select All"), - onPressed: () => dashboardBloc.add(FilterProjectsChangedEvent([])), - ), - ], - ), - ].followedBy( - [null].followedBy(projectsBloc.state.projects).map( - (project) => CheckboxListTile( - secondary: ProjectColour(project: project,), - title: Text(project?.name ?? L10N.of(context).tr.noProject), - value: !state.hiddenProjects.any((p) => p == project?.id), - activeColor: Theme.of(context).accentColor, - onChanged: (_) { - List hiddenProjects = state.hiddenProjects.map((p) => p).toList(); - if(state.hiddenProjects.any((p) => p == project?.id)) { - hiddenProjects.removeWhere((p) => p == project?.id); - } - else { - hiddenProjects.add(project.id); - } - dashboardBloc.add(FilterProjectsChangedEvent(hiddenProjects)); - }, - ) - ) - ).toList(), - ), + ] + .followedBy([null] + .followedBy(projectsBloc.state.projects) + .map((project) => CheckboxListTile( + secondary: ProjectColour( + project: project, + ), + title: Text( + project?.name ?? L10N.of(context).tr.noProject), + value: !state.hiddenProjects + .any((p) => p == project?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) { + List hiddenProjects = + state.hiddenProjects.map((p) => p).toList(); + if (state.hiddenProjects + .any((p) => p == project?.id)) { + hiddenProjects + .removeWhere((p) => p == project?.id); + } else { + hiddenProjects.add(project.id); + } + dashboardBloc.add( + FilterProjectsChangedEvent(hiddenProjects)); + }, + ))) + .toList(), + ), ], ); }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart index c32f34a4..7fc991aa 100644 --- a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart +++ b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart @@ -32,18 +32,23 @@ import 'TimerTileBuilder.dart'; class GroupedStoppedTimersRow extends StatefulWidget { final List timers; + const GroupedStoppedTimersRow({Key key, @required this.timers}) : assert(timers != null), assert(timers.length > 1), super(key: key); @override - _GroupedStoppedTimersRowState createState() => _GroupedStoppedTimersRowState(); + _GroupedStoppedTimersRowState createState() => + _GroupedStoppedTimersRowState(); } -class _GroupedStoppedTimersRowState extends State with SingleTickerProviderStateMixin { - static final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); - static final Animatable _halfTween = Tween(begin: 0.0, end: -0.5); +class _GroupedStoppedTimersRowState extends State + with SingleTickerProviderStateMixin { + static final Animatable _easeInTween = + CurveTween(curve: Curves.easeIn); + static final Animatable _halfTween = + Tween(begin: 0.0, end: -0.5); bool _expanded; AnimationController _controller; @@ -53,7 +58,8 @@ class _GroupedStoppedTimersRowState extends State with void initState() { super.initState(); _expanded = false; - _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this); + _controller = + AnimationController(duration: Duration(milliseconds: 200), vsync: this); _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); } @@ -76,19 +82,16 @@ class _GroupedStoppedTimersRowState extends State with _expanded = expanded; if (_expanded) { _controller.forward(); - } - else { + } else { _controller.reverse(); } }); }, leading: ProjectColour( project: BlocProvider.of(context) - .getProjectByID(widget.timers[0].projectID) - ), + .getProjectByID(widget.timers[0].projectID)), title: timerTileBuilder.getTitleWidget(widget.timers[0]), - subtitle: timerTileBuilder.getSubTitleWidget(widget.timers[0] - ), + subtitle: timerTileBuilder.getSubTitleWidget(widget.timers[0]), trailing: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -99,17 +102,16 @@ class _GroupedStoppedTimersRowState extends State with ), Container(width: 8), Text( - TimerEntry.formatDuration( - widget.timers.fold( + TimerEntry.formatDuration(widget.timers.fold( Duration(), - (Duration sum, TimerEntry timer) => sum + timer.endTime.difference(timer.startTime) - ) - ), - style: TextStyle(fontFamily: "FiraMono") - ), + (Duration sum, TimerEntry timer) => + sum + timer.endTime.difference(timer.startTime))), + style: TextStyle(fontFamily: "FiraMono")), ], ), - children: widget.timers.map((timer) => StoppedTimerRow(timer: timer)).toList(), + children: widget.timers + .map((timer) => StoppedTimerRow(timer: timer)) + .toList(), ), secondaryActions: [ IconSlideAction( @@ -117,23 +119,31 @@ class _GroupedStoppedTimersRowState extends State with foregroundColor: Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.play, onTap: () { - final TimersBloc timersBloc = BlocProvider.of(context); + final TimersBloc timersBloc = + BlocProvider.of(context); assert(timersBloc != null); - final ProjectsBloc projectsBloc = BlocProvider.of(context); + final ProjectsBloc projectsBloc = + BlocProvider.of(context); assert(projectsBloc != null); - Project project = projectsBloc.getProjectByID(widget.timers.first?.projectID); + Project project = + projectsBloc.getProjectByID(widget.timers.first?.projectID); - final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + final WorkTypesBloc workTypesBloc = + BlocProvider.of(context); assert(workTypesBloc != null); - WorkType workType = workTypesBloc .getWorkTypeByID(widget.timers.first?.workTypeID); + WorkType workType = workTypesBloc + .getWorkTypeByID(widget.timers.first?.workTypeID); - final SettingsBloc settingsBloc = BlocProvider.of(context); + final SettingsBloc settingsBloc = + BlocProvider.of(context); if (!settingsBloc.state.allowMultipleActiveTimers) { timersBloc.add(StopAllTimers()); } - timersBloc.add(CreateTimer( description: widget.timers.first?.description ?? "", project: project, workType: workType)); - } - ) + timersBloc.add(CreateTimer( + description: widget.timers.first?.description ?? "", + project: project, + workType: workType)); + }) ], ); } diff --git a/lib/screens/dashboard/components/PopupMenu.dart b/lib/screens/dashboard/components/PopupMenu.dart index e8bf2819..1c28d6d6 100644 --- a/lib/screens/dashboard/components/PopupMenu.dart +++ b/lib/screens/dashboard/components/PopupMenu.dart @@ -24,7 +24,12 @@ import 'package:timecop/screens/settings/SettingsScreen.dart'; import 'package:timecop/screens/workTypes/WorkTypesScreen.dart'; enum MenuItem { - projects, workTypes, reports, export, settings, about, + projects, + workTypes, + reports, + export, + settings, + about, } class PopupMenu extends StatelessWidget { @@ -128,4 +133,4 @@ class PopupMenu extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/ProjectSelectField.dart b/lib/screens/dashboard/components/ProjectSelectField.dart index bcdcbe5f..1407d891 100644 --- a/lib/screens/dashboard/components/ProjectSelectField.dart +++ b/lib/screens/dashboard/components/ProjectSelectField.dart @@ -35,60 +35,65 @@ class _ProjectSelectFieldState extends State { final ProjectsBloc projectsBloc = BlocProvider.of(context); assert(projectsBloc != null); return BlocBuilder( - builder: (BuildContext context, ProjectsState projectsState) { - return BlocBuilder( - bloc: bloc, - builder: (BuildContext context, DashboardState state) { - // detect if the project we had selected was deleted - if(state.newProject != null && projectsBloc.getProjectByID(state.newProject.id) == null) { - bloc.add(ProjectChangedEvent(null)); - return IconButton( - alignment: Alignment.centerLeft, - icon: ProjectColour(project: null), - onPressed: null, - ); - } - + builder: (BuildContext context, ProjectsState projectsState) { + return BlocBuilder( + bloc: bloc, + builder: (BuildContext context, DashboardState state) { + // detect if the project we had selected was deleted + if (state.newProject != null && + projectsBloc.getProjectByID(state.newProject.id) == null) { + bloc.add(ProjectChangedEvent(null)); return IconButton( alignment: Alignment.centerLeft, - icon: ProjectColour(project: state.newProject), - onPressed: () async { - Project chosenProject = await showDialog( + icon: ProjectColour(project: null), + onPressed: null, + ); + } + + return IconButton( + alignment: Alignment.centerLeft, + icon: ProjectColour(project: state.newProject), + onPressed: () async { + Project chosenProject = await showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { - return SimpleDialog( title: Text(L10N.of(context).tr.projects), contentPadding: EdgeInsets.fromLTRB(8.0, 12.0, 8.0, 16.0), - children: [null].followedBy(projectsState.projects).map( - (Project p) => FlatButton( - onPressed: () { - Navigator.of(context).pop(p); - }, - child: Row( - children: [ - ProjectColour(project: p), - Padding( - padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), - child: Text( - p?.name ?? L10N.of(context).tr.noProject, - style: TextStyle(color: p == null ? Theme.of(context).disabledColor : Theme.of(context).textTheme.body1.color) - ), - ), - ], - ) - ) - ).toList(), + children: [null] + .followedBy(projectsState.projects) + .map((Project p) => FlatButton( + onPressed: () { + Navigator.of(context).pop(p); + }, + child: Row( + children: [ + ProjectColour(project: p), + Padding( + padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Text( + p?.name ?? + L10N.of(context).tr.noProject, + style: TextStyle( + color: p == null + ? Theme.of(context) + .disabledColor + : Theme.of(context) + .textTheme + .body1 + .color)), + ), + ], + ))) + .toList(), ); - } - ); - bloc.add(ProjectChangedEvent(chosenProject)); - }, - ); - }, - ); - } - ); + }); + bloc.add(ProjectChangedEvent(chosenProject)); + }, + ); + }, + ); + }); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/RunningTimerRow.dart b/lib/screens/dashboard/components/RunningTimerRow.dart index 90ca79d6..25729059 100644 --- a/lib/screens/dashboard/components/RunningTimerRow.dart +++ b/lib/screens/dashboard/components/RunningTimerRow.dart @@ -28,6 +28,7 @@ import 'TimerTileBuilder.dart'; class RunningTimerRow extends StatelessWidget { final TimerEntry timer; final DateTime now; + const RunningTimerRow({Key key, @required this.timer, @required this.now}) : assert(timer != null), assert(now != null), @@ -41,15 +42,20 @@ class RunningTimerRow extends StatelessWidget { actionPane: SlidableDrawerActionPane(), actionExtentRatio: 0.15, child: ListTile( - leading: ProjectColour( project: BlocProvider.of(context).getProjectByID(timer.projectID)), + leading: ProjectColour( + project: BlocProvider.of(context) + .getProjectByID(timer.projectID)), title: timerTileBuilder.getTitleWidget(timer), subtitle: timerTileBuilder.getSubTitleWidget(timer), - trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => TimerEditor(timer: timer,), + trailing: Text(timer.formatTime(), + style: TextStyle(fontFamily: "FiraMono")), + onTap: () => + Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext context) => TimerEditor( + timer: timer, + ), fullscreenDialog: true, - )) - ), + ))), actions: [ IconSlideAction( color: Theme.of(context).errorColor, @@ -71,10 +77,10 @@ class RunningTimerRow extends StatelessWidget { onPressed: () => Navigator.of(context).pop(true), ), ], - ) - ); + )); if (delete) { - final TimersBloc timersBloc = BlocProvider.of(context); + final TimersBloc timersBloc = + BlocProvider.of(context); assert(timersBloc != null); timersBloc.add(DeleteTimer(timer)); } @@ -95,4 +101,4 @@ class RunningTimerRow extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/RunningTimers.dart b/lib/screens/dashboard/components/RunningTimers.dart index 2e0db6b5..dbe55458 100644 --- a/lib/screens/dashboard/components/RunningTimers.dart +++ b/lib/screens/dashboard/components/RunningTimers.dart @@ -28,19 +28,25 @@ class RunningTimers extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, DashboardState dashboardState) { - if(dashboardState.searchString != null) { + if (dashboardState.searchString != null) { return Container(); } return BlocBuilder( builder: (BuildContext context, TimersState timersState) { - List runningTimers = timersState.timers.where((timer) => timer.endTime == null).toList(); - if(runningTimers.isEmpty) { + List runningTimers = timersState.timers + .where((timer) => timer.endTime == null) + .toList(); + if (runningTimers.isEmpty) { return Container(); } DateTime now = DateTime.now(); - Duration runningTotal = Duration(seconds: runningTimers.fold(0, (int sum, TimerEntry t) => sum + now.difference(t.startTime).inSeconds)); + Duration runningTotal = Duration( + seconds: runningTimers.fold( + 0, + (int sum, TimerEntry t) => + sum + now.difference(t.startTime).inSeconds)); return Material( elevation: 4, @@ -59,29 +65,25 @@ class RunningTimers extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ - Text( - L10N.of(context).tr.runningTimers, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), - Text( - TimerEntry.formatDuration(runningTotal), - style: TextStyle( - color: Theme.of(context).accentColor, - fontFamily: "FiraMono", - ) - ) + Text(L10N.of(context).tr.runningTimers, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), + Text(TimerEntry.formatDuration(runningTotal), + style: TextStyle( + color: Theme.of(context).accentColor, + fontFamily: "FiraMono", + )) ], ), Divider(), ], ), ), - ].followedBy( - runningTimers.map((timer) => RunningTimerRow(timer: timer, now: timersState.now)) - ).toList(), + ] + .followedBy(runningTimers.map((timer) => + RunningTimerRow(timer: timer, now: timersState.now))) + .toList(), ), ); }, @@ -89,4 +91,4 @@ class RunningTimers extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/StartTimerButton.dart b/lib/screens/dashboard/components/StartTimerButton.dart index 1a010ab9..7a18ed97 100644 --- a/lib/screens/dashboard/components/StartTimerButton.dart +++ b/lib/screens/dashboard/components/StartTimerButton.dart @@ -59,20 +59,22 @@ class _StartTimerButtonState extends State { final TimersBloc timers = BlocProvider.of(context); assert(timers != null); - final SettingsBloc settingsBloc = BlocProvider.of(context); + final SettingsBloc settingsBloc = + BlocProvider.of(context); if (!settingsBloc.state.allowMultipleActiveTimers) { timers.add(StopAllTimers()); } - timers.add(CreateTimer(description: bloc.state.newDescription, project: bloc.state.newProject, workType: bloc.state.newWorkType)); + timers.add(CreateTimer( + description: bloc.state.newDescription, + project: bloc.state.newProject, + workType: bloc.state.newWorkType)); bloc.add(TimerWasStartedEvent()); }, ); - } - else { + } else { return StartTimerSpeedDial(); } - } - ); + }); } } diff --git a/lib/screens/dashboard/components/StartTimerSpeedDial.dart b/lib/screens/dashboard/components/StartTimerSpeedDial.dart index 92082a2f..86654144 100644 --- a/lib/screens/dashboard/components/StartTimerSpeedDial.dart +++ b/lib/screens/dashboard/components/StartTimerSpeedDial.dart @@ -25,7 +25,8 @@ class StartTimerSpeedDial extends StatefulWidget { _StartTimerSpeedDialState createState() => _StartTimerSpeedDialState(); } -class _StartTimerSpeedDialState extends State with TickerProviderStateMixin { +class _StartTimerSpeedDialState extends State + with TickerProviderStateMixin { AnimationController _controller; @override @@ -49,9 +50,7 @@ class _StartTimerSpeedDialState extends State with TickerPr assert(bloc != null); // adapted from https://stackoverflow.com/a/46480722 - return Column( - mainAxisSize: MainAxisSize.min, - children: [ + return Column(mainAxisSize: MainAxisSize.min, children: [ Container( height: 70.0, width: 56.0, @@ -59,11 +58,7 @@ class _StartTimerSpeedDialState extends State with TickerPr child: ScaleTransition( scale: CurvedAnimation( parent: _controller, - curve: Interval( - 0.0, - 1.0, - curve: Curves.easeOut - ), + curve: Interval(0.0, 1.0, curve: Curves.easeOut), ), child: FloatingActionButton( heroTag: null, @@ -86,7 +81,10 @@ class _StartTimerSpeedDialState extends State with TickerPr _controller.reverse(); final TimersBloc timers = BlocProvider.of(context); assert(timers != null); - timers.add(CreateTimer( description: bloc.state.newDescription, project: bloc.state.newProject, workType: bloc.state.newWorkType)); + timers.add(CreateTimer( + description: bloc.state.newDescription, + project: bloc.state.newProject, + workType: bloc.state.newWorkType)); bloc.add(TimerWasStartedEvent()); }, ), @@ -99,11 +97,7 @@ class _StartTimerSpeedDialState extends State with TickerPr child: ScaleTransition( scale: CurvedAnimation( parent: _controller, - curve: Interval( - 0.0, - 0.75, - curve: Curves.easeOut - ), + curve: Interval(0.0, 0.75, curve: Curves.easeOut), ), child: FloatingActionButton( heroTag: null, @@ -134,10 +128,11 @@ class _StartTimerSpeedDialState extends State with TickerPr AnimatedBuilder( animation: _controller, builder: (BuildContext conext, Widget child) { - return - FloatingActionButton( + return FloatingActionButton( heroTag: null, - backgroundColor: _controller.isDismissed ? Theme.of(context).accentColor : Theme.of(context).disabledColor, + backgroundColor: _controller.isDismissed + ? Theme.of(context).accentColor + : Theme.of(context).disabledColor, child: _controller.isDismissed ? Stack( // shenanigans to properly centre the icon (font awesome glyphs are variable @@ -147,7 +142,9 @@ class _StartTimerSpeedDialState extends State with TickerPr Positioned( top: 15, left: 16, - child: Icon(FontAwesomeIcons.stopwatch,), + child: Icon( + FontAwesomeIcons.stopwatch, + ), ) ], ) @@ -173,7 +170,6 @@ class _StartTimerSpeedDialState extends State with TickerPr ); }, ), - ] - ); + ]); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/StoppedTimerRow.dart b/lib/screens/dashboard/components/StoppedTimerRow.dart index 1247e357..8970a6f1 100644 --- a/lib/screens/dashboard/components/StoppedTimerRow.dart +++ b/lib/screens/dashboard/components/StoppedTimerRow.dart @@ -31,6 +31,7 @@ import 'TimerTileBuilder.dart'; class StoppedTimerRow extends StatelessWidget { final TimerEntry timer; + const StoppedTimerRow({Key key, @required this.timer}) : assert(timer != null), super(key: key); @@ -47,15 +48,19 @@ class StoppedTimerRow extends StatelessWidget { actionExtentRatio: 0.15, child: ListTile( key: Key("stoppedTimer-" + timer.id.toString()), - leading: ProjectColour(project: projects.getProjectByID(timer.projectID)), + leading: + ProjectColour(project: projects.getProjectByID(timer.projectID)), title: timerTileBuilder.getTitleWidget(timer), subtitle: timerTileBuilder.getSubTitleWidget(timer), - trailing: Text(timer.formatTime(), style: TextStyle(fontFamily: "FiraMono")), - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => TimerEditor( timer: timer,), + trailing: Text(timer.formatTime(), + style: TextStyle(fontFamily: "FiraMono")), + onTap: () => + Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext context) => TimerEditor( + timer: timer, + ), fullscreenDialog: true, - )) - ), + ))), actions: [ IconSlideAction( color: Theme.of(context).errorColor, @@ -77,10 +82,10 @@ class StoppedTimerRow extends StatelessWidget { onPressed: () => Navigator.of(context).pop(true), ), ], - ) - ); + )); if (delete) { - final TimersBloc timersBloc = BlocProvider.of(context); + final TimersBloc timersBloc = + BlocProvider.of(context); assert(timersBloc != null); timersBloc.add(DeleteTimer(timer)); } @@ -93,25 +98,32 @@ class StoppedTimerRow extends StatelessWidget { foregroundColor: Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.play, onTap: () { - final TimersBloc timersBloc = BlocProvider.of(context); + final TimersBloc timersBloc = + BlocProvider.of(context); assert(timersBloc != null); - final ProjectsBloc projectsBloc = BlocProvider.of(context); + final ProjectsBloc projectsBloc = + BlocProvider.of(context); assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(timer.projectID); - final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + final WorkTypesBloc workTypesBloc = + BlocProvider.of(context); assert(workTypesBloc != null); - WorkType workType = workTypesBloc.getWorkTypeByID(timer.workTypeID); + WorkType workType = + workTypesBloc.getWorkTypeByID(timer.workTypeID); - final SettingsBloc settingsBloc = BlocProvider.of(context); + final SettingsBloc settingsBloc = + BlocProvider.of(context); if (!settingsBloc.state.allowMultipleActiveTimers) { timersBloc.add(StopAllTimers()); } - timersBloc.add(CreateTimer( description: timer.description, project: project, workType: workType)); - } - ) + timersBloc.add(CreateTimer( + description: timer.description, + project: project, + workType: workType)); + }) ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/StoppedTimers.dart b/lib/screens/dashboard/components/StoppedTimers.dart index 40bd2ac7..f0f7a2cb 100644 --- a/lib/screens/dashboard/components/StoppedTimers.dart +++ b/lib/screens/dashboard/components/StoppedTimers.dart @@ -19,11 +19,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; -import 'package:timecop/models/timer_group.dart'; import 'package:timecop/models/timer_entry.dart'; +import 'package:timecop/models/timer_group.dart'; import 'package:timecop/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:timecop/screens/dashboard/components/CollapsibleDayGrouping.dart'; import 'package:timecop/screens/dashboard/components/GroupedStoppedTimersRow.dart'; + import 'StoppedTimerRow.dart'; class DayGrouping { @@ -35,15 +36,19 @@ class DayGrouping { Widget rows(BuildContext context) { final SettingsBloc settingsBloc = BlocProvider.of(context); - Duration runningTotal = Duration(seconds: entries.fold(0, (int sum, TimerEntry t) => sum + t.endTime.difference(t.startTime).inSeconds)); + Duration runningTotal = Duration( + seconds: entries.fold( + 0, + (int sum, TimerEntry t) => + sum + t.endTime.difference(t.startTime).inSeconds)); LinkedHashMap> pairedEntries = LinkedHashMap(); for (TimerEntry entry in entries) { - TimerGroup pair = TimerGroup(entry.projectID, entry.workTypeID, entry.description); + TimerGroup pair = + TimerGroup(entry.projectID, entry.workTypeID, entry.description); if (pairedEntries.containsKey(pair)) { pairedEntries[pair].add(entry); - } - else { + } else { pairedEntries[pair] = [entry]; } } @@ -52,16 +57,13 @@ class DayGrouping { if (settingsBloc.state.groupTimers) { if (timers.length > 1) { return [GroupedStoppedTimersRow(timers: timers)]; - } - else { + } else { return [StoppedTimerRow(timer: timers[0])]; } - } - else { + } else { return timers.map((t) => StoppedTimerRow(timer: t)).toList(); } - }) - .expand((l) => l); + }).expand((l) => l); if (settingsBloc.state.collapseDays) { return CollapsibleDayGrouping( @@ -69,8 +71,7 @@ class DayGrouping { totalTime: runningTotal, children: theDaysTimers, ); - } - else { + } else { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -83,20 +84,15 @@ class DayGrouping { mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ - Text( - _dateFormat.format(date), + Text(_dateFormat.format(date), style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), - Text( - TimerEntry.formatDuration(runningTotal), + fontWeight: FontWeight.w700)), + Text(TimerEntry.formatDuration(runningTotal), style: TextStyle( color: Theme.of(context).accentColor, fontFamily: "FiraMono", - ) - ) + )) ], ), Divider(), @@ -113,19 +109,25 @@ class StoppedTimers extends StatelessWidget { const StoppedTimers({Key key}) : super(key: key); static List groupDays(List days, TimerEntry timer) { - bool newDay = days.isEmpty || !days.any((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day); + bool newDay = days.isEmpty || + !days.any((DayGrouping day) => + day.date.year == timer.startTime.year && + day.date.month == timer.startTime.month && + day.date.day == timer.startTime.day); if (newDay) { - days.add( - DayGrouping( - DateTime( + days.add(DayGrouping(DateTime( timer.startTime.year, timer.startTime.month, timer.startTime.day, - ) - ) - ); + ))); } - days.firstWhere((DayGrouping day) => day.date.year == timer.startTime.year && day.date.month == timer.startTime.month && day.date.day == timer.startTime.day) .entries .add(timer); + days + .firstWhere((DayGrouping day) => + day.date.year == timer.startTime.year && + day.date.month == timer.startTime.month && + day.date.day == timer.startTime.day) + .entries + .add(timer); return days; } @@ -142,35 +144,33 @@ class StoppedTimers extends StatelessWidget { // filter based on filters if (dashboardState.filterStart != null) { - timers = timers.where((timer) => timer.startTime.isAfter(dashboardState.filterStart)); + timers = timers.where((timer) => + timer.startTime.isAfter(dashboardState.filterStart)); } if (dashboardState.filterEnd != null) { - timers = timers.where((timer) => timer.startTime.isBefore(dashboardState.filterEnd)); + timers = timers.where((timer) => + timer.startTime.isBefore(dashboardState.filterEnd)); } // filter based on selected projects - timers = timers.where((t) => !dashboardState.hiddenProjects.any((p) => p == t.projectID)); + timers = timers.where((t) => + !dashboardState.hiddenProjects.any((p) => p == t.projectID)); // filter based on search if (dashboardState.searchString != null) { - timers = timers - .where((timer) { + timers = timers.where((timer) { // allow searching using a regex if surrounded by `/` and `/` - if (dashboardState.searchString.length > 2 && dashboardState.searchString.startsWith("/") && dashboardState.searchString.endsWith("/")) { - return timer - .description - ?.contains( - RegExp( + if (dashboardState.searchString.length > 2 && + dashboardState.searchString.startsWith("/") && + dashboardState.searchString.endsWith("/")) { + return timer.description?.contains(RegExp( dashboardState.searchString.substring( - 1, - dashboardState.searchString.length - 1 - ) - ) - ) - ?? true; - } - else { - return timer.description?.toLowerCase()?.contains( dashboardState.searchString.toLowerCase()) ?? true; + 1, dashboardState.searchString.length - 1))) ?? + true; + } else { + return timer.description?.toLowerCase()?.contains( + dashboardState.searchString.toLowerCase()) ?? + true; } }); } @@ -179,11 +179,12 @@ class StoppedTimers extends StatelessWidget { return ListView.builder( itemCount: days.length, - itemBuilder: (BuildContext context, int index) => days[index].rows(context), + itemBuilder: (BuildContext context, int index) => + days[index].rows(context), ); }, ); }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/dashboard/components/TimerTileBuilder.dart b/lib/screens/dashboard/components/TimerTileBuilder.dart index 9f7f99e7..e1b82928 100644 --- a/lib/screens/dashboard/components/TimerTileBuilder.dart +++ b/lib/screens/dashboard/components/TimerTileBuilder.dart @@ -1,11 +1,9 @@ -import 'package:badges/badges.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:timecop/blocs/projects/projects_bloc.dart'; import 'package:timecop/blocs/settings/bloc.dart'; import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/components/WorkTypeBadge.dart'; -import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/timer_entry.dart'; import '../../../l10n.dart'; diff --git a/lib/screens/dashboard/components/TopBar.dart b/lib/screens/dashboard/components/TopBar.dart index 6e37add7..9c37491e 100644 --- a/lib/screens/dashboard/components/TopBar.dart +++ b/lib/screens/dashboard/components/TopBar.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -38,7 +38,7 @@ class _TopBarState extends State { FocusNode _searchFocusNode; @override - void initState() { + void initState() { super.initState(); _searching = false; _searchFocusNode = FocusNode(debugLabel: "search-focus"); @@ -46,7 +46,7 @@ class _TopBarState extends State { } @override - void dispose() { + void dispose() { _searchFocusNode.dispose(); _searchController.dispose(); super.dispose(); @@ -62,22 +62,20 @@ class _TopBarState extends State { DashboardBloc bloc = BlocProvider.of(context); return Form( - key: _searchFormKey, - child: TextFormField( - controller: _searchController, - onChanged: (search) { - bloc.add(SearchChangedEvent(search)); - }, - decoration: InputDecoration( - prefixIcon: Icon(FontAwesomeIcons.search), - suffixIcon: IconButton( - color: Theme.of(context).backgroundColor, - icon: Icon(FontAwesomeIcons.timesCircle), - onPressed: cancelSearch, - ) - ), - ) - ); + key: _searchFormKey, + child: TextFormField( + controller: _searchController, + onChanged: (search) { + bloc.add(SearchChangedEvent(search)); + }, + decoration: InputDecoration( + prefixIcon: Icon(FontAwesomeIcons.search), + suffixIcon: IconButton( + color: Theme.of(context).backgroundColor, + icon: Icon(FontAwesomeIcons.timesCircle), + onPressed: cancelSearch, + )), + )); } @override @@ -85,28 +83,27 @@ class _TopBarState extends State { final DashboardBloc bloc = BlocProvider.of(context); return AppBar( - leading: _searching - ? IconButton( - icon: Icon(FontAwesomeIcons.chevronLeft), - onPressed: cancelSearch, - ) - : PopupMenu(), - title: _searching ? searchBar(context) : Text(L10N.of(context).tr.appName), - actions: - !_searching - ? [ - IconButton( - icon: Icon(FontAwesomeIcons.search), - onPressed: () { - _searchController.text = ""; - bloc.add(SearchChangedEvent("")); - setState(() => _searching = true); - _searchFocusNode.requestFocus(); - }, - ), - FilterButton(), - ] - : [] - ); + leading: _searching + ? IconButton( + icon: Icon(FontAwesomeIcons.chevronLeft), + onPressed: cancelSearch, + ) + : PopupMenu(), + title: + _searching ? searchBar(context) : Text(L10N.of(context).tr.appName), + actions: !_searching + ? [ + IconButton( + icon: Icon(FontAwesomeIcons.search), + onPressed: () { + _searchController.text = ""; + bloc.add(SearchChangedEvent("")); + setState(() => _searching = true); + _searchFocusNode.requestFocus(); + }, + ), + FilterButton(), + ] + : []); } -} \ No newline at end of file +} diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index 90e93664..7c00bb2a 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -46,8 +46,7 @@ class DayGroup { final DateTime date; List timers = []; - DayGroup(this.date) - : assert(date != null); + DayGroup(this.date) : assert(date != null); } class _ExportScreenState extends State { @@ -63,7 +62,9 @@ class _ExportScreenState extends State { super.initState(); final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); - selectedProjects = [null].followedBy(projects.state.projects.map((p) => Project.clone(p))).toList(); + selectedProjects = [null] + .followedBy(projects.state.projects.map((p) => Project.clone(p))) + .toList(); final SettingsBloc settingsBloc = BlocProvider.of(context); _startDate = settingsBloc.getFilterStartDate(); @@ -91,19 +92,25 @@ class _ExportScreenState extends State { // on android, copy it somewhere where it can be shared if (Platform.isAndroid) { Directory directory = await getExternalStorageDirectory(); - File copiedDB = await File(dbPath).copy(p.join(directory.path, "timecop.db")); + File copiedDB = await File(dbPath) + .copy(p.join(directory.path, "timecop.db")); dbPath = copiedDB.path; } - await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopDatabase(_dateFormat.format(DateTime.now())), filePath: dbPath); - } - on Exception catch (e) { - _scaffoldKey.currentState.showSnackBar( - SnackBar( + await FlutterShare.shareFile( + title: L10N + .of(context) + .tr + .timeCopDatabase(_dateFormat.format(DateTime.now())), + filePath: dbPath); + } on Exception catch (e) { + _scaffoldKey.currentState.showSnackBar(SnackBar( backgroundColor: Theme.of(context).errorColor, - content: Text(e.toString(), style: TextStyle(color: Colors.white),), + content: Text( + e.toString(), + style: TextStyle(color: Colors.white), + ), duration: Duration(seconds: 5), - ) - ); + )); } }, ) @@ -112,13 +119,10 @@ class _ExportScreenState extends State { body: ListView( children: [ ExpansionTile( - title: Text( - L10N.of(context).tr.filter, + title: Text(L10N.of(context).tr.filter, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), initiallyExpanded: true, children: [ Slidable( @@ -129,30 +133,33 @@ class _ExportScreenState extends State { title: Text(L10N.of(context).tr.from), trailing: Padding( padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_startDate == null ? "—" : _dateFormat.format(_startDate)), + child: Text(_startDate == null + ? "—" + : _dateFormat.format(_startDate)), ), onTap: () async { - await DatePicker.showDatePicker( - context, + await DatePicker.showDatePicker(context, currentTime: _startDate, - onChanged: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), - onConfirm: (DateTime dt) => setState(() => _startDate = DateTime(dt.year, dt.month, dt.day)), + onChanged: (DateTime dt) => setState(() => + _startDate = DateTime(dt.year, dt.month, dt.day)), + onConfirm: (DateTime dt) => setState(() => + _startDate = DateTime(dt.year, dt.month, dt.day)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); }, ), - secondaryActions: - _startDate == null + secondaryActions: _startDate == null ? [] : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, onTap: () { setState(() { @@ -170,30 +177,34 @@ class _ExportScreenState extends State { title: Text(L10N.of(context).tr.to), trailing: Padding( padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_endDate == null ? "—" : _dateFormat.format(_endDate)), + child: Text( + _endDate == null ? "—" : _dateFormat.format(_endDate)), ), onTap: () async { - await DatePicker.showDatePicker( - context, + await DatePicker.showDatePicker(context, currentTime: _endDate, - onChanged: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), - onConfirm: (DateTime dt) => setState(() => _endDate = DateTime(dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onChanged: (DateTime dt) => setState(() => _endDate = + DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => _endDate = + DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); }, ), - secondaryActions: - _endDate == null + secondaryActions: _endDate == null ? [] : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, onTap: () { setState(() { @@ -207,69 +218,71 @@ class _ExportScreenState extends State { ), BlocBuilder( bloc: settingsBloc, - builder: (BuildContext context, SettingsState settingsState) => ExpansionTile( + builder: (BuildContext context, SettingsState settingsState) => + ExpansionTile( key: Key("optionColumns"), - title: Text( - L10N.of(context).tr.columns, + title: Text(L10N.of(context).tr.columns, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), children: [ SwitchListTile( title: Text(L10N.of(context).tr.date), value: settingsState.exportIncludeDate, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeDate: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeDate: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.project), value: settingsState.exportIncludeProject, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeProject: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeProject: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.description), value: settingsState.exportIncludeDescription, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeDescription: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeDescription: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.combinedProjectDescription), value: settingsState.exportIncludeProjectDescription, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeProjectDescription: value)), + onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent( + exportIncludeProjectDescription: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.startTime), value: settingsState.exportIncludeStartTime, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeStartTime: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeStartTime: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.endTime), value: settingsState.exportIncludeEndTime, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeEndTime: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeEndTime: value)), activeColor: Theme.of(context).accentColor, ), SwitchListTile( title: Text(L10N.of(context).tr.timeH), value: settingsState.exportIncludeDurationHours, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportIncludeDurationHours: value)), + onChanged: (bool value) => settingsBloc.add( + SetBoolValueEvent(exportIncludeDurationHours: value)), activeColor: Theme.of(context).accentColor, ), ], ), ), ExpansionTile( - title: Text( - L10N.of(context).tr.projects, + title: Text(L10N.of(context).tr.projects, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -288,53 +301,58 @@ class _ExportScreenState extends State { child: Text("Select All"), onPressed: () { setState(() { - selectedProjects = [null].followedBy(projectsBloc.state.projects.map((p) => Project.clone(p))).toList(); + selectedProjects = [null] + .followedBy(projectsBloc.state.projects + .map((p) => Project.clone(p))) + .toList(); }); }, ), ], ) - ].followedBy( - [null].followedBy(projectsBloc.state.projects).map( - (project) => CheckboxListTile( - secondary: ProjectColour(project: project,), - title: Text(project?.name ?? L10N.of(context).tr.noProject), + ] + .followedBy([ + null + ].followedBy(projectsBloc.state.projects).map((project) => + CheckboxListTile( + secondary: ProjectColour( + project: project, + ), + title: + Text(project?.name ?? L10N.of(context).tr.noProject), value: selectedProjects.any((p) => p?.id == project?.id), activeColor: Theme.of(context).accentColor, onChanged: (_) => setState(() { if (selectedProjects.any((p) => p?.id == project?.id)) { - selectedProjects.removeWhere((p) => p?.id == project?.id); - } - else { + selectedProjects + .removeWhere((p) => p?.id == project?.id); + } else { selectedProjects.add(project); } }), - ) - ) - ).toList(), + ))) + .toList(), ), ExpansionTile( - title: Text( - L10N.of(context).tr.options, + title: Text(L10N.of(context).tr.options, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), children: [ BlocBuilder( bloc: settingsBloc, - builder: (BuildContext context, SettingsState settingsState) => SwitchListTile( + builder: (BuildContext context, SettingsState settingsState) => + SwitchListTile( title: Text(L10N.of(context).tr.groupTimers), value: settingsState.exportGroupTimers, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(exportGroupTimers: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportGroupTimers: value)), activeColor: Theme.of(context).accentColor, ), ) ], ), - ] - .toList(), + ].toList(), ), floatingActionButton: FloatingActionButton( key: Key("exportFAB"), @@ -353,7 +371,8 @@ class _ExportScreenState extends State { onPressed: () async { final TimersBloc timers = BlocProvider.of(context); assert(timers != null); - final ProjectsBloc projects = BlocProvider.of(context); + final ProjectsBloc projects = + BlocProvider.of(context); assert(projects != null); List headers = []; @@ -382,53 +401,67 @@ class _ExportScreenState extends State { List filteredTimers = timers.state.timers .where((t) => t.endTime != null) .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) - .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) - .where((t) => _endDate == null ? true : t.endTime.isBefore(_endDate)) + .where((t) => + _startDate == null ? true : t.startTime.isAfter(_startDate)) + .where((t) => + _endDate == null ? true : t.endTime.isBefore(_endDate)) .toList(); filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); // group similar timers if that's what you're in to - if (settingsBloc.state.exportGroupTimers && !(settingsBloc.state.exportIncludeStartTime || settingsBloc.state.exportIncludeEndTime)) { - filteredTimers = - timers.state.timers + if (settingsBloc.state.exportGroupTimers && + !(settingsBloc.state.exportIncludeStartTime || + settingsBloc.state.exportIncludeEndTime)) { + filteredTimers = timers.state.timers .where((t) => t.endTime != null) - .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) - .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) - .where((t) => _endDate == null ? true : t.endTime.isBefore(_endDate)) + .where( + (t) => selectedProjects.any((p) => p?.id == t.projectID)) + .where((t) => _startDate == null + ? true + : t.startTime.isAfter(_startDate)) + .where((t) => + _endDate == null ? true : t.endTime.isBefore(_endDate)) .toList(); filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); // now start grouping those suckers - LinkedHashMap>> derp = LinkedHashMap(); + LinkedHashMap>> + derp = LinkedHashMap(); for (TimerEntry timer in filteredTimers) { String date = _exportDateFormat.format(timer.startTime); - LinkedHashMap> pairedEntries = derp.putIfAbsent(date, () => LinkedHashMap()); - List pairedList = pairedEntries.putIfAbsent(TimerGroup( timer.projectID, timer.workTypeID, timer.description), () => []); + LinkedHashMap> pairedEntries = + derp.putIfAbsent(date, () => LinkedHashMap()); + List pairedList = pairedEntries.putIfAbsent( + TimerGroup( + timer.projectID, timer.workTypeID, timer.description), + () => []); pairedList.add(timer); } // ok, now they're grouped based on date, then combined project + description pairs // time to get them back into a flat list - filteredTimers = derp.values.expand((LinkedHashMap> pairedEntries) { - return pairedEntries.values.map((List groupedEntries) { + filteredTimers = derp.values.expand( + (LinkedHashMap> pairedEntries) { + return pairedEntries.values + .map((List groupedEntries) { assert(groupedEntries.isNotEmpty); // not a grouped entry if (groupedEntries.length == 1) return groupedEntries[0]; // yes a group entry, build a dummy timer entry - Duration totalTime = groupedEntries.fold(Duration(),(Duration d, TimerEntry t) => d + t.endTime.difference(t.startTime)); - return TimerEntry.clone(groupedEntries[0], endTime: groupedEntries[0].startTime.add(totalTime)); + Duration totalTime = groupedEntries.fold( + Duration(), + (Duration d, TimerEntry t) => + d + t.endTime.difference(t.startTime)); + return TimerEntry.clone(groupedEntries[0], + endTime: groupedEntries[0].startTime.add(totalTime)); }); - }) - .toList(); + }).toList(); } - List> data = >[headers] - .followedBy( - filteredTimers - .map( - (timer) { + List> data = + >[headers].followedBy(filteredTimers.map((timer) { List row = []; if (settingsBloc.state.exportIncludeDate) { row.add(_exportDateFormat.format(timer.startTime)); @@ -440,7 +473,9 @@ class _ExportScreenState extends State { row.add(timer.description ?? ""); } if (settingsBloc.state.exportIncludeProjectDescription) { - row.add((projects.getProjectByID(timer.projectID)?.name ?? "") + ": " + (timer.description ?? "")); + row.add((projects.getProjectByID(timer.projectID)?.name ?? "") + + ": " + + (timer.description ?? "")); } if (settingsBloc.state.exportIncludeStartTime) { row.add(timer.startTime.toUtc().toIso8601String()); @@ -449,12 +484,15 @@ class _ExportScreenState extends State { row.add(timer.endTime.toUtc().toIso8601String()); } if (settingsBloc.state.exportIncludeDurationHours) { - row.add((timer.endTime.difference(timer.startTime).inSeconds.toDouble() /3600.0).toStringAsFixed(4)); + row.add((timer.endTime + .difference(timer.startTime) + .inSeconds + .toDouble() / + 3600.0) + .toStringAsFixed(4)); } return row; - } - ) - ).toList(); + })).toList(); String csv = ListToCsvConverter().convert(data); print('CSV:'); print(csv); @@ -462,16 +500,19 @@ class _ExportScreenState extends State { Directory directory; if (Platform.isAndroid) { directory = await getExternalStorageDirectory(); - } - else { + } else { directory = await getApplicationDocumentsDirectory(); } final String localPath = '${directory.path}/timecop.csv'; File file = File(localPath); await file.writeAsString(csv, flush: true); - await FlutterShare.shareFile(title: L10N.of(context).tr.timeCopEntries(_dateFormat.format(DateTime.now())), filePath: localPath); - } - ), + await FlutterShare.shareFile( + title: L10N + .of(context) + .tr + .timeCopEntries(_dateFormat.format(DateTime.now())), + filePath: localPath); + }), ); } } diff --git a/lib/screens/projects/ProjectEditor.dart b/lib/screens/projects/ProjectEditor.dart index 2c85c8c3..6e70d89c 100644 --- a/lib/screens/projects/ProjectEditor.dart +++ b/lib/screens/projects/ProjectEditor.dart @@ -22,6 +22,7 @@ import 'package:timecop/models/project.dart'; class ProjectEditor extends StatefulWidget { final Project project; + ProjectEditor({Key key, @required this.project}) : super(key: key); @override @@ -49,60 +50,67 @@ class _ProjectEditorState extends State { @override Widget build(BuildContext context) { return Dialog( - child: Padding( - padding: EdgeInsets.all(16), - child: Form( - key: _formKey, - child: ListView( - shrinkWrap: true, - children: [ - Text( - widget.project == null ? L10N.of(context).tr.createNewProject : L10N.of(context).tr.editProject, - style: Theme.of(context).textTheme.title, + child: Padding( + padding: EdgeInsets.all(16), + child: Form( + key: _formKey, + child: ListView( + shrinkWrap: true, + children: [ + Text( + widget.project == null + ? L10N.of(context).tr.createNewProject + : L10N.of(context).tr.editProject, + style: Theme.of(context).textTheme.title, + ), + TextFormField( + controller: _nameController, + validator: (String value) => value.trim().isEmpty + ? L10N.of(context).tr.pleaseEnterAName + : null, + decoration: InputDecoration( + hintText: L10N.of(context).tr.projectName, ), - TextFormField( - controller: _nameController, - validator: (String value) => value.trim().isEmpty ? L10N.of(context).tr.pleaseEnterAName : null, - decoration: InputDecoration( - hintText: L10N.of(context).tr.projectName, + ), + MaterialColorPicker( + selectedColor: _colour, + shrinkWrap: true, + onColorChange: (Color colour) => _colour = colour, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + child: Text(L10N.of(context).tr.cancel), + onPressed: () => Navigator.of(context).pop(), ), - ), - MaterialColorPicker( - selectedColor: _colour, - shrinkWrap: true, - onColorChange: (Color colour) => _colour = colour, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FlatButton( - child: Text(L10N.of(context).tr.cancel), - onPressed: () => Navigator.of(context).pop(), - ), - FlatButton( - child: Text(widget.project == null ? L10N.of(context).tr.create: L10N.of(context).tr.save), - onPressed: () async { - bool valid = _formKey.currentState.validate(); - if(!valid) return; + FlatButton( + child: Text(widget.project == null + ? L10N.of(context).tr.create + : L10N.of(context).tr.save), + onPressed: () async { + bool valid = _formKey.currentState.validate(); + if (!valid) return; - final ProjectsBloc projects = BlocProvider.of(context); - assert(projects != null); - if(widget.project == null) { - projects.add(CreateProject(_nameController.text.trim(), _colour)); - } - else { - Project p = Project.clone(widget.project, name: _nameController.text.trim(), colour: _colour); - projects.add(EditProject(p)); - } - Navigator.of(context).pop(); - }, - ) - ], - ) - ], - ), + final ProjectsBloc projects = + BlocProvider.of(context); + assert(projects != null); + if (widget.project == null) { + projects.add( + CreateProject(_nameController.text.trim(), _colour)); + } else { + Project p = Project.clone(widget.project, + name: _nameController.text.trim(), colour: _colour); + projects.add(EditProject(p)); + } + Navigator.of(context).pop(); + }, + ) + ], + ) + ], ), - ) - ); + ), + )); } -} \ No newline at end of file +} diff --git a/lib/screens/projects/ProjectsScreen.dart b/lib/screens/projects/ProjectsScreen.dart index e10596d4..61d7c6a6 100644 --- a/lib/screens/projects/ProjectsScreen.dart +++ b/lib/screens/projects/ProjectsScreen.dart @@ -39,84 +39,120 @@ class ProjectsScreen extends StatelessWidget { ), body: BlocBuilder( bloc: settingsBloc, - builder: (BuildContext context, SettingsState settingsState) => BlocBuilder( - bloc: projectsBloc, - builder: (BuildContext context, ProjectsState state) { - return ListView( - children: state.projects.map((project) => Slidable( - actionPane: SlidableDrawerActionPane(), - actionExtentRatio: 0.15, - child: ListTile( - leading: ProjectColour(project: project), - title: Text(project.name), - trailing: settingsState.defaultProjectID == project.id - ? Icon(FontAwesomeIcons.thumbtack) - : null, - onTap: () => showDialog( - context: context, - builder: (BuildContext context) => ProjectEditor(project: project,) - ), - ), - actions: [ - IconSlideAction( - color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.trash, - onTap: () async { - bool delete = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(L10N.of(context).tr.confirmDelete), - content: RichText( - textAlign: TextAlign.justify, - text: TextSpan( - style: TextStyle(color: Theme.of(context).textTheme.body1.color), - children: [ - TextSpan(text: L10N.of(context).tr.areYouSureYouWantToDeleteProject + "\n\n"), - TextSpan(text: "⬤ ", style: TextStyle(color: project.colour)), - TextSpan(text: project.name, style: TextStyle(fontStyle: FontStyle.italic)), - ] - ) - ), - actions: [ - FlatButton( - child: Text(L10N.of(context).tr.cancel), - onPressed: () => Navigator.of(context).pop(false), - ), - FlatButton( - child: Text(L10N.of(context).tr.delete), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ) - ); - if(delete) { - projectsBloc.add(DeleteProject(project)); - } - }, - ) - ], - secondaryActions: [ - IconSlideAction( - color: - project.id == settingsState.defaultProjectID - ? Theme.of(context).errorColor - : Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.thumbtack, - onTap: () { - settingsBloc.add(SetDefaultProjectID( - project.id == settingsState.defaultProjectID - ? null - : project.id - )); - } - ) - ], - )).toList(), - ); - } - ), + builder: (BuildContext context, SettingsState settingsState) => + BlocBuilder( + bloc: projectsBloc, + builder: (BuildContext context, ProjectsState state) { + return ListView( + children: state.projects + .map((project) => Slidable( + actionPane: SlidableDrawerActionPane(), + actionExtentRatio: 0.15, + child: ListTile( + leading: ProjectColour(project: project), + title: Text(project.name), + trailing: + settingsState.defaultProjectID == project.id + ? Icon(FontAwesomeIcons.thumbtack) + : null, + onTap: () => showDialog( + context: context, + builder: (BuildContext context) => + ProjectEditor( + project: project, + )), + ), + actions: [ + IconSlideAction( + color: Theme.of(context).errorColor, + foregroundColor: + Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.trash, + onTap: () async { + bool delete = await showDialog( + context: context, + builder: (BuildContext context) => + AlertDialog( + title: Text(L10N + .of(context) + .tr + .confirmDelete), + content: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + style: TextStyle( + color: + Theme.of(context) + .textTheme + .body1 + .color), + children: [ + TextSpan( + text: L10N + .of(context) + .tr + .areYouSureYouWantToDeleteProject + + "\n\n"), + TextSpan( + text: "⬤ ", + style: TextStyle( + color: project + .colour)), + TextSpan( + text: project.name, + style: TextStyle( + fontStyle: + FontStyle + .italic)), + ])), + actions: [ + FlatButton( + child: Text(L10N + .of(context) + .tr + .cancel), + onPressed: () => + Navigator.of(context) + .pop(false), + ), + FlatButton( + child: Text(L10N + .of(context) + .tr + .delete), + onPressed: () => + Navigator.of(context) + .pop(true), + ), + ], + )); + if (delete) { + projectsBloc.add(DeleteProject(project)); + } + }, + ) + ], + secondaryActions: [ + IconSlideAction( + color: project.id == + settingsState.defaultProjectID + ? Theme.of(context).errorColor + : Theme.of(context).accentColor, + foregroundColor: + Theme.of(context).accentIconTheme.color, + icon: FontAwesomeIcons.thumbtack, + onTap: () { + settingsBloc.add(SetDefaultProjectID( + project.id == + settingsState.defaultProjectID + ? null + : project.id)); + }) + ], + )) + .toList(), + ); + }), ), floatingActionButton: FloatingActionButton( key: Key("addProject"), @@ -133,10 +169,11 @@ class ProjectsScreen extends StatelessWidget { ], ), onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => ProjectEditor(project: null,) - ), + context: context, + builder: (BuildContext context) => ProjectEditor( + project: null, + )), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/reports/ReportsScreen.dart b/lib/screens/reports/ReportsScreen.dart index 249385c4..92318b95 100644 --- a/lib/screens/reports/ReportsScreen.dart +++ b/lib/screens/reports/ReportsScreen.dart @@ -50,7 +50,9 @@ class _ReportsScreenState extends State { super.initState(); final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); - selectedProjects = [null].followedBy(projects.state.projects.map((p) => Project.clone(p))).toList(); + selectedProjects = [null] + .followedBy(projects.state.projects.map((p) => Project.clone(p))) + .toList(); final SettingsBloc settings = BlocProvider.of(context); _startDate = settings.getFilterStartDate(); @@ -61,7 +63,8 @@ class _ReportsScreenState extends State { setState(() { _startDate = dt; if (_endDate != null && _startDate.isAfter(_endDate)) { - _endDate = _startDate.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); + _endDate = _startDate.add( + Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } }); } @@ -82,23 +85,27 @@ class _ReportsScreenState extends State { child: Swiper( itemBuilder: (BuildContext context, int index) { switch (index) { - case 0: return ProjectBreakdown( + case 0: + return ProjectBreakdown( startDate: _startDate, endDate: _endDate, selectedProjects: selectedProjects, ); - case 1: return WeeklyTotals( + case 1: + return WeeklyTotals( startDate: _startDate, endDate: _endDate, selectedProjects: selectedProjects, ); - case 2: return WeekdayAverages( + case 2: + return WeekdayAverages( context, startDate: _startDate, endDate: _endDate, selectedProjects: selectedProjects, ); - case 3: return TimeTable( + case 3: + return TimeTable( startDate: _startDate, endDate: _endDate, selectedProjects: selectedProjects, @@ -111,19 +118,15 @@ class _ReportsScreenState extends State { builder: DotSwiperPaginationBuilder( color: Theme.of(context).disabledColor, activeColor: Theme.of(context).accentColor, - ) - ), + )), control: SwiperControl(iconPrevious: null, iconNext: null), ), ), ExpansionTile( - title: Text( - L10N.of(context).tr.filter, + title: Text(L10N.of(context).tr.filter, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), initiallyExpanded: false, children: [ Slidable( @@ -134,7 +137,9 @@ class _ReportsScreenState extends State { title: Text(L10N.of(context).tr.from), trailing: Padding( padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_startDate == null ? "—" : _dateFormat.format(_startDate)), + child: Text(_startDate == null + ? "—" + : _dateFormat.format(_startDate)), ), onTap: () async { _oldStartDate = _startDate.clone(); @@ -142,15 +147,17 @@ class _ReportsScreenState extends State { DateTime newStartDate = await DatePicker.showDatePicker( context, currentTime: _startDate, - onChanged: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), - onConfirm: (DateTime dt) => setStartDate(DateTime(dt.year, dt.month, dt.day)), + onChanged: (DateTime dt) => + setStartDate(DateTime(dt.year, dt.month, dt.day)), + onConfirm: (DateTime dt) => + setStartDate(DateTime(dt.year, dt.month, dt.day)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); // if the user cancelled, this should be null if (newStartDate == null) { @@ -161,13 +168,13 @@ class _ReportsScreenState extends State { } }, ), - secondaryActions: - _startDate == null + secondaryActions: _startDate == null ? [] : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, onTap: () { setState(() { @@ -185,7 +192,9 @@ class _ReportsScreenState extends State { title: Text(L10N.of(context).tr.to), trailing: Padding( padding: EdgeInsets.fromLTRB(0, 0, 18, 0), - child: Text(_endDate == null ? "—" : _dateFormat.format(_endDate)), + child: Text(_endDate == null + ? "—" + : _dateFormat.format(_endDate)), ), onTap: () async { _oldEndDate = _endDate.clone(); @@ -193,15 +202,19 @@ class _ReportsScreenState extends State { context, currentTime: _endDate, minTime: _startDate, - onChanged: (DateTime dt) => setState(() => _endDate = DateTime( dt.year, dt.month, dt.day, 23, 59, 59, 999)), - onConfirm: (DateTime dt) => setState(() => _endDate = DateTime( dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onChanged: (DateTime dt) => setState(() => _endDate = + DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => _endDate = + DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999)), theme: DatePickerTheme( cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); // if the user cancelled, this should be null if (newEndDate == null) { @@ -211,13 +224,13 @@ class _ReportsScreenState extends State { } }, ), - secondaryActions: - _endDate == null + secondaryActions: _endDate == null ? [] : [ IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, onTap: () { setState(() { @@ -230,13 +243,10 @@ class _ReportsScreenState extends State { ], ), ExpansionTile( - title: Text( - L10N.of(context).tr.projects, + title: Text(L10N.of(context).tr.projects, style: TextStyle( color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + fontWeight: FontWeight.w700)), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -255,7 +265,10 @@ class _ReportsScreenState extends State { child: Text("Select All"), onPressed: () { setState(() { - selectedProjects = [null].followedBy(projectsBloc.state.projects.map((p) => Project.clone(p))).toList(); + selectedProjects = [null] + .followedBy(projectsBloc.state.projects + .map((p) => Project.clone(p))) + .toList(); }); }, ), @@ -266,29 +279,33 @@ class _ReportsScreenState extends State { maxHeight: 150, ), child: ListView( - children: [null].followedBy(projectsBloc.state.projects).map( - (project) => CheckboxListTile( - secondary: ProjectColour(project: project,), - title: Text(project?.name ?? L10N.of(context).tr.noProject), - value: selectedProjects.any((p) => p?.id == project?.id), + children: [null] + .followedBy(projectsBloc.state.projects) + .map((project) => CheckboxListTile( + secondary: ProjectColour( + project: project, + ), + title: Text(project?.name ?? + L10N.of(context).tr.noProject), + value: selectedProjects + .any((p) => p?.id == project?.id), activeColor: Theme.of(context).accentColor, onChanged: (_) => setState(() { - if (selectedProjects.any((p) => p?.id == project?.id)) { - selectedProjects.removeWhere((p) => p?.id == project?.id); - } - else { + if (selectedProjects + .any((p) => p?.id == project?.id)) { + selectedProjects + .removeWhere((p) => p?.id == project?.id); + } else { selectedProjects.add(project); } }), - ) - ) + )) .toList(), ), ) ], ), ], - ) - ); + )); } -} \ No newline at end of file +} diff --git a/lib/screens/reports/components/Legend.dart b/lib/screens/reports/components/Legend.dart index be65d3d8..da946abd 100644 --- a/lib/screens/reports/components/Legend.dart +++ b/lib/screens/reports/components/Legend.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,29 +21,24 @@ class Legend extends StatelessWidget { final Iterable projects; const Legend({Key key, @required this.projects}) - : assert(projects != null), - super(key: key); + : assert(projects != null), + super(key: key); List _chips(BuildContext context) { return projects - .map( - (project) => - Chip( - avatar: ProjectColour(project: project, mini: true), - label: Text( - project?.name ?? L10N.of(context).tr.noProject, - style: TextStyle( - fontSize: Theme.of(context).textTheme.body1.fontSize * 0.75, - ) - ), - ) - ) - .toList(); + .map((project) => Chip( + avatar: ProjectColour(project: project, mini: true), + label: Text(project?.name ?? L10N.of(context).tr.noProject, + style: TextStyle( + fontSize: Theme.of(context).textTheme.body1.fontSize * 0.75, + )), + )) + .toList(); } @override Widget build(BuildContext context) { - if(projects.length <= 5) { + if (projects.length <= 5) { return Wrap( alignment: WrapAlignment.center, spacing: 4.0, @@ -59,4 +54,4 @@ class Legend extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/reports/components/ProjectBreakdown.dart b/lib/screens/reports/components/ProjectBreakdown.dart index 1a2b5832..267ba3e4 100644 --- a/lib/screens/reports/components/ProjectBreakdown.dart +++ b/lib/screens/reports/components/ProjectBreakdown.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -29,9 +29,14 @@ class ProjectBreakdown extends StatefulWidget { final DateTime startDate; final DateTime endDate; final List selectedProjects; - ProjectBreakdown({Key key, @required this.startDate, @required this.endDate, @required this.selectedProjects}) - : assert(selectedProjects != null), - super(key: key); + + ProjectBreakdown( + {Key key, + @required this.startDate, + @required this.endDate, + @required this.selectedProjects}) + : assert(selectedProjects != null), + super(key: key); @override _ProjectBreakdownState createState() => _ProjectBreakdownState(); @@ -40,29 +45,34 @@ class ProjectBreakdown extends StatefulWidget { class _ProjectBreakdownState extends State { int _touchedIndex = -1; - static LinkedHashMap calculateData(BuildContext context, DateTime startDate, DateTime endDate, List selectedProjects) { + static LinkedHashMap calculateData(BuildContext context, + DateTime startDate, DateTime endDate, List selectedProjects) { final TimersBloc timers = BlocProvider.of(context); LinkedHashMap projectHours = LinkedHashMap(); - for( - TimerEntry timer in timers.state.timers + for (TimerEntry timer in timers.state.timers .where((timer) => timer.endTime != null) .where((timer) => selectedProjects.any((p) => p?.id == timer.projectID)) - .where((timer) => startDate != null ? timer.startTime.isAfter(startDate) : true) - .where((timer) => endDate != null ? timer.startTime.isBefore(endDate) : true) - ) { + .where((timer) => + startDate != null ? timer.startTime.isAfter(startDate) : true) + .where((timer) => + endDate != null ? timer.startTime.isBefore(endDate) : true)) { projectHours.update( - timer.projectID, - (sum) => sum + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600, - ifAbsent: () => timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600 - ); + timer.projectID, + (sum) => + sum + + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / + 3600, + ifAbsent: () => + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / + 3600); } return projectHours; } @override - void initState() { + void initState() { super.initState(); _touchedIndex = -1; } @@ -71,60 +81,73 @@ class _ProjectBreakdownState extends State { Widget build(BuildContext context) { final ProjectsBloc projects = BlocProvider.of(context); - LinkedHashMap _projectHours = calculateData(context, widget.startDate, widget.endDate, widget.selectedProjects); - if(_projectHours.isEmpty) { + LinkedHashMap _projectHours = calculateData( + context, widget.startDate, widget.endDate, widget.selectedProjects); + if (_projectHours.isEmpty) { return Container(); } - final double totalHours = _projectHours.values.fold(0.0, (double sum, double v) => sum + v); - + final double totalHours = + _projectHours.values.fold(0.0, (double sum, double v) => sum + v); + return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: AspectRatio( - key: Key("projectBreakdown"), - aspectRatio: 1, - child: PieChart( - PieChartData( - borderData: FlBorderData( - show: false, - ), - pieTouchData: PieTouchData(touchCallback: (pieTouchResponse) { - setState(() { - if (pieTouchResponse.touchInput is FlLongPressEnd || - pieTouchResponse.touchInput is FlPanEnd) { - _touchedIndex = -1; - } else { - _touchedIndex = pieTouchResponse.touchedSectionIndex; - } - }); - }), - sections: List.generate(_projectHours.length, (int index) { - MapEntry entry = _projectHours.entries.elementAt(index); - Project project = projects.state.projects.firstWhere((project) => project?.id == entry.key, orElse: () => null); - return PieChartSectionData( - value: entry.value, - color: project?.colour ?? Theme.of(context).disabledColor, - title: _touchedIndex == index ? L10N.of(context).tr.nHours(entry.value.toStringAsFixed(1)) + "\n(${(100.0 * entry.value / totalHours).toStringAsFixed(0)} %)" : "", - titleStyle: Theme.of(context).textTheme.body1, - radius: _touchedIndex == index ? 80 : 60, - ); - }) - ) + padding: EdgeInsets.fromLTRB(16, 16, 16, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: AspectRatio( + key: Key("projectBreakdown"), + aspectRatio: 1, + child: PieChart(PieChartData( + borderData: FlBorderData( + show: false, + ), + pieTouchData: + PieTouchData(touchCallback: (pieTouchResponse) { + setState(() { + if (pieTouchResponse.touchInput is FlLongPressEnd || + pieTouchResponse.touchInput is FlPanEnd) { + _touchedIndex = -1; + } else { + _touchedIndex = pieTouchResponse.touchedSectionIndex; + } + }); + }), + sections: List.generate(_projectHours.length, (int index) { + MapEntry entry = + _projectHours.entries.elementAt(index); + Project project = projects.state.projects.firstWhere( + (project) => project?.id == entry.key, + orElse: () => null); + return PieChartSectionData( + value: entry.value, + color: + project?.colour ?? Theme.of(context).disabledColor, + title: _touchedIndex == index + ? L10N + .of(context) + .tr + .nHours(entry.value.toStringAsFixed(1)) + + "\n(${(100.0 * entry.value / totalHours).toStringAsFixed(0)} %)" + : "", + titleStyle: Theme.of(context).textTheme.body1, + radius: _touchedIndex == index ? 80 : 60, + ); + }))), ), ), - ), - Container(height: 16,), - Text(L10N.of(context).tr.totalProjectShare, style: Theme.of(context).textTheme.title, textAlign: TextAlign.center,), - Legend( - projects: widget - .selectedProjects - .where((project) => _projectHours.keys.any((id) => project?.id == id)) - ), - ], - ) - ); + Container( + height: 16, + ), + Text( + L10N.of(context).tr.totalProjectShare, + style: Theme.of(context).textTheme.title, + textAlign: TextAlign.center, + ), + Legend( + projects: widget.selectedProjects.where((project) => + _projectHours.keys.any((id) => project?.id == id))), + ], + )); } } diff --git a/lib/screens/reports/components/TimeTable.dart b/lib/screens/reports/components/TimeTable.dart index aa951a04..66dbdf35 100644 --- a/lib/screens/reports/components/TimeTable.dart +++ b/lib/screens/reports/components/TimeTable.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -27,7 +27,13 @@ class TimeTable extends StatelessWidget { final DateTime startDate; final DateTime endDate; final List selectedProjects; - const TimeTable({Key key, @required this.startDate, @required this.endDate, @required this.selectedProjects}) : super(key: key); + + const TimeTable( + {Key key, + @required this.startDate, + @required this.endDate, + @required this.selectedProjects}) + : super(key: key); @override Widget build(BuildContext context) { @@ -35,20 +41,25 @@ class TimeTable extends StatelessWidget { final TimersBloc timers = BlocProvider.of(context); LinkedHashMap projectHours = LinkedHashMap(); - for( - TimerEntry timer in timers.state.timers + for (TimerEntry timer in timers.state.timers .where((timer) => timer.endTime != null) .where((timer) => selectedProjects.any((p) => p?.id == timer.projectID)) - .where((timer) => startDate != null ? timer.startTime.isAfter(startDate) : true) - .where((timer) => endDate != null ? timer.startTime.isBefore(endDate) : true) - ) { + .where((timer) => + startDate != null ? timer.startTime.isAfter(startDate) : true) + .where((timer) => + endDate != null ? timer.startTime.isBefore(endDate) : true)) { projectHours.update( - timer.projectID, - (sum) => sum + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600, - ifAbsent: () => timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600 - ); + timer.projectID, + (sum) => + sum + + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / + 3600, + ifAbsent: () => + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / + 3600); } - final double totalHours = projectHours.values.fold(0.0, (double sum, double v) => sum + v); + final double totalHours = + projectHours.values.fold(0.0, (double sum, double v) => sum + v); return Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 40), @@ -61,88 +72,75 @@ class TimeTable extends StatelessWidget { children: [ Expanded( flex: 3, - child: Text( - L10N.of(context).tr.project, - style: Theme.of(context).textTheme.title - ), + child: Text(L10N.of(context).tr.project, + style: Theme.of(context).textTheme.title), ), Expanded( flex: 1, - child: Text( - L10N.of(context).tr.hours, - textAlign: TextAlign.right, - style: Theme.of(context).textTheme.title - ), + child: Text(L10N.of(context).tr.hours, + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.title), ), ], ), - Divider(thickness: 2.0, color: Theme.of(context).textTheme.body1.color), - ] - .followedBy( - projectHours - .entries - .map((MapEntry entry) { - Project project = projects.state.projects.firstWhere((project) => project?.id == entry.key, orElse: () => null); - return Padding( - padding: EdgeInsets.only(top: 4.0, bottom: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - flex: 3, - child: - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ProjectColour(mini: true, project: project,), - Container(width: 4), - Text( - project?.name ?? L10N.of(context).tr.noProject, - style: Theme.of(context).textTheme.body1 - ) - ], - ), + Divider( + thickness: 2.0, color: Theme.of(context).textTheme.body1.color), + ].followedBy(projectHours.entries.map((MapEntry entry) { + Project project = projects.state.projects.firstWhere( + (project) => project?.id == entry.key, + orElse: () => null); + return Padding( + padding: EdgeInsets.only(top: 4.0, bottom: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ProjectColour( + mini: true, + project: project, + ), + Container(width: 4), + Text(project?.name ?? L10N.of(context).tr.noProject, + style: Theme.of(context).textTheme.body1) + ], ), - Expanded( - flex: 1, - child: Text( - entry.value.toStringAsFixed(1), + ), + Expanded( + flex: 1, + child: Text(entry.value.toStringAsFixed(1), textAlign: TextAlign.right, - style: Theme.of(context).textTheme.body1 - ), - ), - ], - ) - ); - }) - ) - .followedBy([ + style: Theme.of(context).textTheme.body1), + ), + ], + )); + })).followedBy([ projectHours.isEmpty - ? Container() - : Divider(thickness: 1.0, color: Theme.of(context).textTheme.body1.color), + ? Container() + : Divider( + thickness: 1.0, + color: Theme.of(context).textTheme.body1.color), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( flex: 3, - child: Text( - L10N.of(context).tr.total, - style: Theme.of(context).textTheme.body1 - ), + child: Text(L10N.of(context).tr.total, + style: Theme.of(context).textTheme.body1), ), Expanded( flex: 1, - child: Text( - totalHours.toStringAsFixed(1), - textAlign: TextAlign.right, - style: Theme.of(context).textTheme.body1 - ), + child: Text(totalHours.toStringAsFixed(1), + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.body1), ), ], ) - ]) - .toList(), + ]).toList(), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/reports/components/WeekdayAverages.dart b/lib/screens/reports/components/WeekdayAverages.dart index a729e8ef..6ab0fb77 100644 --- a/lib/screens/reports/components/WeekdayAverages.dart +++ b/lib/screens/reports/components/WeekdayAverages.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -13,6 +13,7 @@ // limitations under the License. import 'dart:collection'; +import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; @@ -21,10 +22,8 @@ import 'package:timecop/blocs/projects/bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; -import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/start_of_week.dart'; -import 'dart:math'; - +import 'package:timecop/models/timer_entry.dart'; import 'Legend.dart'; class WeekdayAverages extends StatelessWidget { @@ -33,33 +32,40 @@ class WeekdayAverages extends StatelessWidget { final List selectedProjects; final LinkedHashMap> _daysData; - static LinkedHashMap> calculateData(BuildContext context, DateTime startDate, DateTime endDate, List selectedProjects) { + static LinkedHashMap> calculateData( + BuildContext context, + DateTime startDate, + DateTime endDate, + List selectedProjects) { final TimersBloc timers = BlocProvider.of(context); DateTime firstDate = DateTime.now(); DateTime lastDate = DateTime(1970); LinkedHashMap> daySums = LinkedHashMap(); - for(int i = 0; i < 7; i++) { + for (int i = 0; i < 7; i++) { daySums.putIfAbsent(i, () => LinkedHashMap()); } - for( - TimerEntry timer in timers.state.timers + for (TimerEntry timer in timers.state.timers .where((timer) => timer.endTime != null) .where((timer) => selectedProjects.any((p) => p?.id == timer.projectID)) - .where((timer) => startDate != null ? timer.startTime.isAfter(startDate) : true) - .where((timer) => endDate != null ? timer.startTime.isBefore(endDate) : true) - ) { - double hours = timer.endTime.difference(timer.startTime).inSeconds.toDouble() / 3600.0; + .where((timer) => + startDate != null ? timer.startTime.isAfter(startDate) : true) + .where((timer) => + endDate != null ? timer.startTime.isBefore(endDate) : true)) { + double hours = + timer.endTime.difference(timer.startTime).inSeconds.toDouble() / + 3600.0; int weekday = timer.startTime.weekday; - if(weekday == 7) weekday = 0; + if (weekday == 7) weekday = 0; - daySums[weekday].update(timer.projectID, (double sum) => sum + hours, ifAbsent: () => hours); + daySums[weekday].update(timer.projectID, (double sum) => sum + hours, + ifAbsent: () => hours); - if(timer.startTime.isBefore(firstDate)) { + if (timer.startTime.isBefore(firstDate)) { firstDate = timer.startTime; } - if(timer.startTime.isAfter(lastDate)) { + if (timer.startTime.isAfter(lastDate)) { lastDate = timer.startTime; } } @@ -69,11 +75,11 @@ class WeekdayAverages extends StatelessWidget { lastDate = lastDate.startOfWeek().add(Duration(days: 7)); int totalDays = DateTime(lastDate.year, lastDate.month, lastDate.day) - .difference(DateTime(firstDate.year, firstDate.month, firstDate.day)) - .inDays; + .difference(DateTime(firstDate.year, firstDate.month, firstDate.day)) + .inDays; double totalWeeks = totalDays.toDouble() / 7.0; - if(totalDays > 0) { - for(int i = 0; i < 7; i++) { + if (totalDays > 0) { + for (int i = 0; i < 7; i++) { daySums[i].updateAll((int _project, double sum) => sum / totalWeeks); } } @@ -81,157 +87,145 @@ class WeekdayAverages extends StatelessWidget { return daySums; } - WeekdayAverages(BuildContext context, {Key key, @required this.startDate, @required this.endDate, @required this.selectedProjects}) - : assert(selectedProjects != null), - this._daysData = calculateData(context, startDate, endDate, selectedProjects), - super(key: key); + WeekdayAverages(BuildContext context, + {Key key, + @required this.startDate, + @required this.endDate, + @required this.selectedProjects}) + : assert(selectedProjects != null), + this._daysData = + calculateData(context, startDate, endDate, selectedProjects), + super(key: key); @override Widget build(BuildContext context) { final ProjectsBloc projects = BlocProvider.of(context); - double maxY = _daysData - .values - .fold(0.0, (osum, day) => - max( - osum, - day - .values - .fold(0.0, (isum, v) => max(isum, v)) - ) - ); + double maxY = _daysData.values.fold( + 0.0, + (osum, day) => + max(osum, day.values.fold(0.0, (isum, v) => max(isum, v)))); maxY = ((maxY ~/ 2) + 1) * 2.0 + 2.0; - + return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - key: Key("weekdayAverages"), - child: BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: maxY, - barTouchData: BarTouchData( - enabled: true, - touchTooltipData: BarTouchTooltipData( - fitInsideTheChart: true, - tooltipBgColor: Theme.of(context).cardColor, - getTooltipItem: ( - BarChartGroupData group, - int groupIndex, - BarChartRodData rod, - int rodIndex) - => BarTooltipItem( - L10N.of(context).tr.nHours(rod.y.toStringAsFixed(1)), - Theme.of(context).textTheme.body1 - ) - ) - ), - borderData: FlBorderData( - show: false, - ), - gridData: FlGridData( - show: true, - ), - titlesData: FlTitlesData( - show: true, - leftTitles: SideTitles( - showTitles: true, - textStyle: Theme.of(context).textTheme.body1, + padding: EdgeInsets.fromLTRB(16, 16, 16, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + key: Key("weekdayAverages"), + child: BarChart(BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: maxY, + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + fitInsideTheChart: true, + tooltipBgColor: Theme.of(context).cardColor, + getTooltipItem: (BarChartGroupData group, + int groupIndex, + BarChartRodData rod, + int rodIndex) => + BarTooltipItem( + L10N + .of(context) + .tr + .nHours(rod.y.toStringAsFixed(1)), + Theme.of(context).textTheme.body1))), + borderData: FlBorderData( + show: false, + ), + gridData: FlGridData( + show: true, ), - bottomTitles: SideTitles( - showTitles: true, - textStyle: Theme.of(context).textTheme.body1, - getTitles: (double value) { - switch (value.toInt()) { - case 0: - return 'S'; - case 1: - return 'M'; - case 2: - return 'T'; - case 3: - return 'W'; - case 4: - return 'T'; - case 5: - return 'F'; - case 6: - return 'S'; - default: - return ''; - } - } - ) - ), - barGroups: List.generate(7, (i) => i) - .map((day) => BarChartGroupData( - x: day, - barRods: [ - BarChartRodData( - color: Theme.of(context).disabledColor, - width: 22, - y: _daysData[day].entries.fold(0.0, (double sum, MapEntry entry) => sum + entry.value), - rodStackItem: _buildDayStack(context, day, projects), - //backDrawRodData: BackgroundBarChartRodData( - // color: Theme.of(context).chipTheme.backgroundColor, - // show: true, - // y: maxY, - //) - ) - ] - )) - .toList() - ) + titlesData: FlTitlesData( + show: true, + leftTitles: SideTitles( + showTitles: true, + textStyle: Theme.of(context).textTheme.body1, + ), + bottomTitles: SideTitles( + showTitles: true, + textStyle: Theme.of(context).textTheme.body1, + getTitles: (double value) { + switch (value.toInt()) { + case 0: + return 'S'; + case 1: + return 'M'; + case 2: + return 'T'; + case 3: + return 'W'; + case 4: + return 'T'; + case 5: + return 'F'; + case 6: + return 'S'; + default: + return ''; + } + })), + barGroups: List.generate(7, (i) => i) + .map((day) => + BarChartGroupData(x: day, barRods: [ + BarChartRodData( + color: Theme.of(context).disabledColor, + width: 22, + y: _daysData[day].entries.fold( + 0.0, + (double sum, MapEntry entry) => + sum + entry.value), + rodStackItem: + _buildDayStack(context, day, projects), + //backDrawRodData: BackgroundBarChartRodData( + // color: Theme.of(context).chipTheme.backgroundColor, + // show: true, + // y: maxY, + //) + ) + ])) + .toList())), + ), + Container( + height: 16, ), - ), - Container(height: 16,), - Text(L10N.of(context).tr.averageDailyHours, style: Theme.of(context).textTheme.title, textAlign: TextAlign.center,), - Legend( - projects: selectedProjects - .where((project) => _daysData - .entries - .any((MapEntry> derp) => - derp.value.keys.any((id) => project?.id == id) - ) - ) - ), - ], - ) - ); + Text( + L10N.of(context).tr.averageDailyHours, + style: Theme.of(context).textTheme.title, + textAlign: TextAlign.center, + ), + Legend( + projects: selectedProjects.where((project) => _daysData.entries + .any((MapEntry> derp) => + derp.value.keys.any((id) => project?.id == id)))), + ], + )); } - List _buildDayStack(BuildContext context, int day, ProjectsBloc projects) { + List _buildDayStack( + BuildContext context, int day, ProjectsBloc projects) { double runningY = 0; // sort the stack from largest to smallest - List> derp = - _daysData[day] - .entries - .map((entry) { - Project project = projects.state.projects.firstWhere((project) => project.id == entry.key, orElse: () => null); - return [project, entry.value]; - }) - .toList(); + List> derp = _daysData[day].entries.map((entry) { + Project project = projects.state.projects + .firstWhere((project) => project.id == entry.key, orElse: () => null); + return [project, entry.value]; + }).toList(); derp.sort((a, b) { double va = a[1] as double; double vb = b[1] as double; return vb.compareTo(va); }); - - List stack = derp - .map((entry) { - Project project = entry[0] as Project; - double value = entry[1] as double; - return BarChartRodStackItem( - runningY, - runningY += value, - project?.colour ?? Theme.of(context).disabledColor - ); - }) - .toList(); + + List stack = derp.map((entry) { + Project project = entry[0] as Project; + double value = entry[1] as double; + return BarChartRodStackItem(runningY, runningY += value, + project?.colour ?? Theme.of(context).disabledColor); + }).toList(); return stack; } -} \ No newline at end of file +} diff --git a/lib/screens/reports/components/WeeklyTotals.dart b/lib/screens/reports/components/WeeklyTotals.dart index 557d5dc6..6f703324 100644 --- a/lib/screens/reports/components/WeeklyTotals.dart +++ b/lib/screens/reports/components/WeeklyTotals.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,8 +23,8 @@ import 'package:timecop/blocs/projects/projects_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; -import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/start_of_week.dart'; +import 'package:timecop/models/timer_entry.dart'; import 'Legend.dart'; @@ -32,9 +32,14 @@ class WeeklyTotals extends StatefulWidget { final DateTime startDate; final DateTime endDate; final List selectedProjects; - WeeklyTotals({Key key, @required this.startDate, @required this.endDate, @required this.selectedProjects}) - : assert(selectedProjects != null), - super(key: key); + + WeeklyTotals( + {Key key, + @required this.startDate, + @required this.endDate, + @required this.selectedProjects}) + : assert(selectedProjects != null), + super(key: key); @override _WeeklyTotalsState createState() => _WeeklyTotalsState(); @@ -43,149 +48,157 @@ class WeeklyTotals extends StatefulWidget { class _WeeklyTotalsState extends State { static DateFormat _dateFormat = DateFormat.MMMd(); - static LinkedHashMap> calculateData(BuildContext context, DateTime startDate, DateTime endDate, List selectedProjects) { + static LinkedHashMap> calculateData( + BuildContext context, + DateTime startDate, + DateTime endDate, + List selectedProjects) { final TimersBloc timers = BlocProvider.of(context); DateTime firstDate = startDate; - if(firstDate == null) { - firstDate = timers.state.timers.map((timer) => timer.startTime).fold(DateTime.now(), (DateTime a, DateTime b) => a.isBefore(b) ? a : b); + if (firstDate == null) { + firstDate = timers.state.timers.map((timer) => timer.startTime).fold( + DateTime.now(), (DateTime a, DateTime b) => a.isBefore(b) ? a : b); } firstDate = firstDate.startOfWeek(); - LinkedHashMap> projectWeeklyHours = LinkedHashMap(); - for( - TimerEntry timer in timers.state.timers + LinkedHashMap> projectWeeklyHours = + LinkedHashMap(); + for (TimerEntry timer in timers.state.timers .where((timer) => timer.endTime != null) .where((timer) => selectedProjects.any((p) => p?.id == timer.projectID)) - .where((timer) => startDate != null ? timer.startTime.isAfter(startDate) : true) - .where((timer) => endDate != null ? timer.startTime.isBefore(endDate) : true) - ) { - LinkedHashMap weeklyHours = projectWeeklyHours.putIfAbsent(timer.projectID, () => LinkedHashMap()); - + .where((timer) => + startDate != null ? timer.startTime.isAfter(startDate) : true) + .where((timer) => + endDate != null ? timer.startTime.isBefore(endDate) : true)) { + LinkedHashMap weeklyHours = projectWeeklyHours.putIfAbsent( + timer.projectID, () => LinkedHashMap()); + // calculate the week int week = timer.startTime.difference(firstDate).inDays ~/ 7; double hours = timer.endTime.difference(timer.startTime).inSeconds / 3600; - weeklyHours.update(week, (double oldHours) => oldHours + hours, ifAbsent: () => hours); + weeklyHours.update(week, (double oldHours) => oldHours + hours, + ifAbsent: () => hours); } return projectWeeklyHours; } - + @override Widget build(BuildContext context) { final ProjectsBloc projects = BlocProvider.of(context); DateTime firstDate = widget.startDate; - if(firstDate == null) { + if (firstDate == null) { final TimersBloc timers = BlocProvider.of(context); - firstDate = timers.state.timers.map((timer) => timer.startTime).fold(DateTime.now(), (DateTime a, DateTime b) => a.isBefore(b) ? a : b); + firstDate = timers.state.timers.map((timer) => timer.startTime).fold( + DateTime.now(), (DateTime a, DateTime b) => a.isBefore(b) ? a : b); } firstDate = firstDate.startOfWeek(); - LinkedHashMap> _projectWeeklyHours = calculateData(context, widget.startDate, widget.endDate, widget.selectedProjects); - double maxY = _projectWeeklyHours - .values - .fold(0, (double omax, LinkedHashMap weeks) => - max(omax, weeks.values.fold(0, (double omax, double v) => max(omax, v))) - ); + LinkedHashMap> _projectWeeklyHours = + calculateData( + context, widget.startDate, widget.endDate, widget.selectedProjects); + double maxY = _projectWeeklyHours.values.fold( + 0, + (double omax, LinkedHashMap weeks) => max(omax, + weeks.values.fold(0, (double omax, double v) => max(omax, v)))); maxY = ((maxY ~/ 5) + 1) * 5.0 + 5.0; return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - key: Key("weeklyTotals"), - child: LineChart( - LineChartData( - minY: 0, - maxY: maxY, - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( - color: Theme.of(context).textTheme.body1.color, - ), - left: BorderSide( - color: Theme.of(context).textTheme.body1.color, - ), - ) - ), - gridData: FlGridData( - show: true, - horizontalInterval: 5.0, - ), - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - fitInsideTheChart: true, - tooltipBgColor: Theme.of(context).cardColor, - getTooltipItems: (List spots) { - return spots - .map((LineBarSpot spot) { - return LineTooltipItem( - L10N.of(context).tr.nHours(spot.y.toStringAsFixed(1)), - TextStyle( - color: spot.bar.colors[0], - fontSize: Theme.of(context).textTheme.body1.fontSize, - ) - ); - }) - .toList(); - } - ) - ), - titlesData: FlTitlesData( - show: true, - leftTitles: SideTitles( - showTitles: true, - getTitles: (double v) => v.toStringAsFixed(1), - textStyle: Theme.of(context).textTheme.caption, - interval: 5.0, + padding: EdgeInsets.fromLTRB(16, 16, 16, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + key: Key("weeklyTotals"), + child: LineChart(LineChartData( + minY: 0, + maxY: maxY, + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).textTheme.body1.color, + ), + left: BorderSide( + color: Theme.of(context).textTheme.body1.color, + ), + )), + gridData: FlGridData( + show: true, + horizontalInterval: 5.0, ), - bottomTitles: SideTitles( - showTitles: true, - textStyle: Theme.of(context).textTheme.caption, - getTitles: (double dweek) { - int week = dweek.toInt(); - DateTime date = firstDate.add(Duration(days: week * 7)); - return _dateFormat.format(date).replaceAll(' ', '\n'); - } + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + fitInsideTheChart: true, + tooltipBgColor: Theme.of(context).cardColor, + getTooltipItems: (List spots) { + return spots.map((LineBarSpot spot) { + return LineTooltipItem( + L10N + .of(context) + .tr + .nHours(spot.y.toStringAsFixed(1)), + TextStyle( + color: spot.bar.colors[0], + fontSize: Theme.of(context) + .textTheme + .body1 + .fontSize, + )); + }).toList(); + })), + titlesData: FlTitlesData( + show: true, + leftTitles: SideTitles( + showTitles: true, + getTitles: (double v) => v.toStringAsFixed(1), + textStyle: Theme.of(context).textTheme.caption, + interval: 5.0, + ), + bottomTitles: SideTitles( + showTitles: true, + textStyle: Theme.of(context).textTheme.caption, + getTitles: (double dweek) { + int week = dweek.toInt(); + DateTime date = + firstDate.add(Duration(days: week * 7)); + return _dateFormat.format(date).replaceAll(' ', '\n'); + }), ), - ), - lineBarsData: _projectWeeklyHours - .entries - .map((entry) { - Project project = projects.state.projects.firstWhere((project) => project.id == entry.key, orElse: () => null); + lineBarsData: _projectWeeklyHours.entries.map((entry) { + Project project = projects.state.projects.firstWhere( + (project) => project.id == entry.key, + orElse: () => null); return LineChartBarData( - colors: [project?.colour ?? Theme.of(context).disabledColor], - isCurved: true, - barWidth: 4, - dotData: FlDotData( - dotColor: project?.colour ?? Theme.of(context).disabledColor, - ), - spots: entry - .value - .entries - .map((dataPoint) { - return FlSpot(dataPoint.key.toDouble(), dataPoint.value); - }) - .toList() - ); - }) - .toList() - ) + colors: [ + project?.colour ?? Theme.of(context).disabledColor + ], + isCurved: true, + barWidth: 4, + dotData: FlDotData( + dotColor: project?.colour ?? + Theme.of(context).disabledColor, + ), + spots: entry.value.entries.map((dataPoint) { + return FlSpot( + dataPoint.key.toDouble(), dataPoint.value); + }).toList()); + }).toList())), + ), + Container( + height: 16, ), - ), - Container(height: 16,), - Text(L10N.of(context).tr.weeklyHours, style: Theme.of(context).textTheme.title, textAlign: TextAlign.center,), - Legend( - projects: widget - .selectedProjects - .where((project) => _projectWeeklyHours.keys.any((id) => project?.id == id)) - ), - ], - ) - ); + Text( + L10N.of(context).tr.weeklyHours, + style: Theme.of(context).textTheme.title, + textAlign: TextAlign.center, + ), + Legend( + projects: widget.selectedProjects.where((project) => + _projectWeeklyHours.keys.any((id) => project?.id == id))), + ], + )); } -} \ No newline at end of file +} diff --git a/lib/screens/settings/SettingsScreen.dart b/lib/screens/settings/SettingsScreen.dart index 1b8bd34b..a77efe33 100644 --- a/lib/screens/settings/SettingsScreen.dart +++ b/lib/screens/settings/SettingsScreen.dart @@ -49,7 +49,8 @@ class SettingsScreen extends StatelessWidget { SwitchListTile( title: Text(L10N.of(context).tr.groupTimers), value: settings.groupTimers, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(groupTimers: value)), + onChanged: (bool value) => + settingsBloc.add(SetBoolValueEvent(groupTimers: value)), activeColor: Theme.of(context).accentColor, ), ), @@ -59,7 +60,8 @@ class SettingsScreen extends StatelessWidget { SwitchListTile( title: Text(L10N.of(context).tr.collapseDays), value: settings.collapseDays, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(collapseDays: value)), + onChanged: (bool value) => + settingsBloc.add(SetBoolValueEvent(collapseDays: value)), activeColor: Theme.of(context).accentColor, ), ), @@ -69,7 +71,8 @@ class SettingsScreen extends StatelessWidget { SwitchListTile( title: Text(L10N.of(context).tr.autocompleteDescription), value: settings.autocompleteDescription, - onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(autocompleteDescription: value)), + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(autocompleteDescription: value)), activeColor: Theme.of(context).accentColor, ), ), @@ -107,7 +110,6 @@ class SettingsScreen extends StatelessWidget { ), ), ], - ) - ); + )); } -} \ No newline at end of file +} diff --git a/lib/screens/settings/components/locale_options.dart b/lib/screens/settings/components/locale_options.dart index d4b6cdc6..519aa327 100644 --- a/lib/screens/settings/components/locale_options.dart +++ b/lib/screens/settings/components/locale_options.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,73 +20,71 @@ import 'package:timecop/l10n.dart'; class LocaleOptions extends StatelessWidget { final LocaleBloc bloc; + const LocaleOptions({Key key, @required this.bloc}) - : assert(bloc != null), - super(key: key); + : assert(bloc != null), + super(key: key); @override Widget build(BuildContext context) { return BlocBuilder( - bloc: bloc, - builder: (BuildContext context, LocaleState state) { - return ListTile( - title: Text(L10N.of(context).tr.language), - subtitle: Text(L10N.of(context).tr.automaticLanguage), - trailing: Icon(L10N.of(context).rtl ? FontAwesomeIcons.chevronLeft : FontAwesomeIcons.chevronRight), - leading: Icon(FontAwesomeIcons.language), - onTap: () async { - Locale newLocale = await showModalBottomSheet( - context: context, - builder: (context) => ListView( - shrinkWrap: true, - children: [ - RadioListTile( - title: Text(L10N.of(context).tr.automaticLanguage), - value: null, - groupValue: state.locale, - onChanged: (Locale type) { - bloc.add(ChangeLocaleEvent(type)); - Navigator.pop(context, type); - }, - ), - ] - .followedBy( - [ - const Locale('ar'), - const Locale('de'), - const Locale('en'), - const Locale('es'), - const Locale('fr'), - const Locale('hi'), - const Locale('id'), - const Locale('it'), - const Locale('ja'), - const Locale('ko'), - const Locale('pt'), - const Locale('ru'), - const Locale('zh', 'CN'), - const Locale('zh', 'TW'), - ] - .map((l) => - RadioListTile( - title: Text(L10N.of(context).tr.langName(l)), - value: l, - groupValue: state.locale, - onChanged: (Locale type) { - bloc.add(ChangeLocaleEvent(type)); - Navigator.pop(context, type); - }, - ), - ) - ) - .toList(), - ) - ); + bloc: bloc, + builder: (BuildContext context, LocaleState state) { + return ListTile( + title: Text(L10N.of(context).tr.language), + subtitle: Text(L10N.of(context).tr.automaticLanguage), + trailing: Icon(L10N.of(context).rtl + ? FontAwesomeIcons.chevronLeft + : FontAwesomeIcons.chevronRight), + leading: Icon(FontAwesomeIcons.language), + onTap: () async { + Locale newLocale = await showModalBottomSheet( + context: context, + builder: (context) => ListView( + shrinkWrap: true, + children: [ + RadioListTile( + title: Text(L10N.of(context).tr.automaticLanguage), + value: null, + groupValue: state.locale, + onChanged: (Locale type) { + bloc.add(ChangeLocaleEvent(type)); + Navigator.pop(context, type); + }, + ), + ] + .followedBy([ + const Locale('ar'), + const Locale('de'), + const Locale('en'), + const Locale('es'), + const Locale('fr'), + const Locale('hi'), + const Locale('id'), + const Locale('it'), + const Locale('ja'), + const Locale('ko'), + const Locale('pt'), + const Locale('ru'), + const Locale('zh', 'CN'), + const Locale('zh', 'TW'), + ].map( + (l) => RadioListTile( + title: Text(L10N.of(context).tr.langName(l)), + value: l, + groupValue: state.locale, + onChanged: (Locale type) { + bloc.add(ChangeLocaleEvent(type)); + Navigator.pop(context, type); + }, + ), + )) + .toList(), + )); - bloc.add(ChangeLocaleEvent(newLocale)); - }, - ); - } - ); + bloc.add(ChangeLocaleEvent(newLocale)); + }, + ); + }); } -} \ No newline at end of file +} diff --git a/lib/screens/settings/components/theme_options.dart b/lib/screens/settings/components/theme_options.dart index e1051a2d..45e07285 100644 --- a/lib/screens/settings/components/theme_options.dart +++ b/lib/screens/settings/components/theme_options.dart @@ -1,11 +1,11 @@ // Copyright 2020 Kenton Hamaluik -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,64 +21,69 @@ import 'package:timecop/models/theme_type.dart'; class ThemeOptions extends StatelessWidget { final ThemeBloc bloc; + const ThemeOptions({Key key, @required this.bloc}) - : assert(bloc != null), - super(key: key); + : assert(bloc != null), + super(key: key); @override Widget build(BuildContext context) { return BlocBuilder( - bloc: bloc, - builder: (BuildContext context, ThemeState state) { - return ListTile( - key: Key("themeOption"), - title: Text(L10N.of(context).tr.theme), - subtitle: Text(state.theme.display(context)), - trailing: Icon(L10N.of(context).rtl ? FontAwesomeIcons.chevronLeft : FontAwesomeIcons.chevronRight), - leading: Icon(FontAwesomeIcons.palette), - onTap: () async { - ThemeType oldTheme = state.theme; - ThemeType newTheme = await showModalBottomSheet( - context: context, - builder: (context) => ListView( - shrinkWrap: true, - children: [ - RadioListTile( - key: Key("themeAuto"), - title: Text(L10N.of(context).tr.auto), - value: ThemeType.auto, - groupValue: state.theme, - onChanged: (ThemeType type) => Navigator.pop(context, type), - ), - RadioListTile( - key: Key("themeLight"), - title: Text(L10N.of(context).tr.light), - value: ThemeType.light, - groupValue: state.theme, - onChanged: (ThemeType type) => Navigator.pop(context, type), - ), - RadioListTile( - key: Key("themeDark"), - title: Text(L10N.of(context).tr.dark), - value: ThemeType.dark, - groupValue: state.theme, - onChanged: (ThemeType type) => Navigator.pop(context, type), - ), - RadioListTile( - key: Key("themeBlack"), - title: Text(L10N.of(context).tr.black), - value: ThemeType.black, - groupValue: state.theme, - onChanged: (ThemeType type) => Navigator.pop(context, type), - ), - ], - ) - ); + bloc: bloc, + builder: (BuildContext context, ThemeState state) { + return ListTile( + key: Key("themeOption"), + title: Text(L10N.of(context).tr.theme), + subtitle: Text(state.theme.display(context)), + trailing: Icon(L10N.of(context).rtl + ? FontAwesomeIcons.chevronLeft + : FontAwesomeIcons.chevronRight), + leading: Icon(FontAwesomeIcons.palette), + onTap: () async { + ThemeType oldTheme = state.theme; + ThemeType newTheme = await showModalBottomSheet( + context: context, + builder: (context) => ListView( + shrinkWrap: true, + children: [ + RadioListTile( + key: Key("themeAuto"), + title: Text(L10N.of(context).tr.auto), + value: ThemeType.auto, + groupValue: state.theme, + onChanged: (ThemeType type) => + Navigator.pop(context, type), + ), + RadioListTile( + key: Key("themeLight"), + title: Text(L10N.of(context).tr.light), + value: ThemeType.light, + groupValue: state.theme, + onChanged: (ThemeType type) => + Navigator.pop(context, type), + ), + RadioListTile( + key: Key("themeDark"), + title: Text(L10N.of(context).tr.dark), + value: ThemeType.dark, + groupValue: state.theme, + onChanged: (ThemeType type) => + Navigator.pop(context, type), + ), + RadioListTile( + key: Key("themeBlack"), + title: Text(L10N.of(context).tr.black), + value: ThemeType.black, + groupValue: state.theme, + onChanged: (ThemeType type) => + Navigator.pop(context, type), + ), + ], + )); - bloc.add(ChangeThemeEvent(newTheme ?? oldTheme)); - }, - ); - } - ); + bloc.add(ChangeThemeEvent(newTheme ?? oldTheme)); + }, + ); + }); } -} \ No newline at end of file +} diff --git a/lib/screens/timer/TimerEditor.dart b/lib/screens/timer/TimerEditor.dart index ce842168..5c45cf61 100644 --- a/lib/screens/timer/TimerEditor.dart +++ b/lib/screens/timer/TimerEditor.dart @@ -22,19 +22,20 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:timecop/blocs/projects/bloc.dart'; -import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/components/WorkTypeBadge.dart'; import 'package:timecop/l10n.dart'; -import 'package:timecop/models/project.dart'; import 'package:timecop/models/WorkType.dart'; -import 'package:timecop/models/timer_entry.dart'; import 'package:timecop/models/clone_time.dart'; +import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.dart'; class TimerEditor extends StatefulWidget { final TimerEntry timer; + TimerEditor({Key key, @required this.timer}) : assert(timer != null), super(key: key); @@ -64,14 +65,18 @@ class _TimerEditorState extends State { @override void initState() { super.initState(); - _descriptionController = TextEditingController(text: widget.timer.description); + _descriptionController = + TextEditingController(text: widget.timer.description); _startTime = widget.timer.startTime; _endTime = widget.timer.endTime; - _project = BlocProvider.of(context).getProjectByID(widget.timer.projectID); - _workType = BlocProvider.of(context).getWorkTypeByID(widget.timer.workTypeID); + _project = BlocProvider.of(context) + .getProjectByID(widget.timer.projectID); + _workType = BlocProvider.of(context) + .getWorkTypeByID(widget.timer.workTypeID); _descriptionFocus = FocusNode(); _updateTimerStreamController = StreamController(); - _updateTimer = Timer.periodic(Duration(seconds: 1), (_) => _updateTimerStreamController.add(DateTime.now())); + _updateTimer = Timer.periodic(Duration(seconds: 1), + (_) => _updateTimerStreamController.add(DateTime.now())); } @override @@ -109,7 +114,8 @@ class _TimerEditorState extends State { child: ListView( children: [ BlocBuilder( - builder: (BuildContext context, ProjectsState projectsState) => Padding( + builder: (BuildContext context, ProjectsState projectsState) => + Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), child: DropdownButton( value: _project, @@ -127,28 +133,34 @@ class _TimerEditorState extends State { ProjectColour(project: null), Padding( padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), - child: Text(L10N.of(context).tr.noProject, style: TextStyle( color: Theme.of(context).disabledColor)), + child: Text(L10N.of(context).tr.noProject, + style: TextStyle( + color: + Theme.of(context).disabledColor)), ), ], ), value: null, ) - ].followedBy(projectsState.projects.map( + ] + .followedBy(projectsState.projects.map( (Project project) => DropdownMenuItem( child: Row( children: [ - ProjectColour( project: project,), + ProjectColour( + project: project, + ), Padding( - padding: EdgeInsets.fromLTRB(8.0, 0, 0, 0), + padding: EdgeInsets.fromLTRB( + 8.0, 0, 0, 0), child: Text(project.name), ), ], ), value: project, - ) - )).toList(), - ) - ), + ))) + .toList(), + )), ), BlocBuilder( builder: (BuildContext context, WorkTypesState workTypesState) => @@ -202,8 +214,7 @@ class _TimerEditorState extends State { ), Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 8), - child: - settingsBloc.state.autocompleteDescription + child: settingsBloc.state.autocompleteDescription ? TypeAheadField( direction: AxisDirection.down, textFieldConfiguration: TextFieldConfiguration( @@ -215,16 +226,19 @@ class _TimerEditorState extends State { ), ), itemBuilder: (BuildContext context, String desc) => - ListTile( - title: Text(desc) - ), - onSuggestionSelected: (String description) => _descriptionController.text = description, + ListTile(title: Text(desc)), + onSuggestionSelected: (String description) => + _descriptionController.text = description, suggestionsCallback: (pattern) async { if (pattern.length < 2) return []; List descriptions = timers.state.timers .where((timer) => timer.description != null) - .where((timer) => timer.description.toLowerCase().contains(pattern.toLowerCase()) ?? false) + .where((timer) => + timer.description + .toLowerCase() + .contains(pattern.toLowerCase()) ?? + false) .map((timer) => timer.description) .toSet() .toList(); @@ -249,8 +263,8 @@ class _TimerEditorState extends State { onTap: () async { _oldStartTime = _startTime.clone(); _oldEndTime = _endTime.clone(); - DateTime newStartTime = await DatePicker.showDateTimePicker( - context, + DateTime newStartTime = + await DatePicker.showDateTimePicker(context, currentTime: _startTime, maxTime: _endTime == null ? DateTime.now() : null, onChanged: (DateTime dt) => setStartTime(dt), @@ -259,9 +273,9 @@ class _TimerEditorState extends State { cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); // if the user cancelled, this should be null if (newStartTime == null) { @@ -290,7 +304,8 @@ class _TimerEditorState extends State { actionExtentRatio: 0.15, child: ListTile( title: Text(L10N.of(context).tr.endTime), - trailing: Text(_endTime == null ? "—" : _dateFormat.format(_endTime)), + trailing: + Text(_endTime == null ? "—" : _dateFormat.format(_endTime)), onTap: () async { _oldEndTime = _endTime.clone(); DateTime newEndTime = await DatePicker.showDateTimePicker( @@ -303,9 +318,9 @@ class _TimerEditorState extends State { cancelStyle: Theme.of(context).textTheme.button, doneStyle: Theme.of(context).textTheme.button, itemStyle: Theme.of(context).textTheme.body1, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ) - ); + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + )); // if the user cancelled, this should be null if (newEndTime == null) { @@ -315,12 +330,12 @@ class _TimerEditorState extends State { } }, ), - secondaryActions: - _endTime == null + secondaryActions: _endTime == null ? [ IconSlideAction( color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.clock, onTap: () => setState(() => _endTime = DateTime.now()), ), @@ -328,13 +343,15 @@ class _TimerEditorState extends State { : [ IconSlideAction( color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.clock, onTap: () => setState(() => _endTime = DateTime.now()), ), IconSlideAction( color: Theme.of(context).errorColor, - foregroundColor: Theme.of(context).accentIconTheme.color, + foregroundColor: + Theme.of(context).accentIconTheme.color, icon: FontAwesomeIcons.minusCircle, onTap: () { setState(() { @@ -347,13 +364,13 @@ class _TimerEditorState extends State { StreamBuilder( initialData: DateTime.now(), stream: _updateTimerStreamController.stream, - builder: (BuildContext context, AsyncSnapshot snapshot) => ListTile( + builder: + (BuildContext context, AsyncSnapshot snapshot) => + ListTile( title: Text(L10N.of(context).tr.duration), - trailing: Text(TimerEntry.formatDuration( - _endTime == null + trailing: Text(TimerEntry.formatDuration(_endTime == null ? snapshot.data.difference(_startTime) - : _endTime.difference(_startTime) - )), + : _endTime.difference(_startTime))), ), ), ], @@ -393,4 +410,4 @@ class _TimerEditorState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/workTypes/WorkTypeEditor.dart b/lib/screens/workTypes/WorkTypeEditor.dart index bcbc5ce5..0ce3a75b 100644 --- a/lib/screens/workTypes/WorkTypeEditor.dart +++ b/lib/screens/workTypes/WorkTypeEditor.dart @@ -22,6 +22,7 @@ import 'package:timecop/models/WorkType.dart'; class WorkTypeEditor extends StatefulWidget { final WorkType workType; + WorkTypeEditor({Key key, @required this.workType}) : super(key: key); @override diff --git a/lib/screens/workTypes/WorkTypesScreen.dart b/lib/screens/workTypes/WorkTypesScreen.dart index 20a44f27..cb880227 100644 --- a/lib/screens/workTypes/WorkTypesScreen.dart +++ b/lib/screens/workTypes/WorkTypesScreen.dart @@ -16,9 +16,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/blocs/settings/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; import 'package:timecop/components/WorkTypeBadge.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/screens/workTypes/WorkTypeEditor.dart'; From bb88cc80b678bfe7f81476eb4169df1d35eb7f97 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 17:34:06 -0500 Subject: [PATCH 7/9] fix: added workType to export --- l10n/en.flt | 2 ++ lib/blocs/settings/settings_bloc.dart | 9 +++++++++ lib/blocs/settings/settings_event.dart | 3 +++ lib/blocs/settings/settings_state.dart | 8 ++++++++ lib/screens/export/ExportScreen.dart | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+) diff --git a/l10n/en.flt b/l10n/en.flt index 4f168271..8db7ac2b 100644 --- a/l10n/en.flt +++ b/l10n/en.flt @@ -42,6 +42,8 @@ to = To project = Project +workType = Work Type + description = Description timeH = Time (hours) diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index 323bf25f..32f34fbc 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -37,6 +37,9 @@ class SettingsBloc extends Bloc { bool exportIncludeProject = await settings.getBool("exportIncludeProject") ?? state.exportIncludeProject; + bool exportIncludeWorkType = + await settings.getBool("exportIncludeWorkType") ?? + state.exportIncludeWorkType; bool exportIncludeDate = await settings.getBool("exportIncludeDate") ?? state.exportIncludeDate; bool exportIncludeDescription = @@ -78,6 +81,7 @@ class SettingsBloc extends Bloc { exportGroupTimers: exportGroupTimers, exportIncludeDate: exportIncludeDate, exportIncludeProject: exportIncludeProject, + exportIncludeWorkType: exportIncludeWorkType, exportIncludeDescription: exportIncludeDescription, exportIncludeProjectDescription: exportIncludeProjectDescription, exportIncludeStartTime: exportIncludeStartTime, @@ -142,6 +146,10 @@ class SettingsBloc extends Bloc { await settings.setBool( "exportIncludeProject", event.exportIncludeProject); } + if (event.exportIncludeWorkType != null) { + await settings.setBool( + "exportIncludeWorkType", event.exportIncludeWorkType); + } if (event.exportIncludeDescription != null) { await settings.setBool( "exportIncludeDescription", event.exportIncludeDescription); @@ -189,6 +197,7 @@ class SettingsBloc extends Bloc { exportGroupTimers: event.exportGroupTimers, exportIncludeDate: event.exportIncludeDate, exportIncludeProject: event.exportIncludeProject, + exportIncludeWorkType: event.exportIncludeWorkType, exportIncludeDescription: event.exportIncludeDescription, exportIncludeProjectDescription: event.exportIncludeProjectDescription, exportIncludeStartTime: event.exportIncludeStartTime, diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index 67606163..e3065db5 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -93,6 +93,7 @@ class SetBoolValueEvent extends SettingsEvent { final bool exportGroupTimers; final bool exportIncludeDate; final bool exportIncludeProject; + final bool exportIncludeWorkType; final bool exportIncludeDescription; final bool exportIncludeProjectDescription; final bool exportIncludeStartTime; @@ -109,6 +110,7 @@ class SetBoolValueEvent extends SettingsEvent { {this.exportGroupTimers, this.exportIncludeDate, this.exportIncludeProject, + this.exportIncludeWorkType, this.exportIncludeDescription, this.exportIncludeProjectDescription, this.exportIncludeStartTime, @@ -126,6 +128,7 @@ class SetBoolValueEvent extends SettingsEvent { exportGroupTimers, exportIncludeDate, exportIncludeProject, + exportIncludeWorkType, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index 19ef6135..2dd515c8 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -19,6 +19,7 @@ class SettingsState extends Equatable { final bool exportGroupTimers; final bool exportIncludeDate; final bool exportIncludeProject; + final bool exportIncludeWorkType; final bool exportIncludeDescription; final bool exportIncludeProjectDescription; final bool exportIncludeStartTime; @@ -37,6 +38,7 @@ class SettingsState extends Equatable { @required this.exportGroupTimers, @required this.exportIncludeDate, @required this.exportIncludeProject, + @required this.exportIncludeWorkType, @required this.exportIncludeDescription, @required this.exportIncludeProjectDescription, @required this.exportIncludeStartTime, @@ -53,6 +55,7 @@ class SettingsState extends Equatable { }) : assert(exportGroupTimers != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), + assert(exportIncludeWorkType != null), assert(exportIncludeDescription != null), assert(exportIncludeProjectDescription != null), assert(exportIncludeStartTime != null), @@ -72,6 +75,7 @@ class SettingsState extends Equatable { exportGroupTimers: true, exportIncludeDate: true, exportIncludeProject: true, + exportIncludeWorkType: true, exportIncludeDescription: true, exportIncludeProjectDescription: false, exportIncludeStartTime: false, @@ -93,6 +97,7 @@ class SettingsState extends Equatable { bool exportGroupTimers, bool exportIncludeDate, bool exportIncludeProject, + bool exportIncludeWorkType, bool exportIncludeDescription, bool exportIncludeProjectDescription, bool exportIncludeStartTime, @@ -111,6 +116,8 @@ class SettingsState extends Equatable { exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, + exportIncludeWorkType: + exportIncludeWorkType ?? project.exportIncludeWorkType, exportIncludeDescription: exportIncludeDescription ?? project.exportIncludeDescription, exportIncludeProjectDescription: exportIncludeProjectDescription ?? @@ -140,6 +147,7 @@ class SettingsState extends Equatable { exportGroupTimers, exportIncludeDate, exportIncludeProject, + exportIncludeWorkType, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index 7c00bb2a..0cc665bd 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -29,6 +29,7 @@ import 'package:timecop/blocs/projects/projects_bloc.dart'; import 'package:timecop/blocs/settings/bloc.dart'; import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; import 'package:timecop/l10n.dart'; import 'package:timecop/models/project.dart'; @@ -240,6 +241,13 @@ class _ExportScreenState extends State { .add(SetBoolValueEvent(exportIncludeProject: value)), activeColor: Theme.of(context).accentColor, ), + SwitchListTile( + title: Text(L10N.of(context).tr.workType), + value: settingsState.exportIncludeWorkType, + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportIncludeWorkType: value)), + activeColor: Theme.of(context).accentColor, + ), SwitchListTile( title: Text(L10N.of(context).tr.description), value: settingsState.exportIncludeDescription, @@ -375,6 +383,10 @@ class _ExportScreenState extends State { BlocProvider.of(context); assert(projects != null); + final WorkTypesBloc workTypes = + BlocProvider.of(context); + assert(workTypes != null); + List headers = []; if (settingsBloc.state.exportIncludeDate) { headers.add(L10N.of(context).tr.date); @@ -382,6 +394,9 @@ class _ExportScreenState extends State { if (settingsBloc.state.exportIncludeProject) { headers.add(L10N.of(context).tr.project); } + if (settingsBloc.state.exportIncludeWorkType) { + headers.add(L10N.of(context).tr.workType); + } if (settingsBloc.state.exportIncludeDescription) { headers.add(L10N.of(context).tr.description); } @@ -469,6 +484,10 @@ class _ExportScreenState extends State { if (settingsBloc.state.exportIncludeProject) { row.add(projects.getProjectByID(timer.projectID)?.name ?? ""); } + if (settingsBloc.state.exportIncludeWorkType) { + row.add( + workTypes.getWorkTypeByID(timer.workTypeID)?.name ?? ""); + } if (settingsBloc.state.exportIncludeDescription) { row.add(timer.description ?? ""); } From 5bbae32ae5113a88c760e949a78d5ece7af85c42 Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 17:47:46 -0500 Subject: [PATCH 8/9] feat: added workTypes filter to export --- lib/screens/export/ExportScreen.dart | 73 +++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index 0cc665bd..82f3bfe3 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -31,7 +31,9 @@ import 'package:timecop/blocs/settings/settings_bloc.dart'; import 'package:timecop/blocs/timers/bloc.dart'; import 'package:timecop/blocs/work_types/work_types_bloc.dart'; import 'package:timecop/components/ProjectColour.dart'; +import 'package:timecop/components/WorkTypeBadge.dart'; import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; import 'package:timecop/models/timer_group.dart'; import 'package:timecop/models/timer_entry.dart'; @@ -54,6 +56,7 @@ class _ExportScreenState extends State { DateTime _startDate; DateTime _endDate; List selectedProjects = []; + List selectedWorkTypes = []; static DateFormat _dateFormat = DateFormat("EE, MMM d, yyyy"); static DateFormat _exportDateFormat = DateFormat.yMd(); final GlobalKey _scaffoldKey = GlobalKey(); @@ -67,6 +70,12 @@ class _ExportScreenState extends State { .followedBy(projects.state.projects.map((p) => Project.clone(p))) .toList(); + final WorkTypesBloc workTypes = BlocProvider.of(context); + assert(workTypes != null); + selectedWorkTypes = [null] + .followedBy(workTypes.state.workTypes.map((p) => WorkType.clone(p))) + .toList(); + final SettingsBloc settingsBloc = BlocProvider.of(context); _startDate = settingsBloc.getFilterStartDate(); } @@ -75,6 +84,7 @@ class _ExportScreenState extends State { Widget build(BuildContext context) { final SettingsBloc settingsBloc = BlocProvider.of(context); final ProjectsBloc projectsBloc = BlocProvider.of(context); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); // TODO: break this into components or something so we don't have such a massively unmanagement build function @@ -341,6 +351,62 @@ class _ExportScreenState extends State { ))) .toList(), ), + ExpansionTile( + title: Text(L10N.of(context).tr.workTypes, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + RaisedButton( + child: Text("Select None"), + onPressed: () { + setState(() { + selectedWorkTypes.clear(); + }); + }, + ), + RaisedButton( + child: Text("Select All"), + onPressed: () { + setState(() { + selectedWorkTypes = [null] + .followedBy(workTypesBloc.state.workTypes + .map((p) => WorkType.clone(p))) + .toList(); + }); + }, + ), + ], + ) + ] + .followedBy([null] + .followedBy(workTypesBloc.state.workTypes) + .map((workType) => CheckboxListTile( + secondary: WorkTypeBadge( + workType: workType, + ), + title: Text( + workType?.name ?? L10N.of(context).tr.noWorkType), + value: selectedWorkTypes + .any((p) => p?.id == workType?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) => setState(() { + if (selectedWorkTypes + .any((p) => p?.id == workType?.id)) { + selectedWorkTypes + .removeWhere((p) => p?.id == workType?.id); + } else { + selectedWorkTypes.add(workType); + } + }), + ))) + .toList(), + ), ExpansionTile( title: Text(L10N.of(context).tr.options, style: TextStyle( @@ -379,6 +445,7 @@ class _ExportScreenState extends State { onPressed: () async { final TimersBloc timers = BlocProvider.of(context); assert(timers != null); + final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); @@ -416,6 +483,8 @@ class _ExportScreenState extends State { List filteredTimers = timers.state.timers .where((t) => t.endTime != null) .where((t) => selectedProjects.any((p) => p?.id == t.projectID)) + .where( + (t) => selectedWorkTypes.any((p) => p?.id == t.workTypeID)) .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) .where((t) => @@ -431,6 +500,8 @@ class _ExportScreenState extends State { .where((t) => t.endTime != null) .where( (t) => selectedProjects.any((p) => p?.id == t.projectID)) + .where((t) => + selectedWorkTypes.any((p) => p?.id == t.workTypeID)) .where((t) => _startDate == null ? true : t.startTime.isAfter(_startDate)) @@ -453,7 +524,7 @@ class _ExportScreenState extends State { pairedList.add(timer); } - // ok, now they're grouped based on date, then combined project + description pairs + // ok, now they're grouped based on date, then combined project, workTYpe, description combos // time to get them back into a flat list filteredTimers = derp.values.expand( (LinkedHashMap> pairedEntries) { From ba84bb0a338d2723203969741885b4908a6b6a8f Mon Sep 17 00:00:00 2001 From: RJLyders Date: Sun, 24 May 2020 20:20:04 -0500 Subject: [PATCH 9/9] feat: added options to include time (in addition to existing date) and date range in name of exported file --- l10n/en.flt | 4 ++ lib/blocs/settings/settings_bloc.dart | 54 ++++++---------- lib/blocs/settings/settings_event.dart | 54 ++-------------- lib/blocs/settings/settings_state.dart | 16 +++++ .../l10n/fluent_l10n_provider.dart | 6 ++ lib/data_providers/l10n/l10n_provider.dart | 4 ++ lib/screens/export/ExportScreen.dart | 64 +++++++++++++++++-- terms.flt | 4 ++ 8 files changed, 120 insertions(+), 86 deletions(-) diff --git a/l10n/en.flt b/l10n/en.flt index 8db7ac2b..36ef568e 100644 --- a/l10n/en.flt +++ b/l10n/en.flt @@ -92,6 +92,10 @@ options = Options groupTimers = Group Similar Timers Per Day +includeDateRangeInExportFilename = Include Date Range in Export Filename + +includeTimeInExportFilename = Include Current Time in Export Filename + columns = Columns date = Date diff --git a/lib/blocs/settings/settings_bloc.dart b/lib/blocs/settings/settings_bloc.dart index 32f34fbc..9e639083 100644 --- a/lib/blocs/settings/settings_bloc.dart +++ b/lib/blocs/settings/settings_bloc.dart @@ -34,6 +34,12 @@ class SettingsBloc extends Bloc { if (event is LoadSettingsFromRepository) { bool exportGroupTimers = await settings.getBool("exportGroupTimers") ?? state.exportGroupTimers; + bool exportIncludeDateRangeInFilename = + await settings.getBool("exportIncludeDateRangeInFilename") ?? + state.exportIncludeDateRangeInFilename; + bool exportIncludeTimeInFilename = + await settings.getBool("exportIncludeTimeInFilename") ?? + state.exportIncludeTimeInFilename; bool exportIncludeProject = await settings.getBool("exportIncludeProject") ?? state.exportIncludeProject; @@ -79,6 +85,8 @@ class SettingsBloc extends Bloc { state.displayProjectNameInTimer; yield SettingsState( exportGroupTimers: exportGroupTimers, + exportIncludeDateRangeInFilename: exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: exportIncludeTimeInFilename, exportIncludeDate: exportIncludeDate, exportIncludeProject: exportIncludeProject, exportIncludeWorkType: exportIncludeWorkType, @@ -95,40 +103,7 @@ class SettingsBloc extends Bloc { defaultFilterStartDateToMonday: defaultFilterStartDateToMonday, allowMultipleActiveTimers: allowMultipleActiveTimers, displayProjectNameInTimer: displayProjectNameInTimer); - } - /*else if(event is SetExportGroupTimers) { - await settings.setBool("exportGroupTimers", event.value); - yield SettingsState.clone(state, exportGroupTimers: event.value); - } - else if(event is SetExportIncludeDate) { - await settings.setBool("exportIncludeDate", event.value); - yield SettingsState.clone(state, exportIncludeDate: event.value); - } - else if(event is SetExportIncludeProject) { - await settings.setBool("exportIncludeProject", event.value); - yield SettingsState.clone(state, exportIncludeProject: event.value); - } - else if(event is SetExportIncludeDescription) { - await settings.setBool("exportIncludeDescription", event.value); - yield SettingsState.clone(state, exportIncludeDescription: event.value); - } - else if(event is SetExportIncludeProjectDescription) { - await settings.setBool("exportIncludeProjectDescription", event.value); - yield SettingsState.clone(state, exportIncludeProjectDescription: event.value); - } - else if(event is SetExportIncludeStartTime) { - await settings.setBool("exportIncludeStartTime", event.value); - yield SettingsState.clone(state, exportIncludeStartTime: event.value); - } - else if(event is SetExportIncludeEndTime) { - await settings.setBool("exportIncludeEndTime", event.value); - yield SettingsState.clone(state, exportIncludeEndTime: event.value); - } - else if(event is SetExportIncludeDurationHours) { - await settings.setBool("exportIncludeDurationHours", event.value); - yield SettingsState.clone(state, exportIncludeDurationHours: event.value); - }*/ - else if (event is SetDefaultProjectID) { + } else if (event is SetDefaultProjectID) { await settings.setInt("defaultProjectID", event.projectID ?? -1); yield SettingsState.clone(state, defaultProjectID: event.projectID ?? -1); } else if (event is SetDefaultWorkTypeID) { @@ -139,6 +114,14 @@ class SettingsBloc extends Bloc { if (event.exportGroupTimers != null) { await settings.setBool("exportGroupTimers", event.exportGroupTimers); } + if (event.exportIncludeDateRangeInFilename != null) { + await settings.setBool("exportIncludeDateRangeInFilename", + event.exportIncludeDateRangeInFilename); + } + if (event.exportIncludeTimeInFilename != null) { + await settings.setBool( + "exportIncludeTimeInFilename", event.exportIncludeTimeInFilename); + } if (event.exportIncludeDate != null) { await settings.setBool("exportIncludeDate", event.exportIncludeDate); } @@ -195,6 +178,9 @@ class SettingsBloc extends Bloc { yield SettingsState.clone( state, exportGroupTimers: event.exportGroupTimers, + exportIncludeDateRangeInFilename: + event.exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: event.exportIncludeTimeInFilename, exportIncludeDate: event.exportIncludeDate, exportIncludeProject: event.exportIncludeProject, exportIncludeWorkType: event.exportIncludeWorkType, diff --git a/lib/blocs/settings/settings_event.dart b/lib/blocs/settings/settings_event.dart index e3065db5..2d31db48 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -23,54 +23,6 @@ class LoadSettingsFromRepository extends SettingsEvent { List get props => []; } -/*class SetExportGroupTimers extends SettingsEvent { - final bool value; - const SetExportGroupTimers(this.value); - @override List get props => [value]; -} - -class SetExportIncludeDate extends SettingsEvent { - final bool value; - const SetExportIncludeDate(this.value); - @override List get props => [value]; -} - -class SetExportIncludeProject extends SettingsEvent { - final bool value; - const SetExportIncludeProject(this.value); - @override List get props => [value]; -} - -class SetExportIncludeDescription extends SettingsEvent { - final bool value; - const SetExportIncludeDescription(this.value); - @override List get props => [value]; -} - -class SetExportIncludeProjectDescription extends SettingsEvent { - final bool value; - const SetExportIncludeProjectDescription(this.value); - @override List get props => [value]; -} - -class SetExportIncludeStartTime extends SettingsEvent { - final bool value; - const SetExportIncludeStartTime(this.value); - @override List get props => [value]; -} - -class SetExportIncludeEndTime extends SettingsEvent { - final bool value; - const SetExportIncludeEndTime(this.value); - @override List get props => [value]; -} - -class SetExportIncludeDurationHours extends SettingsEvent { - final bool value; - const SetExportIncludeDurationHours(this.value); - @override List get props => [value]; -}*/ - class SetDefaultProjectID extends SettingsEvent { final int projectID; @@ -91,6 +43,8 @@ class SetDefaultWorkTypeID extends SettingsEvent { class SetBoolValueEvent extends SettingsEvent { final bool exportGroupTimers; + final bool exportIncludeDateRangeInFilename; + final bool exportIncludeTimeInFilename; final bool exportIncludeDate; final bool exportIncludeProject; final bool exportIncludeWorkType; @@ -108,6 +62,8 @@ class SetBoolValueEvent extends SettingsEvent { const SetBoolValueEvent( {this.exportGroupTimers, + this.exportIncludeDateRangeInFilename, + this.exportIncludeTimeInFilename, this.exportIncludeDate, this.exportIncludeProject, this.exportIncludeWorkType, @@ -126,6 +82,8 @@ class SetBoolValueEvent extends SettingsEvent { @override List get props => [ exportGroupTimers, + exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename, exportIncludeDate, exportIncludeProject, exportIncludeWorkType, diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index 2dd515c8..d635fa85 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -17,6 +17,8 @@ import 'package:flutter/foundation.dart'; class SettingsState extends Equatable { final bool exportGroupTimers; + final bool exportIncludeDateRangeInFilename; + final bool exportIncludeTimeInFilename; final bool exportIncludeDate; final bool exportIncludeProject; final bool exportIncludeWorkType; @@ -36,6 +38,8 @@ class SettingsState extends Equatable { SettingsState({ @required this.exportGroupTimers, + @required this.exportIncludeDateRangeInFilename, + @required this.exportIncludeTimeInFilename, @required this.exportIncludeDate, @required this.exportIncludeProject, @required this.exportIncludeWorkType, @@ -53,6 +57,8 @@ class SettingsState extends Equatable { @required this.allowMultipleActiveTimers, @required this.displayProjectNameInTimer, }) : assert(exportGroupTimers != null), + assert(exportIncludeDateRangeInFilename != null), + assert(exportIncludeTimeInFilename != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), assert(exportIncludeWorkType != null), @@ -73,6 +79,8 @@ class SettingsState extends Equatable { static SettingsState initial() { return SettingsState( exportGroupTimers: true, + exportIncludeDateRangeInFilename: false, + exportIncludeTimeInFilename: false, exportIncludeDate: true, exportIncludeProject: true, exportIncludeWorkType: true, @@ -95,6 +103,8 @@ class SettingsState extends Equatable { SettingsState.clone( SettingsState project, { bool exportGroupTimers, + bool exportIncludeDateRangeInFilename, + bool exportIncludeTimeInFilename, bool exportIncludeDate, bool exportIncludeProject, bool exportIncludeWorkType, @@ -113,6 +123,10 @@ class SettingsState extends Equatable { bool displayProjectNameInTimer, }) : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, + exportIncludeDateRangeInFilename: exportIncludeDateRangeInFilename ?? + project.exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: exportIncludeTimeInFilename ?? + project.exportIncludeTimeInFilename, exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, @@ -145,6 +159,8 @@ class SettingsState extends Equatable { @override List get props => [ exportGroupTimers, + exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename, exportIncludeDate, exportIncludeProject, exportIncludeWorkType, diff --git a/lib/data_providers/l10n/fluent_l10n_provider.dart b/lib/data_providers/l10n/fluent_l10n_provider.dart index fbf3c8de..f57357a8 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -144,6 +144,12 @@ class FluentL10NProvider extends L10NProvider { String get groupTimers => _bundle.format("groupTimers", errors: _errors); + String get includeDateRangeInExportFilename => + _bundle.format("includeDateRangeInExportFilename", errors: _errors); + + String get includeTimeInExportFilename => + _bundle.format("includeTimeInExportFilename", errors: _errors); + String get columns => _bundle.format("columns", errors: _errors); String get date => _bundle.format("date", errors: _errors); diff --git a/lib/data_providers/l10n/l10n_provider.dart b/lib/data_providers/l10n/l10n_provider.dart index 8161fe2c..807eb768 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -109,6 +109,10 @@ abstract class L10NProvider { String get groupTimers; + String get includeDateRangeInExportFilename; + + String get includeTimeInExportFilename; + String get columns; String get date; diff --git a/lib/screens/export/ExportScreen.dart b/lib/screens/export/ExportScreen.dart index 82f3bfe3..58e3897b 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -58,6 +58,7 @@ class _ExportScreenState extends State { List selectedProjects = []; List selectedWorkTypes = []; static DateFormat _dateFormat = DateFormat("EE, MMM d, yyyy"); + static DateFormat _dateTimeFormat = DateFormat("EE, MMM d, yyyy HH_mm_s"); static DateFormat _exportDateFormat = DateFormat.yMd(); final GlobalKey _scaffoldKey = GlobalKey(); @@ -423,6 +424,29 @@ class _ExportScreenState extends State { .add(SetBoolValueEvent(exportGroupTimers: value)), activeColor: Theme.of(context).accentColor, ), + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settingsState) => + SwitchListTile( + title: Text( + L10N.of(context).tr.includeDateRangeInExportFilename), + value: settingsState.exportIncludeDateRangeInFilename, + onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent( + exportIncludeDateRangeInFilename: value)), + activeColor: Theme.of(context).accentColor, + ), + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settingsState) => + SwitchListTile( + title: Text(L10N.of(context).tr.includeTimeInExportFilename), + value: settingsState.exportIncludeTimeInFilename, + onChanged: (bool value) => settingsBloc.add( + SetBoolValueEvent(exportIncludeTimeInFilename: value)), + activeColor: Theme.of(context).accentColor, + ), ) ], ), @@ -593,14 +617,46 @@ class _ExportScreenState extends State { } else { directory = await getApplicationDocumentsDirectory(); } + + String timestamp; + DateFormat dateTimeFormatFilename = _dateFormat; + if (settingsBloc.state.exportIncludeTimeInFilename) { + dateTimeFormatFilename = _dateTimeFormat; + } + timestamp = dateTimeFormatFilename.format(DateTime.now()); + + String dateRange = ''; + if (settingsBloc.state.exportIncludeDateRangeInFilename && + (_startDate != null || _endDate != null)) { + String startDateStr = _startDate != null + ? '${L10N.of(context).tr.from} ${_dateFormat.format(_startDate)}' + : ''; + String endDateStr = _endDate != null + ? '${L10N.of(context).tr.to} ${_dateFormat.format(_endDate)}' + : ''; + dateRange = '[' + ('${startDateStr} ${endDateStr}'.trim()) + ']'; + + // file name examples: + // no time, no date range: + // Time Cop Entries (Sun, May 24, 2020) + + // with time, no date range: + // Time Cop Entries (Sun, May 24, 2020 20_11_12) + + // with time, with date range, but only start date of range + // Time Cop Entries (Sun, May 24, 2020 20_10_18 [From Mon, May 18, 2020]) + + // with time, with date range + // Time Cop Entries (Sun, May 24, 2020 20_10_33 [From Mon, May 18, 2020 To Sat, May 23, 2020]) + + timestamp = '${timestamp} ${dateRange}'.trim(); + } + final String localPath = '${directory.path}/timecop.csv'; File file = File(localPath); await file.writeAsString(csv, flush: true); await FlutterShare.shareFile( - title: L10N - .of(context) - .tr - .timeCopEntries(_dateFormat.format(DateTime.now())), + title: L10N.of(context).tr.timeCopEntries(timestamp), filePath: localPath); }), ); diff --git a/terms.flt b/terms.flt index f1cf8992..95a8565c 100644 --- a/terms.flt +++ b/terms.flt @@ -92,6 +92,10 @@ options = Options groupTimers = Group Similar Timers Per Day +includeDateRangeInExportFilename = Include Date Range in Export Filename + +includeTimeInExportFilename = Include Current Time in Export Filename + columns = Columns date = Date