mirror of
https://github.com/hamaluik/timecop.git
synced 2025-08-24 15:16:18 +08:00
500 lines
22 KiB
Dart
500 lines
22 KiB
Dart
// 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:collection';
|
|
import 'dart:io';
|
|
import 'package:cross_file/cross_file.dart';
|
|
import 'package:csv/csv.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'
|
|
as dt;
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:timecop/blocs/projects/projects_bloc.dart';
|
|
import 'package:timecop/blocs/settings/bloc.dart';
|
|
import 'package:timecop/blocs/timers/bloc.dart';
|
|
import 'package:timecop/components/ProjectColour.dart';
|
|
import 'package:timecop/l10n.dart';
|
|
import 'package:timecop/models/project.dart';
|
|
import 'package:timecop/models/project_description_pair.dart';
|
|
import 'package:timecop/models/timer_entry.dart';
|
|
import 'package:timecop/screens/export/components/ExportMenu.dart';
|
|
|
|
class ExportScreen extends StatefulWidget {
|
|
const ExportScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<ExportScreen> createState() => _ExportScreenState();
|
|
}
|
|
|
|
class DayGroup {
|
|
final DateTime date;
|
|
List<TimerEntry> timers = [];
|
|
|
|
DayGroup(this.date);
|
|
}
|
|
|
|
class _ExportScreenState extends State<ExportScreen> {
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
List<Project?> selectedProjects = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final projects = BlocProvider.of<ProjectsBloc>(context);
|
|
selectedProjects = <Project?>[null]
|
|
.followedBy(projects.state.projects
|
|
.where((p) => !p.archived)
|
|
.map((p) => Project.clone(p)))
|
|
.toList();
|
|
|
|
final settingsBloc = BlocProvider.of<SettingsBloc>(context);
|
|
_startDate = settingsBloc.getFilterStartDate();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settingsBloc = BlocProvider.of<SettingsBloc>(context);
|
|
final projectsBloc = BlocProvider.of<ProjectsBloc>(context);
|
|
|
|
final dateFormat = DateFormat.yMMMEd();
|
|
final exportDateFormat = DateFormat.yMd();
|
|
|
|
// TODO: break this into components or something so we don't have such a massively unmanagement build function
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(L10N.of(context).tr.exportImport),
|
|
actions: <Widget>[
|
|
ExportMenu(dateFormat: dateFormat),
|
|
],
|
|
),
|
|
body: ListView(
|
|
children: <Widget>[
|
|
ExpansionTile(
|
|
title: Text(L10N.of(context).tr.filter,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
fontWeight: FontWeight.w700)),
|
|
initiallyExpanded: true,
|
|
children: <Widget>[
|
|
ListTile(
|
|
leading: const Icon(FontAwesomeIcons.calendar),
|
|
title: Text(L10N.of(context).tr.from),
|
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
_startDate == null
|
|
? const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 18),
|
|
child: Text("--"),
|
|
)
|
|
: Text(dateFormat.format(_startDate!)),
|
|
if (_startDate != null)
|
|
IconButton(
|
|
tooltip: L10N.of(context).tr.remove,
|
|
icon: const Icon(FontAwesomeIcons.circleMinus),
|
|
onPressed: () {
|
|
setState(() {
|
|
_startDate = null;
|
|
});
|
|
},
|
|
)
|
|
]),
|
|
onTap: () async {
|
|
await dt.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: dt.DatePickerTheme(
|
|
cancelStyle: Theme.of(context).textTheme.button!,
|
|
doneStyle: Theme.of(context).textTheme.button!,
|
|
itemStyle: Theme.of(context).textTheme.bodyText2!,
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
));
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(FontAwesomeIcons.calendar),
|
|
title: Text(L10N.of(context).tr.to),
|
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
_endDate == null
|
|
? const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 18),
|
|
child: Text("--"),
|
|
)
|
|
: Text(dateFormat.format(_endDate!)),
|
|
if (_endDate != null)
|
|
IconButton(
|
|
tooltip: L10N.of(context).tr.remove,
|
|
icon: const Icon(FontAwesomeIcons.circleMinus),
|
|
onPressed: () {
|
|
setState(() {
|
|
_endDate = null;
|
|
});
|
|
},
|
|
)
|
|
]),
|
|
onTap: () async {
|
|
await dt.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: dt.DatePickerTheme(
|
|
cancelStyle: Theme.of(context).textTheme.button!,
|
|
doneStyle: Theme.of(context).textTheme.button!,
|
|
itemStyle: Theme.of(context).textTheme.bodyText2!,
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
BlocBuilder<SettingsBloc, SettingsState>(
|
|
bloc: settingsBloc,
|
|
builder: (BuildContext context, SettingsState settingsState) =>
|
|
ExpansionTile(
|
|
key: const Key("optionColumns"),
|
|
title: Text(L10N.of(context).tr.columns,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
fontWeight: FontWeight.w700)),
|
|
children: <Widget>[
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.date),
|
|
value: settingsState.exportIncludeDate,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeDate: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.project),
|
|
value: settingsState.exportIncludeProject,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeProject: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.description),
|
|
value: settingsState.exportIncludeDescription,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeDescription: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.combinedProjectDescription),
|
|
value: settingsState.exportIncludeProjectDescription,
|
|
onChanged: (bool value) => settingsBloc.add(SetBoolValueEvent(
|
|
exportIncludeProjectDescription: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.startTime),
|
|
value: settingsState.exportIncludeStartTime,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeStartTime: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.endTime),
|
|
value: settingsState.exportIncludeEndTime,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeEndTime: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.timeH),
|
|
value: settingsState.exportIncludeDurationHours,
|
|
onChanged: (bool value) => settingsBloc.add(
|
|
SetBoolValueEvent(exportIncludeDurationHours: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.notes),
|
|
value: settingsState.exportIncludeNotes,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportIncludeNotes: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
ExpansionTile(
|
|
title: Text(L10N.of(context).tr.projects,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
fontWeight: FontWeight.w700)),
|
|
children: <Widget>[
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: <Widget>[
|
|
ElevatedButton(
|
|
child: Text(L10N.of(context).tr.selectNone),
|
|
onPressed: () {
|
|
setState(() {
|
|
selectedProjects.clear();
|
|
});
|
|
},
|
|
),
|
|
ElevatedButton(
|
|
child: Text(L10N.of(context).tr.selectAll),
|
|
onPressed: () {
|
|
setState(() {
|
|
selectedProjects = <Project?>[null]
|
|
.followedBy(projectsBloc.state.projects
|
|
.where((p) => !p.archived)
|
|
.map((p) => Project.clone(p)))
|
|
.toList();
|
|
});
|
|
},
|
|
),
|
|
],
|
|
)
|
|
]
|
|
.followedBy(<Project?>[null]
|
|
.followedBy(
|
|
projectsBloc.state.projects.where((p) => !p.archived))
|
|
.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).colorScheme.secondary,
|
|
onChanged: (_) => setState(() {
|
|
if (selectedProjects
|
|
.any((p) => p?.id == project?.id)) {
|
|
selectedProjects
|
|
.removeWhere((p) => p?.id == project?.id);
|
|
} else {
|
|
selectedProjects.add(project);
|
|
}
|
|
}),
|
|
)))
|
|
.toList(),
|
|
),
|
|
ExpansionTile(
|
|
title: Text(L10N.of(context).tr.options,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
fontWeight: FontWeight.w700)),
|
|
children: <Widget>[
|
|
BlocBuilder<SettingsBloc, SettingsState>(
|
|
bloc: settingsBloc,
|
|
builder: (BuildContext context, SettingsState settingsState) =>
|
|
SwitchListTile.adaptive(
|
|
title: Text(L10N.of(context).tr.groupTimers),
|
|
value: settingsState.exportGroupTimers,
|
|
onChanged: (bool value) => settingsBloc
|
|
.add(SetBoolValueEvent(exportGroupTimers: value)),
|
|
activeColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
].toList(),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
key: const Key("exportFAB"),
|
|
tooltip: L10N.of(context).tr.exportCSV,
|
|
child: const 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: <Widget>[
|
|
Positioned(
|
|
top: 15,
|
|
left: 19,
|
|
child: Icon(FontAwesomeIcons.fileExport),
|
|
)
|
|
],
|
|
),
|
|
onPressed: () async {
|
|
final timers = BlocProvider.of<TimersBloc>(context);
|
|
final projects = BlocProvider.of<ProjectsBloc>(context);
|
|
|
|
List<String> 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);
|
|
}
|
|
if (settingsBloc.state.exportIncludeNotes) {
|
|
headers.add(L10N.of(context).tr.notes);
|
|
}
|
|
|
|
List<TimerEntry> filteredTimers = timers.state.timers
|
|
.where((t) => t.endTime != null)
|
|
.where((t) => selectedProjects.any((p) => p?.id == t.projectID))
|
|
.where((t) => _startDate == null
|
|
? true
|
|
: t.startTime.isAfter(_startDate!))
|
|
.where((t) =>
|
|
_endDate == null ? true : t.endTime!.isBefore(_endDate!))
|
|
.where((t) =>
|
|
!(projects.getProjectByID(t.projectID)?.archived == true))
|
|
.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!))
|
|
.where((t) =>
|
|
!(projects.getProjectByID(t.projectID)?.archived == true))
|
|
.toList();
|
|
filteredTimers.sort((a, b) => a.startTime.compareTo(b.startTime));
|
|
|
|
// now start grouping those suckers
|
|
final LinkedHashMap<String,
|
|
LinkedHashMap<ProjectDescriptionPair, List<TimerEntry>>>
|
|
derp = LinkedHashMap();
|
|
for (TimerEntry timer in filteredTimers) {
|
|
String date = exportDateFormat.format(timer.startTime);
|
|
LinkedHashMap<ProjectDescriptionPair, List<TimerEntry>>
|
|
pairedEntries =
|
|
derp.putIfAbsent(date, () => LinkedHashMap());
|
|
List<TimerEntry> pairedList = pairedEntries.putIfAbsent(
|
|
ProjectDescriptionPair(timer.projectID, timer.description),
|
|
() => <TimerEntry>[]);
|
|
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<ProjectDescriptionPair, List<TimerEntry>>
|
|
pairedEntries) {
|
|
return pairedEntries.values
|
|
.map((List<TimerEntry> 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(
|
|
const Duration(),
|
|
(Duration d, TimerEntry t) =>
|
|
d + t.endTime!.difference(t.startTime));
|
|
return TimerEntry.clone(groupedEntries[0],
|
|
endTime: groupedEntries[0].startTime.add(totalTime));
|
|
});
|
|
}).toList();
|
|
}
|
|
|
|
final List<List<String>> data =
|
|
<List<String>>[headers].followedBy(filteredTimers.map((timer) {
|
|
List<String> 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));
|
|
}
|
|
if (settingsBloc.state.exportIncludeNotes) {
|
|
row.add(timer.notes?.trim() ?? "");
|
|
}
|
|
return row;
|
|
})).toList();
|
|
final csv =
|
|
const ListToCsvConverter(delimitAllFields: true).convert(data);
|
|
|
|
final csvFilename =
|
|
"timecop_${DateTime.now().toIso8601String().split('T').first}.csv";
|
|
|
|
if (Platform.isMacOS || Platform.isLinux) {
|
|
final outputFile = await FilePicker.platform.saveFile(
|
|
dialogTitle: "",
|
|
fileName: csvFilename,
|
|
);
|
|
|
|
if (outputFile != null) {
|
|
await File(outputFile).writeAsString(csv, flush: true);
|
|
}
|
|
} else {
|
|
final localizations = L10N.of(context);
|
|
final directory = (Platform.isAndroid)
|
|
? await getExternalStorageDirectory()
|
|
: await getApplicationDocumentsDirectory();
|
|
final localPath = '${directory!.path}/$csvFilename';
|
|
|
|
final file = File(localPath);
|
|
await file.writeAsString(csv, flush: true);
|
|
await Share.shareXFiles([XFile(localPath, mimeType: "text/csv")],
|
|
subject: localizations.tr
|
|
.timeCopEntries(dateFormat.format(DateTime.now())));
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|