diff --git a/android/build.gradle b/android/build.gradle index 58a8c74..713d7f6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/lib/cubit/theme_mode_cubit.dart b/lib/cubit/theme_mode_cubit.dart new file mode 100644 index 0000000..b9772ad --- /dev/null +++ b/lib/cubit/theme_mode_cubit.dart @@ -0,0 +1,43 @@ + +import 'package:flutter/material.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +class ThemeModeCubit extends HydratedCubit { + ThemeModeCubit() : super(ThemeMode.system); + + bool? get isDark => switch (state) { + ThemeMode.dark => true, + ThemeMode.light => false, + ThemeMode.system => null, + }; + + set dark(bool? dark) => emit(switch (dark) { + true => ThemeMode.dark, + false => ThemeMode.light, + null => ThemeMode.system, + }); + + @override + ThemeMode fromJson(Map json) => switch (json['version']) { + <= 1 => switch (json['themeMode']) { + 'dark' => ThemeMode.dark, + 'light' => ThemeMode.light, + _ => ThemeMode.system, + }, + _ => ThemeMode.system, + }; + + @override + Map? toJson(ThemeMode state) { + final themeMode = switch (state) { + ThemeMode.dark => 'dark', + ThemeMode.light => 'light', + _ => 'system', + }; + + return { + 'version': 1, + 'themeMode': themeMode, + }; + } +} diff --git a/lib/cubit/time_entries_filter.dart b/lib/cubit/time_entries_filter.dart new file mode 100644 index 0000000..e5a9255 --- /dev/null +++ b/lib/cubit/time_entries_filter.dart @@ -0,0 +1,25 @@ +library time_entries_filter; + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; +import 'package:syntrack/model/common/task.dart'; + +part 'time_entries_filter.g.dart'; + +abstract class TimeEntriesFilter implements Built { + String? get query; + DateTime? get filterStart; + DateTime? get filterEnd; + Duration? get filterDuration; + bool? get filterBooked; + Set get filterWeekday; + Task? get filterTask; + Set get filterWorkInterfaceId; + Set get filterActivityNames; + + TimeEntriesFilter._(); + + factory TimeEntriesFilter([void Function(TimeEntriesFilterBuilder) updates]) = _$TimeEntriesFilter; +} diff --git a/lib/cubit/time_entries_filter_cubit.dart b/lib/cubit/time_entries_filter_cubit.dart new file mode 100644 index 0000000..6f7f4ec --- /dev/null +++ b/lib/cubit/time_entries_filter_cubit.dart @@ -0,0 +1,114 @@ +import 'package:easy_debounce/easy_debounce.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syntrack/cubit/time_entries_filter.dart'; +import 'package:syntrack/model/common/time_entry.dart'; +import 'package:syntrack/util/date_time_extension.dart'; + +class TimeEntriesFilterCubit extends Cubit { + TimeEntriesFilterCubit() + : super((TimeEntriesFilterBuilder() + ..filterActivityNames = {} + ..filterWorkInterfaceId = {} + ..filterWeekday = {}) + .build()); + + Iterable filter(List timeEntries) { + final TimeEntriesFilter( + query: query, + filterActivityNames: filterActivityNames, + filterBooked: filterBooked, + filterTask: filterTask, + filterDuration: filterDuration, + filterWeekday: filterWeekday, + filterWorkInterfaceId: filterWorkInterfaceId, + filterStart: filterStart, + filterEnd: filterEnd, + ) = state; + + if (query == null && + filterActivityNames.isEmpty && + filterBooked == null && + filterTask == null && + filterDuration == null && + filterWeekday.isEmpty && + filterWorkInterfaceId.isEmpty && + filterStart == null && + filterEnd == null) { + return timeEntries; + } + + return timeEntries + .where( + (element) => query != null + ? '${element.comment.trim()}${element.task?.name ?? ''}${element.activity?.name ?? ''}' + .toLowerCase() + .contains(query.trim().toLowerCase()) + : true, + ) + .where( + (element) => filterActivityNames.isNotEmpty ? filterActivityNames.contains(element.activity?.name) : true, + ) + .where( + (element) => filterBooked != null + ? filterBooked + ? element.bookingId != null + : element.bookingId == null + : true, + ) + .where( + (element) => filterTask != null ? element.task?.id == filterTask.id : true, + ) + .where( + (element) => filterWeekday.isNotEmpty ? filterWeekday.contains(element.start.weekday) : true, + ) + .where( + (element) => + filterWorkInterfaceId.isNotEmpty ? filterWorkInterfaceId.contains(element.task?.workInterfaceId) : true, + ) + .where( + (element) => filterStart != null ? element.start.startOfDay == filterStart.startOfDay : true, + ) + .where( + (element) => filterEnd != null ? element.end.startOfDay == filterEnd.startOfDay : true, + ) + .where( + (element) => filterDuration != null ? filterDuration.inSeconds <= element.duration.inSeconds : true, + ); + } + + void debouncedFilters(Function(TimeEntriesFilterBuilder) updates) { + EasyDebounce.debounce('time_entries_filter_cubit.debouncedFilters', const Duration(milliseconds: 500), () { + setFilters(updates); + }); + } + + void setFilters(Function(TimeEntriesFilterBuilder) updates) { + emit(state.rebuild(updates)); + } + + void clearFilters() { + emit(state.rebuild((p0) => p0 + ..filterActivityNames = const {} + ..filterBooked = null + ..filterTask = null + ..filterDuration = null + ..filterWeekday = const {} + ..filterWorkInterfaceId = const {} + ..filterStart = null + ..filterEnd = null)); + } + + void debouncedQuery(String? query) { + EasyDebounce.debounce( + 'time_entries_filter_cubit.debouncedQuery', + const Duration(milliseconds: 750), + () { + immediateQuery(query); + }, + ); + } + + void immediateQuery(String? query) { + emit(state.rebuild((state) => state..query = query)); + } +} diff --git a/lib/cubit/work_interface_cubit.dart b/lib/cubit/work_interface_cubit.dart index 9cb394e..0893d5d 100644 --- a/lib/cubit/work_interface_cubit.dart +++ b/lib/cubit/work_interface_cubit.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:built_collection/built_collection.dart'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:syntrack/model/common/task_search_origin.dart'; import 'package:syntrack/model/work/erpnext/erpnext_config.dart'; import 'package:syntrack/model/work/redmine/redmine_config.dart'; import 'package:syntrack/model/work/work_interface_configs.dart'; @@ -98,4 +100,26 @@ class WorkInterfaceCubit extends HydratedCubit { 'data': jsonDecode(state.toJson()), }; } + + TaskSearchOrigin? getOriginFor(String workInterfaceId) { + return state.combinedConfigs + .map((element) => switch (element) { + ErpNextConfig() => element.id == workInterfaceId ? TaskSearchOrigin.erpNext : null, + RedmineConfig() => element.id == workInterfaceId ? TaskSearchOrigin.redmine : null, + _ => null, + }) + .firstWhereOrNull((element) => element != null); + } + + String getNameFor(dynamic workInterface) => switch (workInterface) { + ErpNextConfig() => workInterface.name, + RedmineConfig() => workInterface.name, + _ => '', + }; + + String getIdFor(dynamic workInterface) => switch (workInterface) { + ErpNextConfig() => workInterface.id, + RedmineConfig() => workInterface.id, + _ => '', + }; } diff --git a/lib/main.dart b/lib/main.dart index 82af34f..d00dd7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,10 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sizer/sizer.dart'; import 'package:syntrack/cubit/booking_cubit.dart'; +import 'package:syntrack/cubit/theme_mode_cubit.dart'; import 'package:syntrack/cubit/task_search_cubit.dart'; import 'package:syntrack/cubit/time_entries_cubit.dart'; +import 'package:syntrack/cubit/time_entries_filter_cubit.dart'; import 'package:syntrack/cubit/time_tracking_cubit.dart'; import 'package:syntrack/cubit/work_interface_cubit.dart'; import 'package:syntrack/repository/data/latest_bookings_data_provider.dart'; @@ -59,7 +61,6 @@ Future createHydratedBoxBackup(Directory appDir, {int maxBackups = 10}) as } class SynTrack extends StatelessWidget { - static const primarySwatch = Colors.blue; final _appRouter = AppRouter(); SynTrack({super.key}); @@ -71,9 +72,15 @@ class SynTrack extends StatelessWidget { create: (context) => WorkRepository(), child: MultiBlocProvider( providers: [ + BlocProvider( + create: (context) => ThemeModeCubit(), + ), BlocProvider( create: (context) => TimeEntriesCubit(), ), + BlocProvider( + create: (context) => TimeEntriesFilterCubit(), + ), BlocProvider( lazy: false, create: (context) { @@ -112,9 +119,16 @@ class SynTrack extends StatelessWidget { ], title: 'synTrack', theme: ThemeData( - primarySwatch: primarySwatch, - useMaterial3: false, + brightness: Brightness.light, + useMaterial3: true, + colorSchemeSeed: const Color(0xFFFF5250), + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + colorSchemeSeed: const Color(0xFF1923DC), ), + themeMode: context.watch().state, routerDelegate: _appRouter.delegate(), routeInformationParser: _appRouter.defaultRouteParser(), ); diff --git a/lib/model/common/task_search_result.dart b/lib/model/common/task_search_result.dart index 72ad79d..78f6302 100644 --- a/lib/model/common/task_search_result.dart +++ b/lib/model/common/task_search_result.dart @@ -12,7 +12,7 @@ import 'package:syntrack/model/serializer/serializers.dart'; part 'task_search_result.g.dart'; abstract class TaskSearchResult implements Built { - Task get task; + Task? get task; TaskSearchOrigin get origin; Activity? get activity; String? get comment; diff --git a/lib/repository/data/latest_bookings_data_provider.dart b/lib/repository/data/latest_bookings_data_provider.dart index a8d278a..3e20bc1 100644 --- a/lib/repository/data/latest_bookings_data_provider.dart +++ b/lib/repository/data/latest_bookings_data_provider.dart @@ -35,22 +35,24 @@ class LatestBookingsDataProvider extends WorkDataProvider { Stream search(config, String query) async* { final tasks = {}; + query = query.toLowerCase(); + yield* Stream.fromIterable(timeEntriesCubit.state).where( (timeEntry) { - return timeEntry.comment.toLowerCase().contains(query) || - (timeEntry.task != null && timeEntry.task!.name.toLowerCase().contains(query)); + return timeEntry.comment.trim().toLowerCase().contains(query) || + (timeEntry.task != null && timeEntry.task!.name.trim().toLowerCase().contains(query)); }, - ).where((event) { + ).where((timeEntry) { // filter out tasks that are already in the list - final contains = !tasks.contains(event.task); - if (event.task != null) { - tasks.add(event.task!); + final contains = !tasks.contains(timeEntry.task); + if (timeEntry.task != null) { + tasks.add(timeEntry.task!); } - return event.task == null || contains; + return timeEntry.task == null || contains; }).map( (e) => TaskSearchResult( (b) => b - ..displayText = '${e.comment}${e.task != null ? ' - ' : ''}${e.task?.name}' + ..displayText = e.comment ..origin = TaskSearchOrigin.latestBookings ..activity = e.activity?.toBuilder() ..comment = e.comment diff --git a/lib/ui/erpnext_edit_page.dart b/lib/ui/erpnext_edit_page.dart index c28a82c..e6dc74e 100644 --- a/lib/ui/erpnext_edit_page.dart +++ b/lib/ui/erpnext_edit_page.dart @@ -30,6 +30,8 @@ class _ErpNextEditPageState extends State { appBar: AppBar( title: const Text('ERPNext'), centerTitle: true, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.save), diff --git a/lib/ui/redmine_edit_page.dart b/lib/ui/redmine_edit_page.dart index e62178d..abf5c62 100644 --- a/lib/ui/redmine_edit_page.dart +++ b/lib/ui/redmine_edit_page.dart @@ -31,6 +31,8 @@ class _RedmineEditPageState extends State { appBar: AppBar( title: const Text('Redmine'), centerTitle: true, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.save), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index b18910b..7c030df 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syntrack/cubit/theme_mode_cubit.dart'; import 'package:syntrack/cubit/work_interface_cubit.dart'; import 'package:syntrack/model/common/task_search_origin.dart'; import 'package:syntrack/model/work/erpnext/erpnext_config.dart'; @@ -20,24 +21,43 @@ class SettingsPage extends StatelessWidget { appBar: AppBar( title: const Text('Settings'), centerTitle: true, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), body: Center( child: Container( - constraints: const BoxConstraints(maxWidth: 500), + constraints: const BoxConstraints(maxWidth: 600), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 16.0, + ), child: Column( children: [ + CheckboxListTile( + title: const Text('Dark Mode'), + value: context.watch().isDark, + onChanged: (value) => context.read().dark = value, + tristate: true, + ), + const SizedBox(height: 10), Padding( padding: const EdgeInsets.only(top: 16.0), child: AppBar( - backgroundColor: Theme.of(context).primaryColorDark, - title: const Text('Work Interfaces'), + backgroundColor: Theme.of(context).colorScheme.primary, + title: Text( + 'Work Interfaces', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), automaticallyImplyLeading: false, shadowColor: Colors.transparent, actions: [ PopupMenuButton( icon: const Icon(Icons.add), + color: Theme.of(context).colorScheme.onPrimary, itemBuilder: (context) => [ PopupMenuItem( child: const ListTile( @@ -73,13 +93,14 @@ class SettingsPage extends StatelessWidget { if (config is RedmineConfig) { return ListTile( title: Text(config.name), + contentPadding: const EdgeInsets.all(8.0), onTap: () => context.router.push(RedmineEditRoute(initialConfig: config)), leading: const WorkInterfaceIcon( origin: TaskSearchOrigin.redmine, borderRadius: 10, padding: EdgeInsets.all(5), ), - trailing: IconButton( + trailing: IconButton.filledTonal( onPressed: () => context.read().deleteConfig(config.id), icon: const Icon(Icons.delete), ), @@ -87,13 +108,14 @@ class SettingsPage extends StatelessWidget { } else if (config is ErpNextConfig) { return ListTile( title: Text(config.name), + contentPadding: const EdgeInsets.all(8.0), onTap: () => context.router.push(ErpNextEditRoute(initialConfig: config)), leading: const WorkInterfaceIcon( origin: TaskSearchOrigin.erpNext, borderRadius: 10, padding: EdgeInsets.all(5), ), - trailing: IconButton( + trailing: IconButton.filledTonal( onPressed: () => context.read().deleteConfig(config.id), icon: const Icon(Icons.delete), ), diff --git a/lib/ui/track_page.dart b/lib/ui/track_page.dart index 67b9632..d42a9ad 100644 --- a/lib/ui/track_page.dart +++ b/lib/ui/track_page.dart @@ -4,9 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sizer/sizer.dart'; import 'package:syntrack/cubit/booking_cubit.dart'; import 'package:syntrack/cubit/time_entries_cubit.dart'; +import 'package:syntrack/cubit/time_entries_filter_cubit.dart'; import 'package:syntrack/cubit/time_tracking_cubit.dart'; import 'package:syntrack/exception/work_interface_not_found.dart'; import 'package:syntrack/router.gr.dart'; +import 'package:syntrack/ui/widget/time_entries_filter_bar.dart'; import 'package:syntrack/ui/widget/time_entries_list.dart'; import 'package:syntrack/ui/widget/time_tracking_header.dart'; @@ -18,16 +20,25 @@ class TrackPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('synTrack'), + backgroundColor: Theme.of(context).colorScheme.primary, + title: Text( + 'synTrack', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), centerTitle: true, actions: [ IconButton( onPressed: () => context.router.push(const SettingsRoute()), - icon: const Icon(Icons.settings), + icon: Icon( + Icons.settings, + color: Theme.of(context).colorScheme.onPrimary, + ), ), ], bottom: const PreferredSize( - preferredSize: Size(double.infinity, 120), + preferredSize: Size(double.infinity, 110), child: Padding( padding: EdgeInsets.all(4.0), child: TimeTrackingHeader(), @@ -37,30 +48,40 @@ class TrackPage extends StatelessWidget { floatingActionButton: context.watch().state.start != null && SizerUtil.deviceType == DeviceType.mobile ? FloatingActionButton( - backgroundColor: Colors.red, + // backgroundColor: Theme.of(context).colorScheme.onPrimary, onPressed: () => context.read().stop(), child: const Icon(Icons.stop), ) : FloatingActionButton( - child: const Icon(Icons.book), + // backgroundColor: Theme.of(context).colorScheme.onPrimary, + child: const Icon(Icons.bookmark), onPressed: () => _bookAll(context), ), body: const TimeEntriesList(), + floatingActionButtonLocation: FloatingActionButtonLocation.endContained, + bottomNavigationBar: const TimeEntriesFilterBar(), ); } _bookAll(BuildContext context) async { try { final notBooked = context.read().state.where((element) => element.bookingId == null).toList(); + final notBookedFiltered = context.read().filter(notBooked); + await context.read().bookMany( context, - notBooked, + notBookedFiltered.toList(), ); } on WorkInterfaceNotFound { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Work Interface/s not found'), - backgroundColor: Colors.red, + SnackBar( + content: Text( + 'Work Interface/s not found', + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + ), + ), + backgroundColor: Theme.of(context).colorScheme.errorContainer, ), ); } diff --git a/lib/ui/widget/activity_selector.dart b/lib/ui/widget/activity_selector.dart index ea0e027..b1d565d 100644 --- a/lib/ui/widget/activity_selector.dart +++ b/lib/ui/widget/activity_selector.dart @@ -15,17 +15,24 @@ class ActivitySelector extends StatelessWidget { @override Widget build(BuildContext context) { - return DropdownButton( - onChanged: onSelect, - value: selectedActivity, - items: activities - .map( - (e) => DropdownMenuItem( - value: e, - child: Text(e.name), - ), - ) - .toList(), + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(1000), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: DropdownButton( + onChanged: onSelect, + value: selectedActivity, + items: activities + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.name), + ), + ) + .toList(), + ), ); } } diff --git a/lib/ui/widget/comment_edit_field.dart b/lib/ui/widget/comment_edit_field.dart index b2616d6..cdec635 100644 --- a/lib/ui/widget/comment_edit_field.dart +++ b/lib/ui/widget/comment_edit_field.dart @@ -2,35 +2,47 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syntrack/cubit/time_tracking_cubit.dart'; -class CommentEditField extends StatelessWidget { +class CommentEditField extends StatefulWidget { const CommentEditField({Key? key}) : super(key: key); + @override + State createState() => _CommentEditFieldState(); +} + +class _CommentEditFieldState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + Future.delayed(Duration.zero, () { + _controller.text = getInitialComment(context); + _focusNode.requestFocus(); + }); + } + @override Widget build(BuildContext context) { - return TextFormField( - initialValue: getInitialComment(context), - autofocus: true, - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 20, + return SearchBar( + hintText: 'Comment', + focusNode: _focusNode, + controller: _controller, + trailing: [ + IconButton( + onPressed: () { + _controller.clear(); + context.read().setComment(''); + }, + icon: const Icon( + Icons.close, ), - decoration: InputDecoration( - hintText: 'Comment', - border: const OutlineInputBorder(), - prefixIcon: Container( - constraints: const BoxConstraints(maxWidth: 50, maxHeight: 50, minWidth: 50, minHeight: 50), - child: const Icon(Icons.access_time), ), - /*TODO: suffixIcon: IconButton( - icon: Icon(Icons.close), - onPressed: () => null, - ),*/ - ), + ], onChanged: (value) { context.read().setComment(value); }, - contextMenuBuilder: (context, editableTextState) { - return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); - }, ); } @@ -38,4 +50,11 @@ class CommentEditField extends StatelessWidget { final state = context.read().state; return state.comment; } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } } diff --git a/lib/ui/widget/date_selector.dart b/lib/ui/widget/date_selector.dart index 0064bd3..8633012 100644 --- a/lib/ui/widget/date_selector.dart +++ b/lib/ui/widget/date_selector.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:syntrack/cubit/time_entries_cubit.dart'; import 'package:syntrack/model/common/time_entry.dart'; +import 'package:syntrack/util/date_time_extension.dart'; class DateSelector extends StatelessWidget { static final _formatterDate = DateFormat('EEE dd.MM.yyyy'); @@ -20,39 +21,81 @@ class DateSelector extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: entry.bookingId != null || readOnly - ? null - : () async { - final timeEntriesCubit = context.read(); - final start = entry.start.toLocal(); - final end = entry.end.toLocal(); - - final newDate = await showDatePicker( - context: context, - initialDate: start, - firstDate: DateTime.fromMillisecondsSinceEpoch(0), - lastDate: DateTime.now().add( - const Duration(days: 365), - ), - ); - - if (newDate != null) { - final newStart = DateTime(newDate.year, newDate.month, newDate.day, start.hour, start.minute).toUtc(); - final newEnd = DateTime(newDate.year, newDate.month, newDate.day, end.hour, end.minute).toUtc(); - - timeEntriesCubit.update( - entry.id, - entry.rebuild((b) => b - ..start = newStart - ..end = newEnd), - ); - } - }, - child: Text( - _formatterDate.format(entry.start.toLocal()), - style: style, + final isSameDay = entry.start.startOfDay == entry.end.startOfDay; + + return Column( + children: [ + InkWell( + onTap: entry.bookingId != null || readOnly ? null : () => _changeStartDate(context), + child: Text( + _formatterDate.format(entry.start.toLocal()), + style: style, + ), + ), + if (!isSameDay) + InkWell( + onTap: entry.bookingId != null || readOnly ? null : () => _changeEndDate(context), + child: Text( + _formatterDate.format(entry.end.toLocal()), + style: style, + ), + ), + ], + ); + } + + void _changeStartDate(BuildContext context) async { + final timeEntriesCubit = context.read(); + final start = entry.start.toLocal(); + final duration = entry.duration; + + final newDate = await showDatePicker( + context: context, + initialDate: start, + firstDate: DateTime.fromMillisecondsSinceEpoch(0), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + + if (newDate != null) { + final newStart = DateTime(newDate.year, newDate.month, newDate.day, start.hour, start.minute).toUtc(); + final newEnd = newStart.add(duration); + + timeEntriesCubit.update( + entry.id, + entry.rebuild( + (b) => b + ..start = newStart + ..end = newEnd, + ), + ); + } + } + + void _changeEndDate(BuildContext context) async { + final timeEntriesCubit = context.read(); + final start = entry.start.toLocal(); + final end = entry.end.toLocal(); + + final newDate = await showDatePicker( + context: context, + initialDate: end, + firstDate: start, + lastDate: DateTime.now().add( + const Duration(days: 365), ), ); + + if (newDate != null) { + final newEnd = DateTime(newDate.year, newDate.month, newDate.day, end.hour, end.minute).toUtc(); + + timeEntriesCubit.update( + entry.id, + entry.rebuild( + (b) => b..end = newEnd, + ), + ); + } } } diff --git a/lib/ui/widget/start_stop_or_discard_tracking_button.dart b/lib/ui/widget/start_stop_or_discard_tracking_button.dart index 805f115..7070cd2 100644 --- a/lib/ui/widget/start_stop_or_discard_tracking_button.dart +++ b/lib/ui/widget/start_stop_or_discard_tracking_button.dart @@ -12,35 +12,32 @@ class StartStopOrDiscardTrackingButton extends StatelessWidget { return Row( children: [ if (!isTracking) - ElevatedButton( + FilledButton.tonal( onPressed: () => context.read().track(), - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.green), - ), child: const Padding( padding: EdgeInsets.all(8.0), - child: Icon(Icons.play_arrow), + child: Icon( + Icons.play_arrow, + ), ), ), if (isTracking) - ElevatedButton( + FilledButton.tonal( onPressed: () => context.read().stop(), - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.red), - ), child: const Padding( padding: EdgeInsets.all(8.0), - child: Icon(Icons.stop), + child: Icon( + Icons.stop, + ), ), ), Padding( padding: const EdgeInsets.all(8.0), - child: TextButton( + child: IconButton( + color: Theme.of(context).colorScheme.onPrimary, + disabledColor: Theme.of(context).colorScheme.onPrimary.withOpacity(0.5), onPressed: isTracking ? () => context.read().discard() : null, - style: ButtonStyle( - foregroundColor: MaterialStateProperty.all(Colors.grey), - ), - child: const Icon(Icons.delete), + icon: const Icon(Icons.delete), ), ), ], diff --git a/lib/ui/widget/task_search_field.dart b/lib/ui/widget/task_search_field.dart index 98290f8..8355011 100644 --- a/lib/ui/widget/task_search_field.dart +++ b/lib/ui/widget/task_search_field.dart @@ -1,10 +1,12 @@ +import 'dart:async'; + +import 'package:easy_debounce/easy_debounce.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:syntrack/cubit/task_search_cubit.dart'; import 'package:syntrack/model/common/task_search_result.dart'; -import 'package:syntrack/ui/widget/search_icon.dart'; import 'package:syntrack/ui/widget/work_interface_icon.dart'; class TaskSearchTextField extends StatefulWidget { @@ -31,20 +33,7 @@ class _TaskSearchTextFieldState extends State { final _textController = TextEditingController(); final _suggestionBoxController = SuggestionsBoxController(); final _focusNode = FocusNode(); - - /*@override - void didChangeDependencies() { - super.didChangeDependencies(); - - _commentSub?.cancel().catchError((e) => print(e)); - _commentSub = context.watch().stream.listen((event) { - if (event.comment.trim().isEmpty) { - setState(() { - _textController.text = ''; - }); - } - }); - }*/ + var _completer = Completer(); @override Widget build(BuildContext context) { @@ -59,54 +48,108 @@ class _TaskSearchTextFieldState extends State { child: Row( children: [ Expanded( - child: TypeAheadField( - suggestionsBoxController: _suggestionBoxController, - debounceDuration: const Duration(milliseconds: 750), - minCharsForSuggestions: 2, - textFieldConfiguration: TextFieldConfiguration( - controller: _textController, - autofocus: widget.autofocus, - style: DefaultTextStyle.of(context).style.copyWith( - fontStyle: FontStyle.italic, - fontSize: 20, - ), - decoration: const InputDecoration( - prefixIcon: SearchIcon(), - border: OutlineInputBorder(), - ), - onChanged: widget.onTextChange, - onSubmitted: (value) { - _suggestionBoxController.close(); - widget.onSubmitted?.call(value); - }, - ), - noItemsFoundBuilder: (context) { - return const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - 'No Tasks found! Try \$me or #[TicketID]', - style: TextStyle( - color: Colors.grey, - fontSize: 20, - ), - ), + child: SearchAnchor.bar( + suggestionsBuilder: (context, controller) { + if (_completer.isCompleted) { + _completer = Completer(); + } + + EasyDebounce.debounce( + 'task-search-debounce', + const Duration(milliseconds: 750), + () { + _completer.complete(controller.text); + }, ); - }, - suggestionsStreamCallback: (pattern) async* { - yield* context.read().searchStream(pattern); - }, - itemBuilder: (context, suggestion) { - return ListTile( - leading: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 25, maxHeight: 25, minWidth: 25, minHeight: 25), - child: WorkInterfaceIcon(origin: suggestion.origin), + + return [ + FutureBuilder( + future: _completer.future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + final query = snapshot.data; + if (query != null) { + final searchResults = []; + + return StreamBuilder( + stream: context.read().searchStream(query), + builder: (context, snapshot) { + if (snapshot.hasError) { + return ListTile( + leading: const Icon(Icons.error), + title: const Text('An unexpected Error occured!'), + subtitle: Text('Error: ${snapshot.error.toString()}'), + onTap: () { + controller.closeView(null); + }, + ); + } + + if (snapshot.connectionState == ConnectionState.done && searchResults.isEmpty) { + if (query.trim().isEmpty) { + return const ListTile( + leading: Icon(Icons.search), + title: Text('Start typing to search for a Task'), + subtitle: Text('Hint: Try \$me or #[TicketID]'), + ); + } + + return ListTile( + leading: const Icon(Icons.play_arrow), + title: Text('Start tracking "$query" and set the Task later'), + subtitle: const Text('Hint: Try \$me or #[TicketID]'), + onTap: () { + controller.closeView(null); + widget.onSubmitted?.call(query); + }, + ); + } + + if (snapshot.hasData && snapshot.connectionState == ConnectionState.active) { + final data = snapshot.data!; + searchResults.add(data); + } + + return Stack( + children: [ + if (snapshot.connectionState == ConnectionState.active) ...[ + const LinearProgressIndicator() + ], + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: searchResults.length, + itemBuilder: (BuildContext context, int index) { + final suggestion = searchResults[index]; + return ListTile( + leading: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 25, + maxHeight: 25, + minWidth: 25, + minHeight: 25, + ), + child: WorkInterfaceIcon(origin: suggestion.origin), + ), + title: Text(suggestion.displayText), + subtitle: Text('#${suggestion.task?.id ?? ""}'), + onTap: () { + controller.closeView(null); + widget.onSuggestionSelected?.call(suggestion); + }, + ); + }, + ), + ], + ); + }, + ); + } + } + return const LinearProgressIndicator(); + }, ), - title: Text(suggestion.displayText), - subtitle: Text('#${suggestion.task.id}'), - ); - }, - onSuggestionSelected: (suggestion) { - widget.onSuggestionSelected?.call(suggestion); + ]; }, ), ), diff --git a/lib/ui/widget/time_entries_filter_bar.dart b/lib/ui/widget/time_entries_filter_bar.dart new file mode 100644 index 0000000..24e6378 --- /dev/null +++ b/lib/ui/widget/time_entries_filter_bar.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syntrack/cubit/time_entries_filter_cubit.dart'; +import 'package:syntrack/cubit/work_interface_cubit.dart'; +import 'package:syntrack/ui/widget/work_interface_icon.dart'; + +class TimeEntriesFilterBar extends StatefulWidget { + const TimeEntriesFilterBar({super.key}); + + @override + State createState() => _TimeEntriesFilterBarState(); +} + +class _TimeEntriesFilterBarState extends State { + bool _searchShown = false; + bool _filtersShown = false; + final _searchBarFocusNode = FocusNode(); + + @override + Widget build(BuildContext context) { + const bottomAppBarHeight = 85.0; + const bottomAppBarHeightExpansion = 150.0; + + return BottomAppBar( + height: _filtersShown ? bottomAppBarHeight + bottomAppBarHeightExpansion : bottomAppBarHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + if (_filtersShown) ...[ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 100.0), + child: SizedBox( + width: double.infinity, + child: Card( + child: _buildFilters(context), + ), + ), + ), + ), + const SizedBox(height: 16), + ], + Row( + children: [ + IconButton( + tooltip: 'Filters', + icon: Icon(_filtersShown ? Icons.filter_list_off : Icons.filter_list), + onPressed: () => toggleFiltersVisibility(context), + ), + if (!_searchShown) + IconButton( + tooltip: 'Search', + icon: const Icon(Icons.search), + onPressed: () => setSearchBarVisibility(context, true), + ), + if (_searchShown) + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 100.0), + child: SearchBar( + onChanged: (query) => context.read().debouncedQuery(query), + focusNode: _searchBarFocusNode, + leading: const Icon(Icons.search), + hintText: 'Search Time Entries', + trailing: [ + IconButton( + onPressed: () => setSearchBarVisibility(context, false), + icon: const Icon( + Icons.close, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildFilters(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Wrap( + direction: Axis.horizontal, + spacing: 6, + runSpacing: 6, + children: [ + SizedBox( + width: 200, + child: Column( + children: [ + const Text('Work Interfaces'), + Wrap( + spacing: 4, + runSpacing: 4, + children: context.watch().state.combinedConfigs.map((e) { + final workInterfaceName = context.read().getNameFor(e); + final workInterfaceId = context.read().getIdFor(e); + final selected = + context.watch().state.filterWorkInterfaceId.contains(workInterfaceId); + + return InkWell( + onTap: () => context.read().setFilters((updates) => + updates.filterWorkInterfaceId = {...updates.filterWorkInterfaceId ?? {}, workInterfaceId}), + child: Chip( + backgroundColor: selected ? Theme.of(context).colorScheme.primaryContainer : null, + onDeleted: selected + ? () => context.read().setFilters( + (updates) => updates.filterWorkInterfaceId = updates.filterWorkInterfaceId + ?.where((element) => element != workInterfaceId) + .toSet() ?? + {}, + ) + : null, + label: Row( + children: [ + SizedBox( + width: 20, + child: WorkInterfaceIcon.fromConfig(e), + ), + const SizedBox(width: 4), + Text(workInterfaceName), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ), + SizedBox( + width: 150, + child: CheckboxListTile( + title: const Text('Booked'), + value: context.watch().state.filterBooked, + onChanged: (value) => context.read().setFilters((updates) { + updates.filterBooked = value; + }), + tristate: true, + ), + ), + SizedBox( + width: 200, + child: Column( + children: [ + Text( + 'Duration ${((context.watch().state.filterDuration?.inMinutes ?? 0) / 60).toStringAsFixed(2)}h'), + Slider( + value: context.watch().state.filterDuration?.inMinutes.toDouble() ?? -1, + onChanged: (value) => context.read().setFilters((updates) { + updates.filterDuration = value < 0 + ? null + : Duration( + minutes: value.toInt(), + ); + }), + min: -1, + max: 60 * 10, + ), + ], + ), + ), + Column( + children: [ + const Text('Weekday'), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('Mon')), + ButtonSegment(value: 2, label: Text('Tue')), + ButtonSegment(value: 3, label: Text('Wed')), + ButtonSegment(value: 4, label: Text('Thu')), + ButtonSegment(value: 5, label: Text('Fri')), + ButtonSegment(value: 6, label: Text('Sat')), + ButtonSegment(value: 7, label: Text('Sun')), + ], + emptySelectionAllowed: true, + multiSelectionEnabled: true, + onSelectionChanged: (value) => context.read().setFilters( + (updates) => updates.filterWeekday = value, + ), + selected: context.watch().state.filterWeekday, + ), + ], + ), + ], + ), + ), + ); + } + + void setSearchBarVisibility(BuildContext context, bool visible) { + setState(() { + _searchShown = visible; + if (visible) { + _searchBarFocusNode.requestFocus(); + } else { + context.read().immediateQuery(null); + } + }); + } + + void toggleFiltersVisibility(BuildContext context) { + setState(() { + _filtersShown = !_filtersShown; + + if (!_filtersShown) { + context.read().clearFilters(); + } + }); + } + + @override + void dispose() { + _searchBarFocusNode.dispose(); + super.dispose(); + } +} diff --git a/lib/ui/widget/time_entries_list.dart b/lib/ui/widget/time_entries_list.dart index 052e7b3..2eff404 100644 --- a/lib/ui/widget/time_entries_list.dart +++ b/lib/ui/widget/time_entries_list.dart @@ -1,34 +1,107 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:isoweek/isoweek.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:syntrack/cubit/booking_cubit.dart'; import 'package:syntrack/cubit/time_entries_cubit.dart'; +import 'package:syntrack/cubit/time_entries_filter_cubit.dart'; import 'package:syntrack/ui/widget/time_entry_list_tile.dart'; +import 'package:syntrack/util/date_time_extension.dart'; + +final _dateFormat = DateFormat('dd/MM/yyyy'); class TimeEntriesList extends StatelessWidget { const TimeEntriesList({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - final entries = context.watch().state; - - return ListView.separated( - padding: const EdgeInsets.only( - top: 8.0, - left: 8.0, - right: 8.0, - ), - itemCount: entries.length + 1, - itemBuilder: (context, index) { - if (index == entries.length) { - return const SizedBox(height: 100); - } - - final entry = entries[index]; - return TimeEntryListTile( - key: Key('TimeEntryListTile-${entry.id}'), - entry: entry, + final allEntries = context.watch().state; + final filteredQueries = context.watch().filter(allEntries); + + final entriesByWeek = groupBy(filteredQueries, (entry) => Week.fromDate(entry.start.startOfDay)); + + return CustomScrollView( + slivers: entriesByWeek.entries.map((mapEntry) { + final week = mapEntry.key; + + final weekStartFormatted = _dateFormat.format(week.days.first); + final weekEndFormatted = _dateFormat.format(week.days.last); + + final entries = mapEntry.value; + final totalHours = entries.map((e) => e.duration.inMinutes / 60).sum.toStringAsFixed(2); + + final anyNotBooked = + entries.firstWhereOrNull((element) => element.task != null && element.bookingId == null) != null; + + return MultiSliver( + pushPinnedChildren: true, + children: [ + SliverAppBar( + pinned: true, + toolbarHeight: 30, + title: DefaultTextStyle.merge( + style: const TextStyle(fontSize: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$weekStartFormatted - $weekEndFormatted', + ), + Text( + 'W${week.weekNumber}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${totalHours}h', + ), + ], + ), + ), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + automaticallyImplyLeading: false, + actions: [ + if (!anyNotBooked) + const Padding( + padding: EdgeInsets.all(2.0), + child: IconButton.outlined( + onPressed: null, + icon: Icon(Icons.bookmark_added), + iconSize: 12, + ), + ), + if (anyNotBooked) + Padding( + padding: const EdgeInsets.all(2.0), + child: IconButton.outlined( + tooltip: 'Book Week #${week.weekNumber}', + onPressed: () { + context.read().bookMany(context, entries); + }, + icon: const Icon(Icons.bookmark), + iconSize: 12, + ), + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final entry = entries[index]; + return TimeEntryListTile( + key: Key('TimeEntryListTile-${entry.id}'), + entry: entry, + ); + }, + childCount: entries.length, + ), + ), + ], ); - }, - separatorBuilder: (_, __) => const Divider(), + }).toList(), ); } } diff --git a/lib/ui/widget/time_entry_actions.dart b/lib/ui/widget/time_entry_actions.dart index 31935b5..cd98402 100644 --- a/lib/ui/widget/time_entry_actions.dart +++ b/lib/ui/widget/time_entry_actions.dart @@ -34,12 +34,17 @@ class TimeEntryActions extends StatelessWidget { entry: entry, ), if (entry.task != null) - TimeEntryBookingButton( - entry: entry, - onBooked: onBooked, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TimeEntryBookingButton( + entry: entry, + onBooked: onBooked, + ), + ), + IconButton.filled( + icon: const Icon( + Icons.play_arrow, ), - TextButton( - child: const Icon(Icons.play_arrow), onPressed: () { onTrack?.call(); context.read().track( @@ -51,9 +56,12 @@ class TimeEntryActions extends StatelessWidget { }, ), if (entry.bookingId == null) - TimeEntryDeleteButton( - entry: entry, - onDelete: onDelete, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TimeEntryDeleteButton( + entry: entry, + onDelete: onDelete, + ), ), PopupMenuButton( itemBuilder: (context) => [ @@ -156,7 +164,7 @@ class TimeEntryActions extends StatelessWidget { onTap: () { WidgetsBinding.instance.addPostFrameCallback((_) async { final timeEntriesCubit = context.read(); - + final confirm = await showDialog( context: context, builder: (context) => AlertDialog( diff --git a/lib/ui/widget/time_entry_booking_button.dart b/lib/ui/widget/time_entry_booking_button.dart index c59d91b..0fda616 100644 --- a/lib/ui/widget/time_entry_booking_button.dart +++ b/lib/ui/widget/time_entry_booking_button.dart @@ -16,10 +16,9 @@ class TimeEntryBookingButton extends StatelessWidget { @override Widget build(BuildContext context) { if (!isBooking(context)) { - return TextButton( - child: Icon( - Icons.book, - color: entry.bookingId == null ? Colors.red : Colors.green, + return IconButton.filled( + icon: Icon( + entry.bookingId == null ? Icons.bookmark : Icons.bookmark_added, ), onPressed: () { _bookOrDeleteBooking(context, entry); diff --git a/lib/ui/widget/time_entry_comment_edit_field.dart b/lib/ui/widget/time_entry_comment_edit_field.dart index 85b9531..71d8df4 100644 --- a/lib/ui/widget/time_entry_comment_edit_field.dart +++ b/lib/ui/widget/time_entry_comment_edit_field.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sizer/sizer.dart'; import 'package:syntrack/cubit/time_entries_cubit.dart'; +import 'package:syntrack/cubit/work_interface_cubit.dart'; import 'package:syntrack/model/common/time_entry.dart'; +import 'package:syntrack/ui/widget/work_interface_icon.dart'; class TimeEntryCommentEditField extends StatelessWidget { final TimeEntry entry; @@ -21,12 +23,20 @@ class TimeEntryCommentEditField extends StatelessWidget { if (entry.bookingId != null || readOnly) { return Row( children: [ + if (entry.task != null) + Container( + width: 24, + height: 24, + padding: const EdgeInsets.only(right: 4), + child: WorkInterfaceIcon( + origin: context.read().getOriginFor(entry.task!.workInterfaceId), + ), + ), if (SizerUtil.deviceType == DeviceType.mobile) Padding( padding: const EdgeInsets.only(right: 4.0), child: Icon( - Icons.book, - color: entry.bookingId != null ? Colors.green : Colors.red, + entry.bookingId == null ? Icons.bookmark : Icons.bookmark_added, ), ), Expanded( diff --git a/lib/ui/widget/time_entry_delete_button.dart b/lib/ui/widget/time_entry_delete_button.dart index b9e60f9..d0968df 100644 --- a/lib/ui/widget/time_entry_delete_button.dart +++ b/lib/ui/widget/time_entry_delete_button.dart @@ -19,10 +19,9 @@ class TimeEntryDeleteButton extends StatelessWidget { throw Exception('invalid TimeEntry'); } - return TextButton( - child: const Icon( + return IconButton.outlined( + icon: const Icon( Icons.delete, - color: Colors.grey, ), onPressed: () { onDelete?.call(); diff --git a/lib/ui/widget/time_entry_editor.dart b/lib/ui/widget/time_entry_editor.dart index 17d1978..4029912 100644 --- a/lib/ui/widget/time_entry_editor.dart +++ b/lib/ui/widget/time_entry_editor.dart @@ -33,34 +33,6 @@ class _TimeEntryEditorState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Text( - widget.entry.task?.name ?? '', - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ), - if (widget.entry.task != null && widget.entry.bookingId == null) ...[ - const SizedBox(width: 16), - TextButton.icon( - onPressed: () => _deleteTask(context), - icon: const Icon(Icons.delete), - label: const Text('DELETE TASK'), - ), - ], - if (widget.entry.task == null) ...[ - const SizedBox(width: 16), - TextButton.icon( - onPressed: () => _showTaskSearchField(context), - icon: const Icon(Icons.search), - label: const Text('SEARCH TASK'), - ), - ], - ], - ), Padding( padding: const EdgeInsets.all(8.0), child: ConstrainedBox( @@ -77,6 +49,18 @@ class _TimeEntryEditorState extends State { ), ), const SizedBox(height: 16), + if (widget.entry.task != null && widget.entry.bookingId == null) + Chip( + label: Text('Task: ${widget.entry.task?.name}'), + onDeleted: () => _deleteTask(context), + ), + if (widget.entry.task == null) + FilledButton.icon( + onPressed: () => _showTaskSearchField(context), + icon: const Icon(Icons.search), + label: const Text('Search Task'), + ), + const SizedBox(height: 16), const Text('Date:'), DateSelector( entry: widget.entry, @@ -129,6 +113,7 @@ class _TimeEntryEditorState extends State { context: context, builder: (context) { return AlertDialog( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, title: const Text('Search Task'), content: ConstrainedBox( constraints: const BoxConstraints.expand( @@ -142,8 +127,8 @@ class _TimeEntryEditorState extends State { widget.entry.id, widget.entry.rebuild( (b) => b - ..task = suggestion.task.toBuilder() - ..activity = suggestion.task.availableActivities[0].toBuilder(), + ..task = suggestion.task?.toBuilder() + ..activity = suggestion.task?.availableActivities[0].toBuilder(), ), ); diff --git a/lib/ui/widget/time_entry_list_tile.dart b/lib/ui/widget/time_entry_list_tile.dart index 0372103..6206963 100644 --- a/lib/ui/widget/time_entry_list_tile.dart +++ b/lib/ui/widget/time_entry_list_tile.dart @@ -10,6 +10,7 @@ import 'package:syntrack/ui/widget/time_entry_actions.dart'; import 'package:syntrack/ui/widget/time_entry_comment_edit_field.dart'; import 'package:syntrack/ui/widget/time_entry_editor.dart'; import 'package:syntrack/ui/widget/time_selector.dart'; +import 'package:syntrack/util/date_time_extension.dart'; class TimeEntryListTile extends StatefulWidget { final TimeEntry entry; @@ -44,10 +45,12 @@ class _TimeEntryListTileState extends State { } Widget _buildTile(BuildContext context) { + final entry = widget.entry; + return InkWell( onTap: () => _showDetails(context), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -59,14 +62,14 @@ class _TimeEntryListTileState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ DateSelector( - entry: widget.entry, + entry: entry, style: const TextStyle( fontWeight: FontWeight.bold, ), readOnly: SizerUtil.deviceType == DeviceType.mobile, ), Text( - '${(widget.entry.end.difference(widget.entry.start).inMinutes / 60).toStringAsFixed(2)}h ${widget.entry.activity?.name ?? ''}'), + '${(entry.end.difference(entry.start).inMinutes / 60).toStringAsFixed(2)}h ${entry.activity?.name ?? ''}'), ], ), ), @@ -76,16 +79,15 @@ class _TimeEntryListTileState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ TimeEntryCommentEditField( - entry: widget.entry, + entry: entry, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), - Text( - widget.entry.task != null ? '#${widget.entry.task!.id} ${widget.entry.task!.name}' : ''), + Text(entry.task != null ? '#${entry.task!.id} ${entry.task!.name}' : ''), TimeSelector( - entry: widget.entry, + entry: entry, readOnly: SizerUtil.deviceType == DeviceType.mobile, ), ], @@ -100,7 +102,7 @@ class _TimeEntryListTileState extends State { Padding( padding: const EdgeInsets.only(left: 16.0), child: TimeEntryActions( - entry: widget.entry, + entry: entry, onBooked: (e) { setState(() { _errorMessage = e?.toString(); diff --git a/lib/ui/widget/time_tracking_header.dart b/lib/ui/widget/time_tracking_header.dart index 2b86ee8..7fb53c8 100644 --- a/lib/ui/widget/time_tracking_header.dart +++ b/lib/ui/widget/time_tracking_header.dart @@ -27,7 +27,6 @@ class _TimeTrackingHeaderState extends State { return Container( decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10), ), child: Padding( @@ -37,38 +36,24 @@ class _TimeTrackingHeaderState extends State { if (trackingState.isTracking) Container( padding: EdgeInsets.all(padding), - width: double.infinity, child: Row( children: [ if (trackingState.task == null && trackingState.isTracking) Padding( padding: EdgeInsets.only(right: padding), - child: TextButton.icon( + child: FilledButton.tonalIcon( onPressed: () => setState(() { _searchingTask = !_searchingTask; }), icon: Icon(_searchingTask ? Icons.close : Icons.search), - label: Text(_searchingTask ? 'CANCEL SEARCH' : 'SEARCH TASK'), + label: Text(_searchingTask ? 'Cancel search' : 'Search Task'), ), ), if (trackingState.task != null) - Padding( - padding: EdgeInsets.only(right: padding), - child: TextButton.icon( - onPressed: () => context.read().removeTask(), - icon: const Icon(Icons.delete), - label: const Text('REMOVE TASK'), - ), - ), - Expanded( - child: Text( - trackingState.task != null ? 'Task: ${trackingState.task?.name}' : 'Task: ', - style: const TextStyle(), - textAlign: TextAlign.start, - overflow: TextOverflow.ellipsis, - maxLines: 1, + Chip( + label: Text('Task: ${trackingState.task?.name}'), + onDeleted: () => context.read().removeTask(), ), - ), ], ), ), @@ -85,38 +70,29 @@ class _TimeTrackingHeaderState extends State { }, ), ), - SizedBox(width: spacing), - if (trackingState.isTracking && - trackingState.task != null && - SizerUtil.deviceType != DeviceType.mobile) ...[ - ActivitySelector( - selectedActivity: trackingState.activity, - activities: trackingState.task!.availableActivities.toList(), - onSelect: context.read().setActivity, - ), + if (trackingState.isTracking) ...[ SizedBox(width: spacing), - ], - Container( - constraints: const BoxConstraints(minWidth: 100), - child: const Center( - child: TimeTrackingWatch(), + if (trackingState.isTracking && + trackingState.task != null && + SizerUtil.deviceType != DeviceType.mobile) ...[ + ActivitySelector( + selectedActivity: trackingState.activity, + activities: trackingState.task!.availableActivities.toList(), + onSelect: context.read().setActivity, + ), + SizedBox(width: spacing), + ], + Container( + constraints: const BoxConstraints(minWidth: 100), + child: const Center( + child: TimeTrackingWatch(), + ), ), - ), - const SizedBox(width: 16), - if (SizerUtil.deviceType != DeviceType.mobile) const StartStopOrDiscardTrackingButton(), + const SizedBox(width: 16), + if (SizerUtil.deviceType != DeviceType.mobile) const StartStopOrDiscardTrackingButton(), + ], ], ), - SizedBox(width: spacing), - /*Row( - children: [ - Expanded( - child: context.watch().state is TimeTrackingIdle - ? TaskSearchTextField() - : CommentEditField(), - ), - SizedBox(width: 16), - ], - ),*/ ], ), ), diff --git a/lib/ui/widget/time_tracking_header_task_search_field.dart b/lib/ui/widget/time_tracking_header_task_search_field.dart index 3ca00ea..c5163f8 100644 --- a/lib/ui/widget/time_tracking_header_task_search_field.dart +++ b/lib/ui/widget/time_tracking_header_task_search_field.dart @@ -17,15 +17,19 @@ class TaskTrackingHeaderTaskSearchField extends StatelessWidget { autofocus: false, onSuggestionSelected: (suggestion) { final task = suggestion.task; - final activity = suggestion.activity ?? task.availableActivities[0]; + final activity = suggestion.activity ?? task?.availableActivities[0]; final suggestionComment = suggestion.comment; final cubit = context.read(); cubit.track( updates: (b) { - b.comment = suggestionComment ?? b.comment; - b.activity = activity.toBuilder(); - b.task = task.toBuilder(); + b.comment = b.comment == null + ? suggestionComment + : b.comment!.trim().isEmpty + ? suggestionComment + : b.comment; + b.activity = activity?.toBuilder(); + b.task = task?.toBuilder(); }, setTimeToNow: !cubit.state.isTracking, stopCurrent: !cubit.state.isTracking, diff --git a/lib/ui/widget/time_tracking_watch.dart b/lib/ui/widget/time_tracking_watch.dart index b9275d6..b642ee7 100644 --- a/lib/ui/widget/time_tracking_watch.dart +++ b/lib/ui/widget/time_tracking_watch.dart @@ -43,14 +43,24 @@ class _TimeTrackingWatchState extends State { Widget build(BuildContext context) { final state = context.watch().state; - return IgnorePointer( - ignoring: !state.isTracking, - child: InkWell( - onTap: () => _selectStartTime(context), - child: Text( - '$hours:$minutes:$seconds', - style: const TextStyle( - fontSize: 24, + return Container( + width: 130, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(1000), + ), + padding: const EdgeInsets.all(8), + child: IgnorePointer( + ignoring: !state.isTracking, + child: InkWell( + onTap: () => _selectStartTime(context), + child: Text( + '$hours:$minutes:$seconds', + style: TextStyle( + fontSize: 25, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), ), ), ), diff --git a/lib/ui/widget/work_interface_icon.dart b/lib/ui/widget/work_interface_icon.dart index 477e286..d6a6c22 100644 --- a/lib/ui/widget/work_interface_icon.dart +++ b/lib/ui/widget/work_interface_icon.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:syntrack/model/common/task_search_origin.dart'; +import 'package:syntrack/model/work/erpnext/erpnext_config.dart'; +import 'package:syntrack/model/work/redmine/redmine_config.dart'; class WorkInterfaceIcon extends StatelessWidget { - final TaskSearchOrigin origin; + final TaskSearchOrigin? origin; final double borderRadius; final EdgeInsets padding; @@ -13,8 +15,29 @@ class WorkInterfaceIcon extends StatelessWidget { this.borderRadius = 0, }) : super(key: key); + factory WorkInterfaceIcon.fromConfig( + dynamic workInterfaceConfig, { + EdgeInsets padding = EdgeInsets.zero, + double borderRadius = 0, + }) => + switch (workInterfaceConfig) { + ErpNextConfig() => WorkInterfaceIcon( + origin: TaskSearchOrigin.erpNext, + padding: padding, + borderRadius: borderRadius, + ), + RedmineConfig() => WorkInterfaceIcon( + origin: TaskSearchOrigin.redmine, + padding: padding, + borderRadius: borderRadius, + ), + _ => throw Exception('no work interface for $workInterfaceConfig'), + }; + @override Widget build(BuildContext context) { + if (origin == null) return Container(); + final icon = _getIcon(); return Padding( diff --git a/pubspec.lock b/pubspec.lock index b446e75..30a3c8c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -194,7 +194,7 @@ packages: source: hosted version: "4.4.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + easy_debounce: + dependency: "direct main" + description: + name: easy_debounce + sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236 + url: "https://pub.dev" + source: hosted + version: "2.0.3" equatable: dependency: "direct main" description: @@ -478,6 +486,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + isoweek: + dependency: "direct main" + description: + name: isoweek + sha256: "7df2c91f82c8368e414b90e6872c1dab3cccb1173c761faf3724827ec840608b" + url: "https://pub.dev" + source: hosted + version: "1.1.4" js: dependency: transitive description: @@ -723,6 +739,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: ccdc502098a8bfa07b3ec582c282620031481300035584e1bb3aca296a505e8c + url: "https://pub.dev" + source: hosted + version: "0.2.10" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d546bbe..ec0d91c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,10 @@ dependencies: url: https://github.com/enoy19/flutter_typeahead.git ref: streamed path: ^1.8.3 + easy_debounce: ^2.0.3 + collection: ^1.17.1 + isoweek: ^1.1.4 + sliver_tools: ^0.2.10 dev_dependencies: flutter_test: