diff --git a/doc/Time Cop TimeSheet (Mon, May 25, 2020 16_22_52 [From Mon, May 18, 2020]).json b/doc/Time Cop TimeSheet (Mon, May 25, 2020 16_22_52 [From Mon, May 18, 2020]).json new file mode 100644 index 00000000..c987c10c --- /dev/null +++ b/doc/Time Cop TimeSheet (Mon, May 25, 2020 16_22_52 [From Mon, May 18, 2020]).json @@ -0,0 +1,54 @@ +{ + "endOfWeek": "5/24/2020", + "rows": [ + { + "project": "INC-IAN", + "workType": "Reqs", + "notes": "MTG with John for details[2m]", + "totalHours": 0.0, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]" + }, + { + "project": "INC-IAN", + "workType": "Doc", + "notes": "test[44m]", + "totalHours": 0.75, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.75]" + }, + { + "project": "INC-IAN", + "workType": "Dep", + "notes": "MTG with John for details[0m]", + "totalHours": 0.0, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]" + }, + { + "project": "ITR6339", + "workType": "Dev", + "notes": "README[11h 36m]", + "totalHours": 11.5, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 11.5]" + }, + { + "project": null, + "workType": "Test", + "notes": "bersat[0m]", + "totalHours": 0.0, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]" + }, + { + "project": null, + "workType": "Dev", + "notes": "a[0m]", + "totalHours": 0.0, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]" + }, + { + "project": null, + "workType": null, + "notes": "test no project or work type[2m]", + "totalHours": 0.0, + "dayHours": "[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]" + } + ] +} \ No newline at end of file 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 00000000..96b1db1f Binary files /dev/null and b/doc/images/TimeCop-dashboard-timers-with-work-type-badges.png differ diff --git a/doc/images/TimeCop-work-types-add.png b/doc/images/TimeCop-work-types-add.png new file mode 100644 index 00000000..eb44057a Binary files /dev/null and b/doc/images/TimeCop-work-types-add.png differ diff --git a/doc/images/TimeCop-work-types.png b/doc/images/TimeCop-work-types.png new file mode 100644 index 00000000..8d1753c4 Binary files /dev/null and b/doc/images/TimeCop-work-types.png differ 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 978bc769..3009dc20 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? @@ -38,25 +42,37 @@ to = To project = Project +workType = Work Type + description = Description timeH = Time (hours) timeCopEntries = Time Cop Entries ({ $date }) +timeSheetExportFileName = Time Cop TimeSheet ({$date}).json + 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 @@ -78,6 +94,12 @@ options = Options groupTimers = Group Similar Timers Per Day +includeDateRangeInExportFilename = Include Date Range in Export Filename + +includeTimeInExportFilename = Include Current Time in Export Filename + +timesheetExport = Timesheet Export + columns = Columns date = Date @@ -118,6 +140,12 @@ collapseDays = Collapse Days autocompleteDescription = Autocomplete Descriptions +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/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..eb5724b3 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) @@ -62,10 +62,15 @@ class ProjectsBloc extends Bloc { } Project getProjectByID(int id) { + return getProjectByIDFromList(state.projects, id); + } + + static Project getProjectByIDFromList(List projectList, int id) { if (id == null) return null; - for (Project p in state.projects) { - if (p.id == id) return p; + try { + return projectList.singleWhere((w) => w.id == id); + } catch (err) { + return null; } - return null; } } 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 0c79aa73..f088645b 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 @@ -28,108 +31,166 @@ class SettingsBloc extends Bloc { Stream mapEventToState( 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; - bool groupTimers = await settings.getBool("groupTimers") ?? state.groupTimers; - bool collapseDays = await settings.getBool("collapseDays") ?? state.collapseDays; - bool autocompleteDescription = await settings.getBool("autocompleteDescription") ?? state.autocompleteDescription; + 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 exportTimesheet = + await settings.getBool("exportTimesheet") ?? state.exportTimesheet; + 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 = + 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, - exportIncludeProject: exportIncludeProject, - exportIncludeDescription: exportIncludeDescription, - exportIncludeProjectDescription: exportIncludeProjectDescription, - exportIncludeStartTime: exportIncludeStartTime, - exportIncludeEndTime: exportIncludeEndTime, - exportIncludeDurationHours: exportIncludeDurationHours, - defaultProjectID: defaultProjectID, - groupTimers: groupTimers, - collapseDays: collapseDays, - autocompleteDescription: autocompleteDescription, - ); - } - /*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) { + exportGroupTimers: exportGroupTimers, + exportIncludeDateRangeInFilename: exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: exportIncludeTimeInFilename, + exportTimesheet: exportTimesheet, + exportIncludeDate: exportIncludeDate, + exportIncludeProject: exportIncludeProject, + exportIncludeWorkType: exportIncludeWorkType, + exportIncludeDescription: exportIncludeDescription, + exportIncludeProjectDescription: exportIncludeProjectDescription, + exportIncludeStartTime: exportIncludeStartTime, + exportIncludeEndTime: exportIncludeEndTime, + exportIncludeDurationHours: exportIncludeDurationHours, + defaultProjectID: defaultProjectID, + defaultWorkTypeID: defaultWorkTypeID, + groupTimers: groupTimers, + collapseDays: collapseDays, + autocompleteDescription: autocompleteDescription, + defaultFilterStartDateToMonday: defaultFilterStartDateToMonday, + allowMultipleActiveTimers: allowMultipleActiveTimers, + displayProjectNameInTimer: displayProjectNameInTimer); + } 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 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); } - if(event.exportIncludeDate != null) { + if (event.exportIncludeDateRangeInFilename != null) { + await settings.setBool("exportIncludeDateRangeInFilename", + event.exportIncludeDateRangeInFilename); + } + if (event.exportIncludeTimeInFilename != null) { + await settings.setBool( + "exportIncludeTimeInFilename", event.exportIncludeTimeInFilename); + } + if (event.exportTimesheet != null) { + await settings.setBool("exportTimesheet", event.exportTimesheet); + } + if (event.exportIncludeDate != null) { await settings.setBool("exportIncludeDate", event.exportIncludeDate); } - if(event.exportIncludeProject != null) { - await settings.setBool("exportIncludeProject", event.exportIncludeProject); + if (event.exportIncludeProject != null) { + 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); + if (event.exportIncludeDescription != null) { + await settings.setBool( + "exportIncludeDescription", event.exportIncludeDescription); } - if(event.exportIncludeProjectDescription != null) { - await settings.setBool("exportIncludeProjectDescription", event.exportIncludeProjectDescription); + if (event.exportIncludeProjectDescription != null) { + await settings.setBool("exportIncludeProjectDescription", + event.exportIncludeProjectDescription); } - if(event.exportIncludeStartTime != null) { - await settings.setBool("exportIncludeStartTime", event.exportIncludeStartTime); + if (event.exportIncludeStartTime != null) { + await settings.setBool( + "exportIncludeStartTime", event.exportIncludeStartTime); } - if(event.exportIncludeEndTime != null) { - await settings.setBool("exportIncludeEndTime", event.exportIncludeEndTime); + if (event.exportIncludeEndTime != null) { + await settings.setBool( + "exportIncludeEndTime", event.exportIncludeEndTime); } - if(event.exportIncludeDurationHours != null) { - await settings.setBool("exportIncludeDurationHours", event.exportIncludeDurationHours); + 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) { - await settings.setBool("autocompleteDescription", event.autocompleteDescription); + if (event.autocompleteDescription != null) { + await settings.setBool( + "autocompleteDescription", event.autocompleteDescription); } - yield SettingsState.clone(state, + if (event.defaultFilterStartDateToMonday != null) { + await settings.setBool("defaultFilterStartDateToMonday", + event.defaultFilterStartDateToMonday); + } + 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, + exportIncludeDateRangeInFilename: + event.exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: event.exportIncludeTimeInFilename, + exportTimesheet: event.exportTimesheet, exportIncludeDate: event.exportIncludeDate, exportIncludeProject: event.exportIncludeProject, + exportIncludeWorkType: event.exportIncludeWorkType, exportIncludeDescription: event.exportIncludeDescription, exportIncludeProjectDescription: event.exportIncludeProjectDescription, exportIncludeStartTime: event.exportIncludeStartTime, @@ -138,7 +199,31 @@ class SettingsBloc extends Bloc { groupTimers: event.groupTimers, collapseDays: event.collapseDays, autocompleteDescription: event.autocompleteDescription, + defaultFilterStartDateToMonday: event.defaultFilterStartDateToMonday, + allowMultipleActiveTimers: event.allowMultipleActiveTimers, + displayProjectNameInTimer: event.displayProjectNameInTimer, ); } } + + /** + * 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... + startDate = todayZerothHour + .subtract(Duration(days: todayZerothHour.weekday - dayOfWeek)); + } else { + 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 520d162c..f62b93e9 100644 --- a/lib/blocs/settings/settings_event.dart +++ b/lib/blocs/settings/settings_event.dart @@ -19,67 +19,36 @@ abstract class SettingsEvent extends Equatable { } class LoadSettingsFromRepository extends SettingsEvent { - @override List get props => []; + @override + 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 SetDefaultProjectID extends SettingsEvent { + final int projectID; -class SetExportIncludeProjectDescription extends SettingsEvent { - final bool value; - const SetExportIncludeProjectDescription(this.value); - @override List get props => [value]; -} + const SetDefaultProjectID(this.projectID); -class SetExportIncludeStartTime extends SettingsEvent { - final bool value; - const SetExportIncludeStartTime(this.value); - @override List get props => [value]; + @override + List get props => [projectID]; } -class SetExportIncludeEndTime extends SettingsEvent { - final bool value; - const SetExportIncludeEndTime(this.value); - @override List get props => [value]; -} +class SetDefaultWorkTypeID extends SettingsEvent { + final int workTypeID; -class SetExportIncludeDurationHours extends SettingsEvent { - final bool value; - const SetExportIncludeDurationHours(this.value); - @override List get props => [value]; -}*/ + const SetDefaultWorkTypeID(this.workTypeID); -class SetDefaultProjectID extends SettingsEvent { - final int projectID; - const SetDefaultProjectID(this.projectID); - @override List get props => [projectID]; + @override + List get props => [workTypeID]; } class SetBoolValueEvent extends SettingsEvent { final bool exportGroupTimers; + final bool exportIncludeDateRangeInFilename; + final bool exportIncludeTimeInFilename; + final bool exportTimesheet; final bool exportIncludeDate; final bool exportIncludeProject; + final bool exportIncludeWorkType; final bool exportIncludeDescription; final bool exportIncludeProjectDescription; final bool exportIncludeStartTime; @@ -88,8 +57,49 @@ class SetBoolValueEvent extends SettingsEvent { final bool groupTimers; final bool collapseDays; final bool autocompleteDescription; + final bool defaultFilterStartDateToMonday; + final bool allowMultipleActiveTimers; + final bool displayProjectNameInTimer; - 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.exportIncludeDateRangeInFilename, + this.exportIncludeTimeInFilename, + this.exportTimesheet, + this.exportIncludeDate, + this.exportIncludeProject, + this.exportIncludeWorkType, + this.exportIncludeDescription, + this.exportIncludeProjectDescription, + this.exportIncludeStartTime, + this.exportIncludeEndTime, + this.exportIncludeDurationHours, + this.groupTimers, + this.collapseDays, + this.autocompleteDescription, + this.defaultFilterStartDateToMonday, + this.allowMultipleActiveTimers, + this.displayProjectNameInTimer}); - @override List get props => [exportGroupTimers, exportIncludeDate, exportIncludeProject, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, exportIncludeEndTime, exportIncludeDurationHours, groupTimers, collapseDays, autocompleteDescription]; + @override + List get props => [ + exportGroupTimers, + exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename, + exportTimesheet, + exportIncludeDate, + exportIncludeProject, + exportIncludeWorkType, + exportIncludeDescription, + exportIncludeProjectDescription, + exportIncludeStartTime, + exportIncludeEndTime, + exportIncludeDurationHours, + groupTimers, + collapseDays, + autocompleteDescription, + defaultFilterStartDateToMonday, + allowMultipleActiveTimers, + displayProjectNameInTimer + ]; } diff --git a/lib/blocs/settings/settings_state.dart b/lib/blocs/settings/settings_state.dart index b6d78aaf..466c6d46 100644 --- a/lib/blocs/settings/settings_state.dart +++ b/lib/blocs/settings/settings_state.dart @@ -17,114 +17,172 @@ import 'package:flutter/foundation.dart'; class SettingsState extends Equatable { final bool exportGroupTimers; + final bool exportIncludeDateRangeInFilename; + final bool exportIncludeTimeInFilename; + final bool exportTimesheet; final bool exportIncludeDate; final bool exportIncludeProject; + final bool exportIncludeWorkType; final bool exportIncludeDescription; final bool exportIncludeProjectDescription; final bool exportIncludeStartTime; 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, + @required this.exportIncludeDateRangeInFilename, + @required this.exportIncludeTimeInFilename, + @required this.exportTimesheet, @required this.exportIncludeDate, @required this.exportIncludeProject, + @required this.exportIncludeWorkType, @required this.exportIncludeDescription, @required this.exportIncludeProjectDescription, @required this.exportIncludeStartTime, @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(exportIncludeDateRangeInFilename != null), + assert(exportIncludeTimeInFilename != null), + assert(exportTimesheet != null), assert(exportIncludeDate != null), assert(exportIncludeProject != null), + assert(exportIncludeWorkType != null), assert(exportIncludeDescription != null), assert(exportIncludeProjectDescription != null), assert(exportIncludeStartTime != null), assert(exportIncludeEndTime != null), assert(exportIncludeDurationHours != null), assert(defaultProjectID != null), + assert(defaultWorkTypeID != null), assert(groupTimers != null), assert(collapseDays != null), - assert(autocompleteDescription != null); + assert(autocompleteDescription != null), + assert(defaultFilterStartDateToMonday != null), + assert(allowMultipleActiveTimers != null), + assert(displayProjectNameInTimer != null); static SettingsState initial() { return SettingsState( exportGroupTimers: true, + exportIncludeDateRangeInFilename: false, + exportIncludeTimeInFilename: false, + exportTimesheet: false, exportIncludeDate: true, exportIncludeProject: true, + exportIncludeWorkType: true, exportIncludeDescription: true, exportIncludeProjectDescription: false, exportIncludeStartTime: false, exportIncludeEndTime: false, exportIncludeDurationHours: true, defaultProjectID: -1, + defaultWorkTypeID: -1, groupTimers: true, collapseDays: false, autocompleteDescription: true, + defaultFilterStartDateToMonday: true, + allowMultipleActiveTimers: true, + displayProjectNameInTimer: true, ); } - SettingsState.clone(SettingsState project, { + SettingsState.clone( + SettingsState project, { bool exportGroupTimers, + bool exportIncludeDateRangeInFilename, + bool exportIncludeTimeInFilename, + bool exportTimesheet, bool exportIncludeDate, bool exportIncludeProject, + bool exportIncludeWorkType, bool exportIncludeDescription, bool exportIncludeProjectDescription, bool exportIncludeStartTime, bool exportIncludeEndTime, bool exportIncludeDurationHours, int defaultProjectID, + int defaultWorkTypeID, bool groupTimers, bool collapseDays, bool autocompleteDescription, - }) - : this( + bool defaultFilterStartDateToMonday, + bool allowMultipleActiveTimers, + bool displayProjectNameInTimer, + }) : this( exportGroupTimers: exportGroupTimers ?? project.exportGroupTimers, - exportIncludeDate: - exportIncludeDate ?? project.exportIncludeDate, + exportIncludeDateRangeInFilename: exportIncludeDateRangeInFilename ?? + project.exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename: exportIncludeTimeInFilename ?? + project.exportIncludeTimeInFilename, + exportTimesheet: exportTimesheet ?? project.exportTimesheet, + exportIncludeDate: exportIncludeDate ?? project.exportIncludeDate, exportIncludeProject: exportIncludeProject ?? project.exportIncludeProject, + exportIncludeWorkType: + exportIncludeWorkType ?? project.exportIncludeWorkType, exportIncludeDescription: exportIncludeDescription ?? project.exportIncludeDescription, - exportIncludeProjectDescription: - exportIncludeProjectDescription ?? project.exportIncludeProjectDescription, + 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, + 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 List get props => [ exportGroupTimers, + exportIncludeDateRangeInFilename, + exportIncludeTimeInFilename, + exportTimesheet, exportIncludeDate, exportIncludeProject, + exportIncludeWorkType, exportIncludeDescription, exportIncludeProjectDescription, exportIncludeStartTime, exportIncludeEndTime, exportIncludeDurationHours, defaultProjectID, + defaultWorkTypeID, groupTimers, collapseDays, autocompleteDescription, + defaultFilterStartDateToMonday, + allowMultipleActiveTimers, + displayProjectNameInTimer, ]; } 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 8ba4b41a..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,20 +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); + 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) { @@ -54,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); @@ -63,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(); 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..d1f4db5b 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'; @@ -21,40 +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; - 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 { 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/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..fda49ed1 --- /dev/null +++ b/lib/blocs/work_types/work_types_bloc.dart @@ -0,0 +1,77 @@ +// 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) { + return getWorkTypeByIDFromList(state.workTypes, id); + } + + static WorkType getWorkTypeByIDFromList(List worktypeList, int id) { + if (id == null) return null; + try { + return worktypeList.singleWhere((w) => w.id == id); + } catch (err) { + 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..1304a044 --- /dev/null +++ b/lib/blocs/work_types/work_types_event.dart @@ -0,0 +1,56 @@ +// 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..4db76af3 --- /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'; + +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/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/TimesheetRow.dart b/lib/components/TimesheetRow.dart new file mode 100644 index 00000000..3bc0a136 --- /dev/null +++ b/lib/components/TimesheetRow.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; +import 'package:timecop/components/worktype_data.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; + +class TimesheetRow { + final Project project; + final WorkType workType; + String notes = ''; + final dayHrs = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + double totalHours = 0; + + final int minBlockOfMins = 15; + + TimesheetRow({this.project, this.workType, WorkTypeData workTypeData}) { + workTypeData.noteMinutes.forEach((note, noteMinutes) { + if (notes.isNotEmpty) { + notes = notes + '; '; + } + var noteMins = noteMinutes.round(); + String timeStr; + if (noteMins < 60) { + timeStr = '${noteMins}m'; + } else { + var noteHrs = (noteMins / 60).floor(); + noteMins = noteMins - noteHrs * 60; + timeStr = '${noteHrs}h ${noteMins}m'; + } + notes = notes + note + '[' + timeStr + ']'; + }); + + for (var day = DateTime.monday; day <= DateTime.sunday; day++) { + int dayIdx = day - 1; + var mins = applyMinBlockOfTime(workTypeData.weekDayMinutes[dayIdx]); + var hrs = mins / 60; + hrs = ((hrs * 100).roundToDouble()) / 100; + dayHrs[dayIdx] = hrs; + totalHours = totalHours + hrs; + } + } + + int applyMinBlockOfTime(double mins) { + return (mins / minBlockOfMins).round() * minBlockOfMins; + } + + Map toJson() => _toJson(this); + + Map _toJson(TimesheetRow instance) { + return { + 'project': instance.project?.getProjectCode(), + 'workType': instance.workType?.name, + 'notes': instance.notes, + 'totalHours': instance.totalHours, + 'dayHours': instance.dayHrs, + }; + } +} diff --git a/lib/components/WorkTypeBadge.dart b/lib/components/WorkTypeBadge.dart new file mode 100644 index 00000000..a5e0ca03 --- /dev/null +++ b/lib/components/WorkTypeBadge.dart @@ -0,0 +1,59 @@ +// 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/components/exporter.dart b/lib/components/exporter.dart new file mode 100644 index 00000000..01f91ab1 --- /dev/null +++ b/lib/components/exporter.dart @@ -0,0 +1,329 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:csv/csv.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_share/flutter_share.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:timecop/blocs/projects/projects_bloc.dart'; +import 'package:timecop/blocs/settings/bloc.dart'; +import 'package:timecop/blocs/timers/timers_bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; +import 'package:timecop/components/timesheet.dart'; +import 'package:timecop/l10n.dart'; +import 'package:timecop/models/timer_entry.dart'; +import 'package:timecop/models/proj_work_desc_data.dart'; +import 'package:path/path.dart' as p; + +import 'exporter_data.dart'; + +class Exporter { + final BuildContext context; + final ExporterData exporterData; + Exporter({@required this.context, @required this.exporterData}) + : assert(context != null), + assert(exporterData != null); + + static final DateFormat exportDateFormat = DateFormat.yMd(); + + SettingsBloc settingsBloc = null; + TimersBloc timersBloc = null; + ProjectsBloc projectsBloc = null; + WorkTypesBloc workTypesBloc = null; + + void _init() { + if (settingsBloc == null) { + settingsBloc = BlocProvider.of(context); + assert(settingsBloc != null); + } + + if (timersBloc == null) { + timersBloc = BlocProvider.of(context); + assert(timersBloc != null); + } + + if (projectsBloc == null) { + projectsBloc = BlocProvider.of(context); + assert(projectsBloc != null); + } + + if (workTypesBloc == null) { + workTypesBloc = BlocProvider.of(context); + assert(workTypesBloc != null); + } + } + + Future exportTimeEntries() async { + _init(); + List filteredTimers = _getFilteredAndSortedTimers(timersBloc); + + if (settingsBloc.state.exportTimesheet) { + await _exportTimesheet(filteredTimers); + } else { + await _exportCsv(filteredTimers); + } + } + + Future _exportTimesheet(List timers) async { + var endOfWeekOnSunday = + Timesheet.getWeekEndOnSunday(exporterData.startDate); + + var timesheet = Timesheet( + endOfWeekOnSunday: endOfWeekOnSunday, + projects: projectsBloc.state.projects, + workTypes: workTypesBloc.state.workTypes, + timers: timers); + + var encoder = JsonEncoder.withIndent(' '); + var jsonDataPretty = encoder.convert(timesheet); + + await exportData('timecop-timesheet.json', jsonDataPretty, + L10N.of(context).tr.timeSheetExportFileName); + } + + Future _exportCsv(List filteredTimers) async { + // group similar timers if that's what you're in to + if (settingsBloc.state.exportGroupTimers && + !(settingsBloc.state.exportIncludeStartTime || + settingsBloc.state.exportIncludeEndTime)) { + filteredTimers = _groupDailyTimersByProjWorkDesc(filteredTimers); + } + + List> data = _convertTimersToRowsOfData(filteredTimers); + + String csv = ListToCsvConverter().convert(data); +// print('CSV:'); +// print(csv); + + await exportData('timecop.csv', csv, L10N.of(context).tr.timeCopEntries); + } + + void exportData(String localFileName, String data, + String Function(String) fileNameFn) async { + Directory directory; + if (Platform.isAndroid) { + directory = await getExternalStorageDirectory(); + } else { + directory = await getApplicationDocumentsDirectory(); + } + + String timestamp; + DateFormat dateTimeFormatFilename = ExporterData.dateFormat; + if (settingsBloc.state.exportIncludeTimeInFilename) { + dateTimeFormatFilename = ExporterData.dateTimeFormat; + } + timestamp = dateTimeFormatFilename.format(DateTime.now()); + + String dateRange = ''; + if (settingsBloc.state.exportIncludeDateRangeInFilename && + (exporterData.startDate != null || exporterData.endDate != null)) { + String startDateStr = exporterData.startDate != null + ? '${L10N.of(context).tr.from} ${ExporterData.dateFormat.format(exporterData.startDate)}' + : ''; + String endDateStr = exporterData.endDate != null + ? '${L10N.of(context).tr.to} ${ExporterData.dateFormat.format(exporterData.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}/${localFileName}'; + File file = File(localPath); + await file.writeAsString(data, flush: true); + await FlutterShare.shareFile( + title: fileNameFn(timestamp), filePath: localPath); + } + + Future exportDatabase() async { + _init(); + var databasesPath = await getDatabasesPath(); + var dbPath = p.join(databasesPath, 'timecop.db'); + + try { + // 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")); + dbPath = copiedDB.path; + } + await FlutterShare.shareFile( + title: L10N + .of(context) + .tr + .timeCopDatabase(ExporterData.dateFormat.format(DateTime.now())), + filePath: dbPath); + } on Exception catch (e) { + exporterData.scaffoldKey.currentState.showSnackBar(SnackBar( + backgroundColor: Theme.of(context).errorColor, + content: Text( + e.toString(), + style: TextStyle(color: Colors.white), + ), + duration: Duration(seconds: 5), + )); + } + } + + List _getFilteredAndSortedTimers(TimersBloc timers) { + List filteredTimers = timers.state.timers + .where((t) => t.endTime != null) + .where((t) => + exporterData.selectedProjects.any((p) => p?.id == t.projectID)) + .where((t) => + exporterData.selectedWorkTypes.any((p) => p?.id == t.workTypeID)) + .where((t) => exporterData.startDate == null + ? true + : t.startTime.isAfter(exporterData.startDate)) + .where((t) => exporterData.endDate == null + ? true + : t.endTime.isBefore(exporterData.endDate)) + .toList(); + filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime)); + return filteredTimers; + } + + List _groupDailyTimersByProjWorkDesc(List timers) { + // now start grouping those suckers + LinkedHashMap>> + mapOfDaysOfGroupsOfTimers = LinkedHashMap(); + for (TimerEntry timer in timers) { + /* TODO check if there is a problem with timers running past midnight. + The following code appears to assume that timers will not extend past midnight. + Either timers should be stopped or split at midnight (is this currently happening?) + or this code needs to accommodate for timers that extend beyond the midnight. + */ + String dateStrForTimer = exportDateFormat.format(timer.startTime); + + // get the map of Project-WorkType-Description groups for the day + // based on this timer's start time. If it does not exist, then an empty + // map is created. + LinkedHashMap> + mapOfProjWorkDescGroupsOnDay = mapOfDaysOfGroupsOfTimers.putIfAbsent( + dateStrForTimer, () => LinkedHashMap()); + + // get the list of timers for the Project-WorkType-Description group + // that match this timer's data on the timer's date + List listOfTimersForProjWorkDescGroupOnDay = + mapOfProjWorkDescGroupsOnDay.putIfAbsent( + ProjWorkDescData( + timer.projectID, timer.workTypeID, timer.description), + () => []); + + // add this timer to the Project-WorkType-Description group that matches + // this timer's data on the timer's date. + listOfTimersForProjWorkDescGroupOnDay.add(timer); + } + + // ok, now we have a map of days with a child map of proj-work-desc groups + // with a child list of timers. + // time to get them back into a flat list + timers = mapOfDaysOfGroupsOfTimers.values.expand( + (LinkedHashMap> + mapOfGroupsOfTimers) { + return mapOfGroupsOfTimers.values + .map((List listOfTimersInGroup) { + assert(listOfTimersInGroup.isNotEmpty); + + // not a grouped entry + if (listOfTimersInGroup.length == 1) return listOfTimersInGroup[0]; + + // yes a group entry, build a dummy timer entry + Duration totalTime = listOfTimersInGroup.fold( + Duration(), + (Duration d, TimerEntry t) => + d + t.endTime.difference(t.startTime)); + return TimerEntry.clone(listOfTimersInGroup[0], + endTime: listOfTimersInGroup[0].startTime.add(totalTime)); + }); + }).toList(); + return timers; + } + + List> _convertTimersToRowsOfData(List timers) { + List headers = _getHeaders(settingsBloc); + + List> data = + >[headers].followedBy(timers.map((timer) { + List row = []; + if (settingsBloc.state.exportIncludeDate) { + row.add(exportDateFormat.format(timer.startTime)); + } + if (settingsBloc.state.exportIncludeProject) { + row.add(projectsBloc.getProjectByID(timer.projectID)?.name ?? ""); + } + if (settingsBloc.state.exportIncludeWorkType) { + row.add(workTypesBloc.getWorkTypeByID(timer.workTypeID)?.name ?? ""); + } + if (settingsBloc.state.exportIncludeDescription) { + row.add(timer.description ?? ""); + } + if (settingsBloc.state.exportIncludeProjectDescription) { + row.add((projectsBloc.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(); + return data; + } + + List _getHeaders(SettingsBloc settingsBloc) { + 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.exportIncludeWorkType) { + headers.add(L10N.of(context).tr.workType); + } + 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); + } + return headers; + } +} diff --git a/lib/components/exporter_data.dart b/lib/components/exporter_data.dart new file mode 100644 index 00000000..13064ffe --- /dev/null +++ b/lib/components/exporter_data.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; + +class ExporterData { + DateTime startDate; + DateTime endDate; + List selectedProjects = []; + List selectedWorkTypes = []; + final GlobalKey scaffoldKey = GlobalKey(); + static final DateFormat dateFormat = DateFormat("EE, MMM d, yyyy"); + static final DateFormat dateTimeFormat = + DateFormat("EE, MMM d, yyyy HH_mm_s"); +} diff --git a/lib/components/project_data.dart b/lib/components/project_data.dart new file mode 100644 index 00000000..868a0d72 --- /dev/null +++ b/lib/components/project_data.dart @@ -0,0 +1,7 @@ +import 'package:timecop/components/worktype_data.dart'; + +class ProjectData { + final workTypes = Map(); + + ProjectData(); +} diff --git a/lib/components/timesheet.dart b/lib/components/timesheet.dart new file mode 100644 index 00000000..4dc5a4a0 --- /dev/null +++ b/lib/components/timesheet.dart @@ -0,0 +1,166 @@ +import 'package:flutter/cupertino.dart'; +import 'package:intl/intl.dart'; +import 'package:timecop/blocs/projects/bloc.dart'; +import 'package:timecop/blocs/work_types/work_types_bloc.dart'; +import 'package:timecop/components/project_data.dart'; +import 'package:timecop/components/week_data.dart'; +import 'package:timecop/components/worktype_data.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.dart'; + +import 'TimesheetRow.dart'; + +class Timesheet { + final DateTime endOfWeekOnSunday; + final List projects; + final List workTypes; + final List timers; + + final rows = []; + + final TIMESHEET_JSON_DATE_FORMAT = 'yyyy-MM-dd'; + + // TODO use I10N for the following strings + static final Map workTypeNames = { + 'reqs': 'Requirements Gathering', + 'dev': 'Development', + 'doc': 'Documentation', + 'test': 'Testing Support', + 'dep': 'Deployment', + 'misc': 'Miscellaneous' + }; + + Timesheet( + {@required this.endOfWeekOnSunday, + @required this.projects, + @required this.workTypes, + @required this.timers}) + : assert(endOfWeekOnSunday != null), + assert(endOfWeekOnSunday.weekday == DateTime.sunday), + assert(projects != null), + assert(workTypes != null), + assert(timers != null) { + _processTimers(); + } + + void addProjectData(Project project, ProjectData projectData) { + projectData.workTypes.forEach((workTypeID, workTypeData) { + WorkType workType; + if (workTypeID != null) { + workType = WorkTypesBloc.getWorkTypeByIDFromList(workTypes, workTypeID); + } + var timesheetRow = TimesheetRow( + project: project, workType: workType, workTypeData: workTypeData); + rows.add(timesheetRow); + }); + } + + void _processTimers() { + if (timers.isNotEmpty) { + var mapOfWeeks = {}; + + for (var timer in timers) { + var discount = 0.0; + _addWorkTimeFromTimer(mapOfWeeks, timer, discount); + } + _processWeekData(mapOfWeeks[endOfWeekOnSunday]); + } + } + + void _processWeekData(WeekData weekData) { + weekData.projects.forEach((projectID, projectData) { + Project project; + if (projectID != null) { + project = ProjectsBloc.getProjectByIDFromList(projects, projectID); + } + addProjectData(project, projectData); + }); + } + + static void _addToGroup( + Map mapOfWeeks, + DateTime aEndOfWeekDate, + int aProjectID, + int aWorkTypeID, + String aNote, + int aDayOfWeekNum, + double aMins) { + var weekData = mapOfWeeks.putIfAbsent(aEndOfWeekDate, () => WeekData()); + var projectData = + weekData.projects.putIfAbsent(aProjectID, () => ProjectData()); + var workTypeData = + projectData.workTypes.putIfAbsent(aWorkTypeID, () => WorkTypeData()); + workTypeData.noteMinutes.putIfAbsent(aNote, () => 0.0); + + workTypeData.noteMinutes[aNote] += aMins; + workTypeData.weekDayMinutes[aDayOfWeekNum - 1] += aMins; + } + + static int getWeekNum(DateTime aDate) { + var dayOfYear = int.parse(DateFormat('D').format(aDate)); + return ((dayOfYear - aDate.weekday + 10) / 7).floor(); + } + + static DateTime getWeekEndOnSunday(DateTime aDate) { + var sunday = aDate.add(Duration(days: DateTime.sunday - aDate.weekday)); + return DateTime(sunday.year, sunday.month, sunday.day); + } + + static void _addWorkTimeFromTimer( + Map mapOfWeeks, TimerEntry timer, double discount) { + _addWorkTimePart(mapOfWeeks, timer, timer.startTime, discount); + } + + static void _addWorkTimePart(Map mapOfWeeks, + TimerEntry timer, DateTime startTime, double discount) { + if (startTime.isBefore(timer.endTime)) { + DateTime endTime; + if (isSameDay(startTime, timer.endTime)) { + endTime = timer.endTime; + } else { + // call this method again (recursively) to add the remaining time to + // future days + _addWorkTimePart( + mapOfWeeks, timer, getStartOfNextDay(startTime), discount); + + // since all time past midnight on the start date was just added + // via the recursive call above, below we will only process the + // time on the start date + endTime = getBeforeMidnightOfDay(startTime); + } + + var mins = endTime.difference(startTime).inMinutes.toDouble(); + if (discount > 0) { + mins = mins * (1 - discount); + } + _addToGroup(mapOfWeeks, getWeekEndOnSunday(startTime), timer.projectID, + timer.workTypeID, timer.description, startTime.weekday, mins); + } + } + + static DateTime getStartOfNextDay(DateTime aDate) { + return DateTime(aDate.year, aDate.month, aDate.day + 1, 0, 0, 0, 0); + } + + static DateTime getBeforeMidnightOfDay(DateTime aDate) { + return DateTime(aDate.year, aDate.month, aDate.day, 23, 59, 59, 999); + } + + static bool isSameDay(DateTime day1, DateTime day2) { + var day1ZeroTime = DateTime(day1.year, day1.month, day1.day); + var day2ZeroTime = DateTime(day2.year, day2.month, day2.day); + + return (day1ZeroTime.compareTo(day2ZeroTime) == 0); + } + + Map toJson() => _toJson(this); + + Map _toJson(Timesheet instance) { + return { + 'endOfWeek': DateFormat(TIMESHEET_JSON_DATE_FORMAT) + .format(instance.endOfWeekOnSunday), + 'rows': [...instance.rows.map((e) => e.toJson()).toList()], + }; + } +} diff --git a/lib/components/week_data.dart b/lib/components/week_data.dart new file mode 100644 index 00000000..5ad22ba0 --- /dev/null +++ b/lib/components/week_data.dart @@ -0,0 +1,7 @@ +import 'package:timecop/components/project_data.dart'; + +class WeekData { + final projects = Map(); + + WeekData(); +} diff --git a/lib/components/worktype_data.dart b/lib/components/worktype_data.dart new file mode 100644 index 00000000..d68cf996 --- /dev/null +++ b/lib/components/worktype_data.dart @@ -0,0 +1,6 @@ +class WorkTypeData { + final noteMinutes = Map(); + final weekDayMinutes = [0.0,0.0,0.0,0.0,0.0,0.0,0.0]; + + WorkTypeData(); +} \ No newline at end of file diff --git a/lib/data_providers/data/data_provider.dart b/lib/data_providers/data/data_provider.dart index 808f39d1..2fdc84c7 100644 --- a/lib/data_providers/data/data_provider.dart +++ b/lib/data_providers/data/data_provider.dart @@ -13,16 +13,37 @@ // 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'; abstract class DataProvider { Future createProject({@required String name, Color colour}); + 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..5c6f6f9e 100644 --- a/lib/data_providers/data/database_provider.dart +++ b/lib/data_providers/data/database_provider.dart @@ -14,18 +14,20 @@ 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/timer_entry.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.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 +43,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 +67,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 +123,10 @@ 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,70 +135,143 @@ 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); } /// 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( - id: row["id"] as int, - name: row["name"] as String, - colour: Color(row["colour"] as int))) - .toList(); + 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(); } /// 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 + 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, DateTime startTime, DateTime endTime}) async { - int st = startTime?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch; + 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"); - 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(); + 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(); } /// 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=?, 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..16f8ee78 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; } } @@ -144,9 +132,23 @@ 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]), ]; } + + 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,39 +159,51 @@ class MockDataProvider extends DataProvider { id: tid++, description: l10n[localeKey]["ui-layout"], projectID: 1, - startTime: DateTime.now().subtract(Duration(hours: 2, minutes: 10, seconds: 1)), + workTypeID: 1, + startTime: DateTime.now() + .subtract(Duration(hours: 2, minutes: 10, seconds: 1)), endTime: null, ), TimerEntry( 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'; } - 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++, description: l10n[localeKey][descriptionKey], 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), @@ -200,14 +214,19 @@ class MockDataProvider extends DataProvider { id: tid++, description: l10n[localeKey]['administration'], 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), @@ -221,18 +240,37 @@ 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 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, ); } + 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 334396a1..47459d41 100644 --- a/lib/data_providers/l10n/fluent_l10n_provider.dart +++ b/lib/data_providers/l10n/fluent_l10n_provider.dart @@ -21,18 +21,16 @@ 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()); - + 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); @@ -42,94 +40,235 @@ 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 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 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 pleaseEnterAName => _bundle.format("pleaseEnterAName", 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); + 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 timeSheetExportFileName(String date) => + _bundle.format("timeSheetExportFileName", + args: {"date": date}, errors: _errors); + String get options => _bundle.format("options", errors: _errors); + 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 timesheetExport => + _bundle.format("timesheetExport", 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) { + 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": { - switch(locale.countryCode) { - case "CN": return "中文(简体)"; - case "TW": return "中文(繁體)"; - default: return "中文"; + 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": + { + switch (locale.countryCode) { + 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 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 63643441..971e579e 100644 --- a/lib/data_providers/l10n/l10n_provider.dart +++ b/lib/data_providers/l10n/l10n_provider.dart @@ -15,66 +15,155 @@ 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; -} \ No newline at end of file + 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 timeSheetExportFileName(String date); + + String get options; + + String get groupTimers; + + String get includeDateRangeInExportFilename; + + String get includeTimeInExportFilename; + + String get timesheetExport; + + 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; +} 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 c1cbf000..ae1eb00f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,24 +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(); @@ -72,6 +73,9 @@ Future runMain(SettingsProvider settings, DataProvider data) async { BlocProvider( create: (_) => ProjectsBloc(data), ), + BlocProvider( + create: (_) => WorkTypesBloc(data), + ), ], child: TimeCopApp(settings: settings), )); @@ -79,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); @@ -93,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; @@ -102,6 +108,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()); } @@ -116,49 +123,51 @@ 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 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..cf7d4894 --- /dev/null +++ b/lib/models/WorkType.dart @@ -0,0 +1,40 @@ +// 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/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/proj_work_desc_data.dart b/lib/models/proj_work_desc_data.dart new file mode 100644 index 00000000..7c395dad --- /dev/null +++ b/lib/models/proj_work_desc_data.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 ProjWorkDescData extends Equatable { + final int project; + final int workType; + final String description; + + ProjWorkDescData(this.project, this.workType, this.description); + + @override + List get props => [project, workType, description]; +} diff --git a/lib/models/project.dart b/lib/models/project.dart index dcabb311..ba595c9b 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -21,18 +21,29 @@ 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, + ); + + String getProjectCode() { + var projectCode = name; + var spaceInProject = projectCode.indexOf(' '); + if (spaceInProject > 0) { + projectCode = projectCode.substring(0, spaceInProject); + } + return projectCode; + } +} 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 edc4d3c2..8ffce28d 100644 --- a/lib/models/timer_entry.dart +++ b/lib/models/timer_entry.dart @@ -19,37 +19,53 @@ 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 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, 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"); - } - else { - return - d.inMinutes.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"); } } @@ -57,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 8d591761..433d489f 100644 --- a/lib/screens/dashboard/DashboardScreen.dart +++ b/lib/screens/dashboard/DashboardScreen.dart @@ -16,6 +16,7 @@ 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'; @@ -23,6 +24,7 @@ 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); @@ -30,11 +32,11 @@ 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), + return BlocProvider( + create: (_) => DashboardBloc(projectsBloc, workTypesBloc, settingsBloc), child: Scaffold( appBar: TopBar(), body: Column( @@ -59,12 +61,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, @@ -77,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 db28126d..526017d2 100644 --- a/lib/screens/dashboard/bloc/dashboard_bloc.dart +++ b/lib/screens/dashboard/bloc/dashboard_bloc.dart @@ -2,8 +2,11 @@ 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/blocs/work_types/bloc.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; part 'dashboard_event.dart'; @@ -11,50 +14,124 @@ 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, DateTime.now().subtract(Duration(days: 30)), null, [], null); + Project newProject = + projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); + WorkType newWorkType = + workTypesBloc.getWorkTypeByID(settingsBloc.state.defaultWorkTypeID); + + return DashboardState("", newProject, newWorkType, false, + settingsBloc.getFilterStartDate(), null, [], null); } @override Stream mapEventToState( DashboardEvent event, ) async* { - if(event is DescriptionChangedEvent) { - yield DashboardState(event.description, state.newProject, 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); - } - 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) { - Project newProject = projectsBloc.getProjectByID(settingsBloc.state.defaultProjectID); - yield DashboardState("", newProject, false, state.filterStart, state.filterEnd, state.hiddenProjects, state.searchString); - } - else if(event is FilterStartChangedEvent) { + 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("", 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) { 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); - } - 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) { - yield DashboardState(state.newDescription, state.newProject, 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, + 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 631e29bf..07003f5e 100644 --- a/lib/screens/dashboard/bloc/dashboard_event.dart +++ b/lib/screens/dashboard/bloc/dashboard_event.dart @@ -6,46 +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]; } 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 c1a38409..14aa020e 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,31 @@ class DashboardState extends Equatable { DashboardState state, DateTime filterStart, DateTime filterEnd, - String searchString, - { + String searchString, { String newDescription, Project newProject, + WorkType newWorkType, bool timerWasStarted, List hiddenProjects, - }) - : this( - newDescription ?? state.newDescription, - newProject ?? state.newProject, - timerWasStarted ?? state.timerWasStarted, + }) : 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, + newWorkType, + timerWasStarted, filterStart, filterEnd, - hiddenProjects ?? state.hiddenProjects, + hiddenProjects, searchString - ); - - @override - List get props => [newDescription, newProject, 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 7bebb57e..7fc991aa 100644 --- a/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart +++ b/lib/screens/dashboard/components/GroupedStoppedTimersRow.dart @@ -17,63 +17,62 @@ 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/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}) : 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; Animation _iconTurns; @override - void initState() { + 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)); } @override - void dispose() { + void dispose() { _controller.dispose(); 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, @@ -81,22 +80,18 @@ class _GroupedStoppedTimersRowState extends State with onExpansionChanged: (expanded) { setState(() { _expanded = expanded; - if(_expanded) { + if (_expanded) { _controller.forward(); - } - else { + } else { _controller.reverse(); } }); }, leading: ProjectColour( - project: BlocProvider.of(context) - .getProjectByID(widget.timers[0].projectID) - ), - title: Text( - formatDescription(context, widget.timers[0].description), - style: styleDescription(context, widget.timers[0].description) - ), + project: BlocProvider.of(context) + .getProjectByID(widget.timers[0].projectID)), + title: timerTileBuilder.getTitleWidget(widget.timers[0]), + subtitle: timerTileBuilder.getSubTitleWidget(widget.timers[0]), trailing: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -107,32 +102,48 @@ 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) - ) - ), - style: TextStyle(fontFamily: "FiraMono") - ), + TimerEntry.formatDuration(widget.timers.fold( + Duration(), + (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( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.play, - onTap: () { - final TimersBloc timersBloc = BlocProvider.of(context); + 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); + 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)); - } - ) + 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, + workType: workType)); + }) ], ); } diff --git a/lib/screens/dashboard/components/PopupMenu.dart b/lib/screens/dashboard/components/PopupMenu.dart index 970b2c4d..1c28d6d6 100644 --- a/lib/screens/dashboard/components/PopupMenu.dart +++ b/lib/screens/dashboard/components/PopupMenu.dart @@ -21,9 +21,15 @@ 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 +46,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 +89,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( @@ -114,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 0515d330..25729059 100644 --- a/lib/screens/dashboard/components/RunningTimerRow.dart +++ b/lib/screens/dashboard/components/RunningTimerRow.dart @@ -23,42 +23,39 @@ 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; - } + const RunningTimerRow({Key key, @required this.timer, @required this.now}) + : 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,24 +63,24 @@ 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) { - final TimersBloc timersBloc = BlocProvider.of(context); + 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)); } @@ -104,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 228bcb2b..7a18ed97 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,49 @@ 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, + workType: bloc.state.newWorkType)); + bloc.add(TimerWasStartedEvent()); + }, + ); + } else { + return StartTimerSpeedDial(); } - ); + }); } } diff --git a/lib/screens/dashboard/components/StartTimerSpeedDial.dart b/lib/screens/dashboard/components/StartTimerSpeedDial.dart index e6a9a21a..86654144 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. @@ -25,11 +25,12 @@ class StartTimerSpeedDial extends StatefulWidget { _StartTimerSpeedDialState createState() => _StartTimerSpeedDialState(); } -class _StartTimerSpeedDialState extends State with TickerProviderStateMixin { +class _StartTimerSpeedDialState extends State + with TickerProviderStateMixin { AnimationController _controller; @override - void initState() { + void initState() { super.initState(); _controller = AnimationController( vsync: this, @@ -38,7 +39,7 @@ class _StartTimerSpeedDialState extends State with TickerPr } @override - void dispose() { + void dispose() { _controller.dispose(); super.dispose(); } @@ -47,133 +48,128 @@ 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, - 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 - ), - ), - 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()); - }, + return Column(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), + ), + 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, + workType: bloc.state.newWorkType)); + 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 - ), - ), - 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()); - }, + ), + 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()); + }, ), ), - 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..8970a6f1 100644 --- a/lib/screens/dashboard/components/StoppedTimerRow.dart +++ b/lib/screens/dashboard/components/StoppedTimerRow.dart @@ -17,51 +17,50 @@ 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/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; - } + const StoppedTimerRow({Key key, @required this.timer}) + : assert(timer != null), + super(key: key); @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)), - 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: 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, @@ -69,24 +68,24 @@ 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) { - final TimersBloc timersBloc = BlocProvider.of(context); + 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,19 +94,36 @@ class StoppedTimerRow extends StatelessWidget { ], secondaryActions: [ IconSlideAction( - color: Theme.of(context).accentColor, - foregroundColor: Theme.of(context).accentIconTheme.color, - icon: FontAwesomeIcons.play, - onTap: () { - final TimersBloc timersBloc = BlocProvider.of(context); + 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); + final ProjectsBloc projectsBloc = + BlocProvider.of(context); assert(projectsBloc != null); Project project = projectsBloc.getProjectByID(timer.projectID); - timersBloc.add(CreateTimer(description: timer.description, project: project)); - } - ) + + 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, + 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 e9eee263..ac590d71 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/project_description_pair.dart'; import 'package:timecop/models/timer_entry.dart'; +import 'package:timecop/models/proj_work_desc_data.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,42 +36,43 @@ 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)); - - LinkedHashMap> pairedEntries = LinkedHashMap(); - for(TimerEntry entry in entries) { - ProjectDescriptionPair pair = ProjectDescriptionPair(entry.projectID, entry.description); - if(pairedEntries.containsKey(pair)) { + 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) { + ProjWorkDescData pair = ProjWorkDescData( + entry.projectID, entry.workTypeID, entry.description); + if (pairedEntries.containsKey(pair)) { pairedEntries[pair].add(entry); - } - else { + } 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 { + } 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) { + if (settingsBloc.state.collapseDays) { return CollapsibleDayGrouping( date: date, totalTime: runningTotal, children: theDaysTimers, ); - } - else { + } else { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -83,20 +85,15 @@ class DayGrouping { mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ - Text( - _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", - ) - ) + Text(_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", + )) ], ), Divider(), @@ -113,19 +110,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); - if(newDay) { - days.add( - DayGrouping( - DateTime( - timer.startTime.year, - timer.startTime.month, - 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( + 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,52 +141,51 @@ 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) { - timers = timers.where((timer) => timer.startTime.isAfter(dashboardState.filterStart)); + if (dashboardState.filterStart != null) { + timers = timers.where((timer) => + timer.startTime.isAfter(dashboardState.filterStart)); } - if(dashboardState.filterEnd != null) { - timers = timers.where((timer) => timer.startTime.isBefore(dashboardState.filterEnd)); + 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)); + timers = timers.where((t) => + !dashboardState.hiddenProjects.any((p) => p == t.projectID)); // filter based on search - 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( + 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( 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; + } + }); } List days = timers.fold([], groupDays); 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 new file mode 100644 index 00000000..e1b82928 --- /dev/null +++ b/lib/screens/dashboard/components/TimerTileBuilder.dart @@ -0,0 +1,73 @@ +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/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/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/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 37ad2eff..6765e0ee 100644 --- a/lib/screens/export/ExportScreen.dart +++ b/lib/screens/export/ExportScreen.dart @@ -12,27 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:collection'; -import 'dart:io'; -import 'package:path/path.dart' as p; -import 'package:flutter_share/flutter_share.dart'; -import 'package:csv/csv.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_datetime_picker/flutter_datetime_picker.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite/sqflite.dart'; 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/components/WorkTypeBadge.dart'; +import 'package:timecop/components/exporter.dart'; +import 'package:timecop/components/exporter_data.dart'; import 'package:timecop/l10n.dart'; +import 'package:timecop/models/WorkType.dart'; import 'package:timecop/models/project.dart'; -import 'package:timecop/models/project_description_pair.dart'; import 'package:timecop/models/timer_entry.dart'; class ExportScreen extends StatefulWidget { @@ -46,62 +41,50 @@ class DayGroup { final DateTime date; List timers = []; - DayGroup(this.date) - : assert(date != null); + DayGroup(this.date) : assert(date != null); } class _ExportScreenState extends State { - DateTime _startDate; - DateTime _endDate; - List selectedProjects = []; - static DateFormat _dateFormat = DateFormat("EE, MMM d, yyyy"); - static DateFormat _exportDateFormat = DateFormat.yMd(); - final GlobalKey _scaffoldKey = GlobalKey(); + ExporterData _exporterData = new ExporterData(); @override void initState() { super.initState(); final ProjectsBloc projects = BlocProvider.of(context); assert(projects != null); - selectedProjects = [null].followedBy(projects.state.projects.map((p) => Project.clone(p))).toList(); + _exporterData.selectedProjects = [null] + .followedBy(projects.state.projects.map((p) => Project.clone(p))) + .toList(); + + final WorkTypesBloc workTypes = BlocProvider.of(context); + assert(workTypes != null); + _exporterData.selectedWorkTypes = [null] + .followedBy(workTypes.state.workTypes.map((p) => WorkType.clone(p))) + .toList(); + + final SettingsBloc settingsBloc = BlocProvider.of(context); + _exporterData.startDate = settingsBloc.getFilterStartDate(); } @override Widget build(BuildContext context) { final SettingsBloc settingsBloc = BlocProvider.of(context); final ProjectsBloc projectsBloc = BlocProvider.of(context); + final WorkTypesBloc workTypesBloc = BlocProvider.of(context); + final Exporter exporter = + Exporter(context: context, exporterData: _exporterData); - // TODO: break this into components or something so we don't have such a massively unmanagement build function + // TODO: break this into components or something so we don't have such a massively unmanageable build function return Scaffold( - key: _scaffoldKey, + key: _exporterData.scaffoldKey, appBar: AppBar( title: Text(L10N.of(context).tr.export), actions: [ IconButton( icon: Icon(FontAwesomeIcons.database), onPressed: () async { - var databasesPath = await getDatabasesPath(); - var dbPath = p.join(databasesPath, 'timecop.db'); - - try { - // 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")); - 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), - ) - ); - } + exporter.exportDatabase(); }, ) ], @@ -109,13 +92,10 @@ class _ExportScreenState extends State { body: ListView( children: [ ExpansionTile( - title: Text( - L10N.of(context).tr.filter, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + title: Text(L10N.of(context).tr.filter, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), initiallyExpanded: true, children: [ Slidable( @@ -126,38 +106,42 @@ 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(_exporterData.startDate == null + ? "—" + : ExporterData.dateFormat + .format(_exporterData.startDate)), ), 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)), - 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, - ) - ); + await DatePicker.showDatePicker(context, + currentTime: _exporterData.startDate, + onChanged: (DateTime dt) => setState(() => _exporterData + .startDate = DateTime(dt.year, dt.month, dt.day)), + onConfirm: (DateTime dt) => setState(() => _exporterData + .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 + secondaryActions: _exporterData.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(() { + _exporterData.startDate = null; + }); + }, + ) + ], ), Slidable( actionPane: SlidableDrawerActionPane(), @@ -167,106 +151,121 @@ 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(_exporterData.endDate == null + ? "—" + : ExporterData.dateFormat + .format(_exporterData.endDate)), ), 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)), - 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, - ) - ); + await DatePicker.showDatePicker(context, + currentTime: _exporterData.endDate, + onChanged: (DateTime dt) => setState(() => + _exporterData.endDate = DateTime( + dt.year, dt.month, dt.day, 23, 59, 59, 999)), + onConfirm: (DateTime dt) => setState(() => + _exporterData.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, + )); }, ), - secondaryActions: - _endDate == null + secondaryActions: _exporterData.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(() { + _exporterData.endDate = null; + }); + }, + ) + ], ), ], ), 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, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + title: Text(L10N.of(context).tr.columns, + style: TextStyle( + color: Theme.of(context).accentColor, + 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.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, - 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, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), + title: Text(L10N.of(context).tr.projects, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -277,7 +276,7 @@ class _ExportScreenState extends State { child: Text("Select None"), onPressed: () { setState(() { - selectedProjects.clear(); + _exporterData.selectedProjects.clear(); }); }, ), @@ -285,190 +284,167 @@ class _ExportScreenState extends State { child: Text("Select All"), onPressed: () { setState(() { - selectedProjects = [null].followedBy(projectsBloc.state.projects.map((p) => Project.clone(p))).toList(); + _exporterData.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), - 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(), + ] + .followedBy([null] + .followedBy(projectsBloc.state.projects) + .map((project) => CheckboxListTile( + secondary: ProjectColour( + project: project, + ), + title: Text( + project?.name ?? L10N.of(context).tr.noProject), + value: _exporterData.selectedProjects + .any((p) => p?.id == project?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) => setState(() { + if (_exporterData.selectedProjects + .any((p) => p?.id == project?.id)) { + _exporterData.selectedProjects + .removeWhere((p) => p?.id == project?.id); + } else { + _exporterData.selectedProjects.add(project); + } + }), + ))) + .toList(), ), ExpansionTile( - title: Text( - L10N.of(context).tr.options, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 + 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(() { + _exporterData.selectedWorkTypes.clear(); + }); + }, + ), + RaisedButton( + child: Text("Select All"), + onPressed: () { + setState(() { + _exporterData.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: _exporterData.selectedWorkTypes + .any((p) => p?.id == workType?.id), + activeColor: Theme.of(context).accentColor, + onChanged: (_) => setState(() { + if (_exporterData.selectedWorkTypes + .any((p) => p?.id == workType?.id)) { + _exporterData.selectedWorkTypes + .removeWhere((p) => p?.id == workType?.id); + } else { + _exporterData.selectedWorkTypes.add(workType); + } + }), + ))) + .toList(), + ), + ExpansionTile( + title: Text(L10N.of(context).tr.options, + style: TextStyle( + color: Theme.of(context).accentColor, + 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, + ), + ), + 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, + ), + ), + BlocBuilder( + bloc: settingsBloc, + builder: (BuildContext context, SettingsState settingsState) => + SwitchListTile( + title: Text(L10N.of(context).tr.timesheetExport), + value: settingsState.exportTimesheet, + onChanged: (bool value) => settingsBloc + .add(SetBoolValueEvent(exportTimesheet: value)), activeColor: Theme.of(context).accentColor, ), ) ], ), - ] - .toList(), + ].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); - } - - 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)); - - // 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)); - - // 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); - } - - // 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); - - // 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)); - }); - }) - .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; - } + 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), ) - ).toList(); - String csv = ListToCsvConverter().convert(data); - print('CSV:'); - print(csv); - - 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); - } - ), + ], + ), + onPressed: () async { + exporter.exportTimeEntries(); + }), ); } } 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 ed53aebc..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.areYouSureYouWantToDelete + "\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 4eb7bb56..92318b95 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'; @@ -49,16 +50,21 @@ 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(); - _startDate = DateTime.now().subtract(Duration(days: 30)); + selectedProjects = [null] + .followedBy(projects.state.projects.map((p) => Project.clone(p))) + .toList(); + + 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)){ - _endDate = _startDate.add(Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); + if (_endDate != null && _startDate.isAfter(_endDate)) { + _endDate = _startDate.add( + Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999)); } }); } @@ -66,226 +72,240 @@ 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), ), - control: SwiperControl(iconPrevious: null, iconNext: null), ), - ), - ExpansionTile( - title: Text( - L10N.of(context).tr.filter, - style: TextStyle( - color: Theme.of(context).accentColor, - fontWeight: FontWeight.w700 - ) - ), - 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, - ) - ); + ExpansionTile( + title: Text(L10N.of(context).tr.filter, + style: TextStyle( + color: Theme.of(context).accentColor, + fontWeight: FontWeight.w700)), + 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; - }); - } - }, - ), - 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; - }); - } - }, - ), - 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/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 e4bf0b2e..a77efe33 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,84 @@ 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)), + 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)), + 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)), + 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, + ), + ), + 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, + ), + ), + 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/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 9830aea5..5c45cf61 100644 --- a/lib/screens/timer/TimerEditor.dart +++ b/lib/screens/timer/TimerEditor.dart @@ -24,17 +24,21 @@ import 'package:intl/intl.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/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/clone_time.dart'; import 'package:timecop/models/project.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 +54,7 @@ class _TimerEditorState extends State { DateTime _oldEndTime; Project _project; + WorkType _workType; FocusNode _descriptionFocus; final _formKey = GlobalKey(); Timer _updateTimer; @@ -60,13 +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); + _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 @@ -82,7 +92,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); } @@ -104,51 +114,107 @@ class _TimerEditorState extends State { child: ListView( 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(), - ) - ), + 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(), + )), + ), + 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), - child: - settingsBloc.state.autocompleteDescription + child: settingsBloc.state.autocompleteDescription ? TypeAheadField( direction: AxisDirection.down, textFieldConfiguration: TextFieldConfiguration( @@ -160,19 +226,22 @@ 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 []; + 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; }, ) @@ -194,22 +263,22 @@ class _TimerEditorState extends State { onTap: () async { _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, - ) - ); + 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, + )); // if the user cancelled, this should be null - if(newStartTime == null) { + if (newStartTime == null) { setState(() { _startTime = _oldStartTime; _endTime = _oldEndTime; @@ -235,70 +304,73 @@ 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( - 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; }); } }, ), - secondaryActions: - _endTime == null + secondaryActions: _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(), 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))), ), ), ], @@ -320,13 +392,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(), ); @@ -337,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 new file mode 100644 index 00000000..0ce3a75b --- /dev/null +++ b/lib/screens/workTypes/WorkTypeEditor.dart @@ -0,0 +1,116 @@ +// 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..cb880227 --- /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/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'; + +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 675b41ea..1427792b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,42 +7,49 @@ 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: 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" + badges: + dependency: "direct main" + description: + name: badges + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" bloc: dependency: transitive description: @@ -56,21 +63,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: @@ -84,14 +91,14 @@ packages: name: coverage url: "https://pub.dartlang.org" source: hosted - version: "0.13.8" + version: "0.13.9" crypto: dependency: transitive description: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.3" + version: "2.1.4" csslib: 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: @@ -159,7 +166,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 @@ -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: @@ -225,7 +232,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: @@ -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,28 +303,28 @@ packages: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.1.4" image: dependency: transitive description: 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: 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: @@ -513,21 +534,21 @@ 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: name: random_color url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.5" resource: dependency: transitive description: @@ -555,28 +576,28 @@ 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: 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 @@ -616,7 +644,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,14 +658,21 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.5.5" + version: "1.7.0" sqflite: dependency: "direct main" description: 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: @@ -659,6 +694,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 +721,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: @@ -735,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: @@ -763,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: @@ -777,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: @@ -785,20 +827,34 @@ 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: 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.4 <2.0.0" + flutter: ">=1.17.1 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index ca71f2be..88c9c071 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,24 +12,25 @@ 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 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 flutter_typeahead: ^1.8.0 + badges: ^1.1.1 dev_dependencies: pedantic: ^1.0.0 diff --git a/terms.flt b/terms.flt index dfd04faf..d43acd4e 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,25 +42,37 @@ to = To project = Project +workType = Work Type + description = Description timeH = Time (hours) timeCopEntries = Time Cop Entries ({$date}) +timeSheetExportFileName = Time Cop TimeSheet ({$date}).json + 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 @@ -78,6 +94,12 @@ options = Options groupTimers = Group Similar Timers Per Day +includeDateRangeInExportFilename = Include Date Range in Export Filename + +includeTimeInExportFilename = Include Current Time in Export Filename + +timesheetExport = Timesheet Export + columns = Columns date = Date @@ -119,6 +141,12 @@ collapseDays = Collapse Days autocompleteDescription = Autocomplete Descriptions +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/test/project_name_test.dart b/test/project_name_test.dart new file mode 100644 index 00000000..b245599f --- /dev/null +++ b/test/project_name_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timecop/models/project.dart'; + +void main() { + group('project name tests', () { + test('compare various project name values', () { + var project = Project(id: 1, name: 'personal', colour: Colors.red); + expect(project?.name != 'personal', equals(false)); + + var project2 = Project(id: 2, name: 'test', colour: Colors.blue); + expect(project2?.name != 'personal', equals(true)); + + expect(() => Project(id: 3, name: null, colour: Colors.green), + throwsAssertionError); + + Project project4; + expect(project4?.name != 'personal', equals(true)); + + expect(null != 'personal', equals(true)); + }); + }); +} diff --git a/test/timesheet_test.dart b/test/timesheet_test.dart new file mode 100644 index 00000000..5b3ee48a --- /dev/null +++ b/test/timesheet_test.dart @@ -0,0 +1,63 @@ +import 'package:test/test.dart'; +import 'package:timecop/components/timesheet.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.dart'; +import 'package:flutter/material.dart'; + +void main() { + group('Timesheet', () { + var endOfWeekOnSunday = Timesheet.getWeekEndOnSunday( + DateTime.now().subtract(Duration(days: 7))); + + var projects = []; + var workTypes = []; + var timers = []; + + test('rows should be empty', () { + var timesheet = Timesheet( + endOfWeekOnSunday: endOfWeekOnSunday, + projects: projects, + workTypes: workTypes, + timers: timers); + expect(timesheet.rows.isEmpty, true); + }); + + var project2 = + Project(id: 2, name: 'ADHOC0998953 A task for Joe', colour: Colors.red); + projects.add(project2); + var workType2 = WorkType(id: 2, name: 'doc', colour: Colors.blue); + workTypes.add(workType2); + var startTime2 = + endOfWeekOnSunday.subtract(Duration(days: 3, hours: 8, minutes: 23)); + var note2 = 'Elaine: doc mtg'; + var timerEntry2 = TimerEntry( + id: 2, + description: note2, + projectID: project2.id, + workTypeID: workType2.id, + startTime: startTime2, + endTime: startTime2.add(Duration(days: 2)), + ); + + test('multi-day events should be split across days', () { + timers.add(timerEntry2); + var timesheet = Timesheet( + endOfWeekOnSunday: endOfWeekOnSunday, + projects: projects, + workTypes: workTypes, + timers: timers); + expect(timesheet.rows.isEmpty, equals(false)); + expect(timesheet.rows.length, equals(1)); + var tsRow = timesheet.rows[0]; + expect(tsRow.project.name, equals(project2.name)); + expect(tsRow.workType.name, equals(workType2.name)); + expect(tsRow.notes, equals('${note2}[47h 58m]')); + expect(tsRow.dayHrs[startTime2.weekday - 1], equals(8.25)); + expect(tsRow.dayHrs[startTime2.weekday], equals(24.0)); + expect(tsRow.dayHrs[startTime2.weekday + 1], equals(15.5)); + + print(timesheet.toJson()); + }); + }); +} diff --git a/test/timesheet_test2.dart b/test/timesheet_test2.dart new file mode 100644 index 00000000..80a73592 --- /dev/null +++ b/test/timesheet_test2.dart @@ -0,0 +1,51 @@ +import 'package:test/test.dart'; +import 'package:timecop/blocs/work_types/bloc.dart'; +import 'package:timecop/components/TimesheetRow.dart'; +import 'package:timecop/components/timesheet.dart'; +import 'package:timecop/models/WorkType.dart'; +import 'package:timecop/models/project.dart'; +import 'package:timecop/models/timer_entry.dart'; +import 'package:flutter/material.dart'; + +void main() { + group('Timesheet2', () { + var endOfWeekOnSunday = Timesheet.getWeekEndOnSunday( + DateTime.now().subtract(Duration(days: 7))); + + var projects = []; + var workTypes = []; + var timers = []; + + test('rows should have 1 element', () { + var project = Project(id: 1, name: 'ITR6203', colour: Colors.red); + projects.add(project); + var workType = WorkType(id: 1, name: 'dev', colour: Colors.blue); + workTypes.add(workType); + var startTime = + endOfWeekOnSunday.subtract(Duration(days: 4, hours: 14, minutes: 56)); + var note = 'Jill: testing'; + var timerEntry1 = TimerEntry( + id: 1, + description: note, + projectID: project.id, + workTypeID: workType.id, + startTime: startTime, + endTime: startTime.add(Duration(minutes: 13)), + ); + + timers.add(timerEntry1); + var timesheet = Timesheet( + endOfWeekOnSunday: endOfWeekOnSunday, + projects: projects, + workTypes: workTypes, + timers: timers); + expect(timesheet.rows.isEmpty, equals(false)); + expect(timesheet.rows.length, equals(1)); + var tsRow = timesheet.rows[0]; + expect(tsRow.project.name, equals(project.name)); + expect(tsRow.workType.name, equals(workType.name)); + expect(tsRow.notes, equals('${note}[13m]')); + expect(tsRow.dayHrs[startTime.weekday - 1], equals(15 / 60)); + }); + }); +}