diff --git a/lib/api/toggl_api_service.chopper.dart b/lib/api/toggl_api_service.chopper.dart index 984886e..890450f 100644 --- a/lib/api/toggl_api_service.chopper.dart +++ b/lib/api/toggl_api_service.chopper.dart @@ -6,6 +6,7 @@ part of 'toggl_api_service.dart'; // ChopperGenerator // ************************************************************************** +// coverage:ignore-file // ignore_for_file: type=lint final class _$TogglApiService extends TogglApiService { _$TogglApiService([ChopperClient? client]) { @@ -14,7 +15,7 @@ final class _$TogglApiService extends TogglApiService { } @override - final definitionType = TogglApiService; + final Type definitionType = TogglApiService; @override Future> getProfile() { @@ -32,7 +33,7 @@ final class _$TogglApiService extends TogglApiService { String startDate, String endDate, ) { - final Uri $url = Uri.parse('/api/v9/me/time_entries'); + final Uri $url = Uri.parse('/api/v9/me/time_entries?meta=true'); final Map $params = { 'start_date': startDate, 'end_date': endDate, @@ -57,6 +58,17 @@ final class _$TogglApiService extends TogglApiService { return client.send, Workspace>($request); } + @override + Future>> getAllClients() { + final Uri $url = Uri.parse('/api/v9/me/clients'); + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + ); + return client.send, TogglClient>($request); + } + @override Future>> getAllProjects() { final Uri $url = Uri.parse('/api/v9/me/projects'); diff --git a/lib/api/toggl_api_service.dart b/lib/api/toggl_api_service.dart index e734c15..95e899f 100644 --- a/lib/api/toggl_api_service.dart +++ b/lib/api/toggl_api_service.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import '../model/project.dart'; import '../model/time_entry.dart'; +import '../model/toggl_client.dart'; import '../model/user.dart'; import '../model/workspace.dart'; import '../resources/keys.dart'; @@ -36,13 +37,16 @@ abstract class TogglApiService extends ChopperService { @Get(path: '/me') Future> getProfile(); - @Get(path: '/me/time_entries') + @Get(path: '/me/time_entries?meta=true') Future>> getTimeEntries( @Query('start_date') String startDate, @Query('end_date') String endDate); @Get(path: '/workspaces') Future>> getAllWorkspaces(); + @Get(path: '/me/clients') + Future>> getAllClients(); + @Get(path: '/me/projects') Future>> getAllProjects(); @@ -110,6 +114,7 @@ Map)> registry = { User: User.fromJson, Workspace: Workspace.fromJson, Project: Project.fromJson, + TogglClient: TogglClient.fromJson, TimeEntry: TimeEntry.fromJson, Map: (Map json) => json, }; diff --git a/lib/model/project.dart b/lib/model/project.dart index 57e5ea4..128163a 100644 --- a/lib/model/project.dart +++ b/lib/model/project.dart @@ -27,6 +27,9 @@ class Project with EquatableMixin { final String? currency; final bool recurring; final int? wid; + @JsonKey(name: 'client_id') + final int? clientId; + final int? cid; Project({ required this.id, @@ -42,6 +45,8 @@ class Project with EquatableMixin { required this.currency, this.recurring = false, this.wid, + this.clientId, + this.cid, }); factory Project.fromJson(Map json) => diff --git a/lib/model/project.g.dart b/lib/model/project.g.dart index 45f9532..9aa3165 100644 --- a/lib/model/project.g.dart +++ b/lib/model/project.g.dart @@ -7,8 +7,8 @@ part of 'project.dart'; // ************************************************************************** Project _$ProjectFromJson(Map json) => Project( - id: json['id'] as int, - workspaceId: json['workspace_id'] as int, + id: (json['id'] as num).toInt(), + workspaceId: (json['workspace_id'] as num).toInt(), name: json['name'] as String, isPrivate: json['is_private'] as bool, active: json['active'] as bool, @@ -20,7 +20,9 @@ Project _$ProjectFromJson(Map json) => Project( billable: json['billable'] as bool? ?? false, currency: json['currency'] as String?, recurring: json['recurring'] as bool? ?? false, - wid: json['wid'] as int?, + wid: (json['wid'] as num?)?.toInt(), + clientId: (json['client_id'] as num?)?.toInt(), + cid: (json['cid'] as num?)?.toInt(), ); Map _$ProjectToJson(Project instance) => { @@ -37,4 +39,6 @@ Map _$ProjectToJson(Project instance) => { 'currency': instance.currency, 'recurring': instance.recurring, 'wid': instance.wid, + 'client_id': instance.clientId, + 'cid': instance.cid, }; diff --git a/lib/model/time_entry.dart b/lib/model/time_entry.dart index b13afa2..2752064 100644 --- a/lib/model/time_entry.dart +++ b/lib/model/time_entry.dart @@ -62,6 +62,8 @@ class TimeEntry with EquatableMixin { @JsonKey(name: 'server_deleted_at', fromJson: deletedFromJson) final bool isDeleted; final bool isRunning; + @JsonKey(name: 'client_name') + final String? clientName; TimeEntryType get type => TimeEntryType.fromBool(billable); @@ -81,6 +83,7 @@ class TimeEntry with EquatableMixin { required this.billable, required this.isDeleted, this.isRunning = false, + this.clientName, }); @visibleForTesting @@ -99,7 +102,8 @@ class TimeEntry with EquatableMixin { wid = -1, pid = -1, billable = false, - isRunning = false; + isRunning = false, + clientName = null; /// CopyWith TimeEntry copyWith({ @@ -118,6 +122,7 @@ class TimeEntry with EquatableMixin { bool? billable, bool? isDeleted, bool? isRunning, + String? clientName, }) { return TimeEntry( id: id ?? this.id, @@ -135,6 +140,7 @@ class TimeEntry with EquatableMixin { billable: billable ?? this.billable, isDeleted: isDeleted ?? this.isDeleted, isRunning: isRunning ?? this.isRunning, + clientName: clientName ?? this.clientName, ); } @@ -160,5 +166,8 @@ class TimeEntry with EquatableMixin { wid, pid, billable, + isDeleted, + isRunning, + clientName, ]; } diff --git a/lib/model/time_entry.g.dart b/lib/model/time_entry.g.dart index 74c9559..591518d 100644 --- a/lib/model/time_entry.g.dart +++ b/lib/model/time_entry.g.dart @@ -7,21 +7,23 @@ part of 'time_entry.dart'; // ************************************************************************** TimeEntry _$TimeEntryFromJson(Map json) => TimeEntry( - id: json['id'] as int, - workspaceId: json['workspace_id'] as int, - projectId: json['project_id'] as int?, - taskId: json['task_id'] as int?, + id: (json['id'] as num).toInt(), + workspaceId: (json['workspace_id'] as num).toInt(), + projectId: (json['project_id'] as num?)?.toInt(), + taskId: (json['task_id'] as num?)?.toInt(), description: json['description'] as String?, start: const DateTimeConverter().fromJson(json['start'] as String), stop: const NullableDateTimeConverter().fromJson(json['stop'] as String?), - duration: const DurationConverter().fromJson(json['duration'] as int), - userId: json['user_id'] as int, - uid: json['uid'] as int, - wid: json['wid'] as int?, - pid: json['pid'] as int?, + duration: + const DurationConverter().fromJson((json['duration'] as num).toInt()), + userId: (json['user_id'] as num).toInt(), + uid: (json['uid'] as num).toInt(), + wid: (json['wid'] as num?)?.toInt(), + pid: (json['pid'] as num?)?.toInt(), billable: json['billable'] as bool, isDeleted: deletedFromJson(json['server_deleted_at']), isRunning: json['isRunning'] as bool? ?? false, + clientName: json['client_name'] as String?, ); Map _$TimeEntryToJson(TimeEntry instance) => { @@ -40,4 +42,5 @@ Map _$TimeEntryToJson(TimeEntry instance) => { 'billable': instance.billable, 'server_deleted_at': instance.isDeleted, 'isRunning': instance.isRunning, + 'client_name': instance.clientName, }; diff --git a/lib/model/toggl_client.dart b/lib/model/toggl_client.dart new file mode 100644 index 0000000..40d87aa --- /dev/null +++ b/lib/model/toggl_client.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../utils/json_converters.dart'; + +part 'toggl_client.g.dart'; + +@JsonSerializable() +class TogglClient with EquatableMixin { + final int id; + final String name; + final int wid; + final bool archived; + @JsonKey(name: 'at') + @DateTimeConverter() + final DateTime createdAt; + + @JsonKey(name: 'creator_id') + final int creatorId; + + TogglClient({ + required this.id, + required this.name, + required this.wid, + required this.archived, + required this.createdAt, + required this.creatorId, + }); + + factory TogglClient.fromJson(Map json) { + return _$TogglClientFromJson(json); + } + + Map toJson() => _$TogglClientToJson(this); + + @override + List get props => [id]; +} diff --git a/lib/model/toggl_client.g.dart b/lib/model/toggl_client.g.dart new file mode 100644 index 0000000..468f8d0 --- /dev/null +++ b/lib/model/toggl_client.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggl_client.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TogglClient _$TogglClientFromJson(Map json) => TogglClient( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + wid: (json['wid'] as num).toInt(), + archived: json['archived'] as bool, + createdAt: const DateTimeConverter().fromJson(json['at'] as String), + creatorId: (json['creator_id'] as num).toInt(), + ); + +Map _$TogglClientToJson(TogglClient instance) => + { + 'id': instance.id, + 'name': instance.name, + 'wid': instance.wid, + 'archived': instance.archived, + 'at': const DateTimeConverter().toJson(instance.createdAt), + 'creator_id': instance.creatorId, + }; diff --git a/lib/model/user.g.dart b/lib/model/user.g.dart index 6625f8e..395fb61 100644 --- a/lib/model/user.g.dart +++ b/lib/model/user.g.dart @@ -7,14 +7,14 @@ part of 'user.dart'; // ************************************************************************** User _$UserFromJson(Map json) => User( - id: json['id'] as int, + id: (json['id'] as num).toInt(), email: json['email'] as String, fullName: json['fullname'] as String, avatarUrl: json['image_url'] as String, timezone: json['timezone'] as String, - defaultWorkspaceId: json['default_workspace_id'] as int?, - beginningOfWeek: json['beginning_of_week'] as int? ?? 1, - countryId: json['country_id'] as int? ?? -1, + defaultWorkspaceId: (json['default_workspace_id'] as num?)?.toInt(), + beginningOfWeek: (json['beginning_of_week'] as num?)?.toInt() ?? 1, + countryId: (json['country_id'] as num?)?.toInt() ?? -1, oauthProviders: (json['oauth_providers'] as List?) ?.map((e) => e as String) .toList() ?? diff --git a/lib/model/workspace.g.dart b/lib/model/workspace.g.dart index c081dcc..4b3b813 100644 --- a/lib/model/workspace.g.dart +++ b/lib/model/workspace.g.dart @@ -7,8 +7,8 @@ part of 'workspace.dart'; // ************************************************************************** Workspace _$WorkspaceFromJson(Map json) => Workspace( - id: json['id'] as int, - organizationId: json['organization_id'] as int, + id: (json['id'] as num).toInt(), + organizationId: (json['organization_id'] as num).toInt(), name: json['name'] as String, defaultCurrency: json['default_currency'] as String, projectsBillableByDefault: json['projects_billable_by_default'] as bool, diff --git a/lib/pages/home/home_store.dart b/lib/pages/home/home_store.dart index c7c0d6b..af7502a 100644 --- a/lib/pages/home/home_store.dart +++ b/lib/pages/home/home_store.dart @@ -15,8 +15,11 @@ import 'package:screwdriver/screwdriver.dart'; import '../../api/toggl_api_service.dart'; import '../../model/day_entry.dart'; +import '../../model/project.dart'; import '../../model/time_entry.dart'; +import '../../model/toggl_client.dart'; import '../../model/user.dart'; +import '../../model/workspace.dart'; import '../../resources/keys.dart'; import '../../utils/app_icon_manager.dart'; import '../../utils/extensions.dart'; @@ -336,27 +339,50 @@ abstract class _HomeStore with Store { void processTimeEntries(List entries) { log('Processing time entries...'); - final int? projectId = getProjectFromStorage()?.id; - final int? workspaceId = getWorkspaceFromStorage()?.id; + final Project? project = getProjectFromStorage(); + final TogglClient? client = getClientFromStorage(); + final Workspace? workspace = getWorkspaceFromStorage(); final TimeEntryType selectedTimeEntryType = TimeEntryType.values.byName( settingsBox.get(HiveKeys.entryType, defaultValue: TimeEntryType.all.name)); - List filtered = entries.where((item) { - if (item.projectId == projectId && item.workspaceId == workspaceId) { - return true; - } - if (workspaceId != null && item.workspaceId != workspaceId) { - log('Skipping entry [${item.id}]: ${item.description} because workspaceId does not match!'); - return false; - } - if (projectId != null && item.projectId != projectId) { - log('Skipping entry [${item.id}]: ${item.description} because projectId does not match!'); - return false; - } - return true; - }).where((entry) { + List filtered = [...entries]; + if (workspace != null && workspace.id != -1) { + // filter by workspace. + filtered = filtered + .where((entry) => + entry.workspaceId == workspace.id || entry.wid == workspace.id) + .toList(); + } + if (client != null && client.id != -1) { + // filter by client. + filtered = + filtered.where((entry) => entry.clientName == client.name).toList(); + } + if (project != null && project.id != -1) { + // filter by client. + filtered = + filtered.where((entry) => entry.projectId == project.id).toList(); + } + + // List filtered = entries.where((item) { + // // when a specific project is selected. + // if (item.projectId == project?.id && item.workspaceId == workspace.id) { + // return true; + // } + // if (workspace != null && item.workspaceId != workspace.id) { + // log('Skipping entry [${item.id}]: ${item.description} because workspaceId does not match!'); + // return false; + // } + // if (project != null && item.projectId != project.id) { + // log('Skipping entry [${item.id}]: ${item.description} because projectId does not match!'); + // return false; + // } + // return true; + // }).toList(); + + filtered = filtered.where((entry) { if (selectedTimeEntryType.isAll) return true; if (entry.type == selectedTimeEntryType) return true; log('Skipping entry [${entry.id}]: ${entry.description} because entry type does not match!'); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 1c43a2e..0fd8f31 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../../model/project.dart'; import '../../model/time_entry.dart'; +import '../../model/toggl_client.dart'; import '../../model/user.dart'; import '../../model/workspace.dart'; import '../../resources/colors.dart'; @@ -423,7 +424,7 @@ class ProjectSettings extends StatelessObserverWidget { return Stack( children: [ SettingsSection( - title: 'Workspace & Project', + title: 'Filters', children: [ const SettingItemTitle('Workspace'), Text( @@ -446,6 +447,38 @@ class ProjectSettings extends StatelessObserverWidget { items: store.workspaces, ), const SizedBox(height: 16), + const SettingItemTitle('Client'), + Text( + 'Select a client to track time for its projects.', + style: subtitleTextStyle(context), + ), + const SizedBox(height: 8), + CustomDropdown( + value: store.selectedClient, + isExpanded: true, + onSelected: (value) async { + if (value.id == store.selectedClient?.id) return; + await store.onClientSelected(value); + homeStore.refreshData(); + }, + itemBuilder: (context, item) { + return CustomDropdownMenuItem( + value: item, + child: Text( + item.name.isNotEmpty ? item.name : 'Untitled', + style: TextStyle( + color: item.name.isEmpty + ? context.theme.textColor.withOpacity(0.5) + : null, + fontStyle: item.name.isNotEmpty ? null : FontStyle.italic, + fontSize: 14, + ), + ), + ); + }, + items: [emptyClient, ...store.filteredClients], + ), + const SizedBox(height: 16), const SettingItemTitle('Project'), Text( 'Select a project to track your time for.', @@ -475,10 +508,7 @@ class ProjectSettings extends StatelessObserverWidget { ), ); }, - items: [ - emptyProject, - ...store.filteredProjects, - ], + items: [emptyProject, ...store.filteredProjects], ), const SizedBox(height: 16), const SettingItemTitle('What to track?'), @@ -524,7 +554,7 @@ class ProjectSettings extends StatelessObserverWidget { splashRadius: 12, constraints: const BoxConstraints(), padding: const EdgeInsets.all(6), - onPressed: store.loadWorkspacesAndProjects, + onPressed: store.loadFilters, icon: Observer(builder: (context) { const icon = Icon(Icons.sync); if (store.isLoadingProjects) { diff --git a/lib/pages/settings/settings_store.dart b/lib/pages/settings/settings_store.dart index ce13d9e..f5316e0 100644 --- a/lib/pages/settings/settings_store.dart +++ b/lib/pages/settings/settings_store.dart @@ -11,6 +11,7 @@ import 'package:screwdriver/screwdriver.dart'; import '../../api/toggl_api_service.dart'; import '../../model/project.dart'; import '../../model/time_entry.dart'; +import '../../model/toggl_client.dart'; import '../../model/workspace.dart'; import '../../resources/keys.dart'; import '../../utils/utils.dart'; @@ -52,6 +53,9 @@ abstract class _SettingsStore with Store { @observable Project? selectedProject; + @observable + TogglClient? selectedClient; + @observable TimeEntryType selectedTimeEntryType = TimeEntryType.all; @@ -63,6 +67,11 @@ abstract class _SettingsStore with Store { List projects = []; + @observable + List filteredClients = []; + + List clients = []; + @observable String? error; @@ -88,7 +97,7 @@ abstract class _SettingsStore with Store { useMaterial3 = box.get(HiveKeys.useMaterial3, defaultValue: true); showRemaining = box.get(HiveKeys.showRemaining, defaultValue: false); - loadWorkspacesAndProjects(); + loadFilters(); } void refresh() { @@ -122,7 +131,7 @@ abstract class _SettingsStore with Store { } @action - Future loadWorkspacesAndProjects() async { + Future loadFilters() async { try { isLoadingProjects = true; error = null; @@ -145,10 +154,20 @@ abstract class _SettingsStore with Store { .where((project) => project.workspaceId == selectedWorkspace!.id) .toList(); + final clientsResponse = await apiService.getAllClients(); + if (!clientsResponse.isSuccessful) { + throw clientsResponse.error ?? clientsResponse.bodyString; + } + + clients = clientsResponse.body!; + filteredClients = clients + .where((client) => client.wid == selectedWorkspace!.id) + .toList(); + isLoadingProjects = false; - log('Workspaces and projects loaded successfully!'); + log('Workspaces, clients, and projects loaded successfully!'); } catch (err, stacktrace) { - log('Failed to load workspaces and projects'); + log('Failed to load workspaces, clients, and projects'); log(err.toString()); log(stacktrace.toString()); error = err.toString(); @@ -159,11 +178,43 @@ abstract class _SettingsStore with Store { @action Future onWorkspaceSelected(Workspace workspace) async { selectedWorkspace = workspace; + // Filter projects by selected workspace. filteredProjects = projects .where((project) => project.workspaceId == workspace.id) .toList(); + // Set selected project to All selectedProject = emptyProject; + + // Filter clients by selected workspace. + filteredClients = + clients.where((client) => client.wid == workspace.id).toList(); + + // Set selected client to All + selectedClient = emptyClient; await secretsBox.put(HiveKeys.workspace, json.encode(workspace.toJson())); + await secretsBox.delete(HiveKeys.client); + await secretsBox.delete(HiveKeys.project); + } + + @action + Future onClientSelected(TogglClient client) async { + selectedClient = client; + if (client == emptyClient) { + // load all projects in selected workspace. + filteredProjects = projects + .where((project) => project.workspaceId == selectedWorkspace!.id) + .toList(); + } else { + // load projects filtered by selected client. + filteredProjects = projects + .where((project) => + project.clientId == client.id || project.cid == client.id) + .toList(); + } + + // Set selected project to All + selectedProject = emptyProject; + await secretsBox.put(HiveKeys.client, json.encode(client.toJson())); await secretsBox.delete(HiveKeys.project); } diff --git a/lib/pages/settings/settings_store.g.dart b/lib/pages/settings/settings_store.g.dart index 69eb502..239b69b 100644 --- a/lib/pages/settings/settings_store.g.dart +++ b/lib/pages/settings/settings_store.g.dart @@ -121,6 +121,22 @@ mixin _$SettingsStore on _SettingsStore, Store { }); } + late final _$selectedClientAtom = + Atom(name: '_SettingsStore.selectedClient', context: context); + + @override + TogglClient? get selectedClient { + _$selectedClientAtom.reportRead(); + return super.selectedClient; + } + + @override + set selectedClient(TogglClient? value) { + _$selectedClientAtom.reportWrite(value, super.selectedClient, () { + super.selectedClient = value; + }); + } + late final _$selectedTimeEntryTypeAtom = Atom(name: '_SettingsStore.selectedTimeEntryType', context: context); @@ -170,6 +186,22 @@ mixin _$SettingsStore on _SettingsStore, Store { }); } + late final _$filteredClientsAtom = + Atom(name: '_SettingsStore.filteredClients', context: context); + + @override + List get filteredClients { + _$filteredClientsAtom.reportRead(); + return super.filteredClients; + } + + @override + set filteredClients(List value) { + _$filteredClientsAtom.reportWrite(value, super.filteredClients, () { + super.filteredClients = value; + }); + } + late final _$errorAtom = Atom(name: '_SettingsStore.error', context: context); @override @@ -185,13 +217,12 @@ mixin _$SettingsStore on _SettingsStore, Store { }); } - late final _$loadWorkspacesAndProjectsAsyncAction = - AsyncAction('_SettingsStore.loadWorkspacesAndProjects', context: context); + late final _$loadFiltersAsyncAction = + AsyncAction('_SettingsStore.loadFilters', context: context); @override - Future loadWorkspacesAndProjects() { - return _$loadWorkspacesAndProjectsAsyncAction - .run(() => super.loadWorkspacesAndProjects()); + Future loadFilters() { + return _$loadFiltersAsyncAction.run(() => super.loadFilters()); } late final _$onWorkspaceSelectedAsyncAction = @@ -203,6 +234,15 @@ mixin _$SettingsStore on _SettingsStore, Store { .run(() => super.onWorkspaceSelected(workspace)); } + late final _$onClientSelectedAsyncAction = + AsyncAction('_SettingsStore.onClientSelected', context: context); + + @override + Future onClientSelected(TogglClient client) { + return _$onClientSelectedAsyncAction + .run(() => super.onClientSelected(client)); + } + late final _$onProjectSelectedAsyncAction = AsyncAction('_SettingsStore.onProjectSelected', context: context); @@ -289,9 +329,11 @@ refreshFrequency: ${refreshFrequency}, isLoadingProjects: ${isLoadingProjects}, selectedWorkspace: ${selectedWorkspace}, selectedProject: ${selectedProject}, +selectedClient: ${selectedClient}, selectedTimeEntryType: ${selectedTimeEntryType}, workspaces: ${workspaces}, filteredProjects: ${filteredProjects}, +filteredClients: ${filteredClients}, error: ${error} '''; } diff --git a/lib/pages/setup/auth_page.dart b/lib/pages/setup/auth_page.dart index aec7823..5cfc9a5 100644 --- a/lib/pages/setup/auth_page.dart +++ b/lib/pages/setup/auth_page.dart @@ -5,6 +5,7 @@ import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_screwdriver/flutter_screwdriver.dart'; @@ -18,6 +19,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../../api/toggl_api_service.dart'; import '../../model/project.dart'; +import '../../model/toggl_client.dart'; import '../../model/user.dart'; import '../../model/workspace.dart'; import '../../resources/keys.dart'; @@ -65,11 +67,18 @@ class _AuthPageState extends State { late bool restoreTheme = widget.restoreTheme; @override - Widget build(BuildContext context) { + void initState() { + super.initState(); if (restoreTheme) { restoreTheme = false; - Future.delayed(Duration.zero, () => AdaptiveTheme.of(context).reset()); + SchedulerBinding.instance.addPostFrameCallback((_) { + AdaptiveTheme.of(context).reset(); + }); } + } + + @override + Widget build(BuildContext context) { return CustomScaffold( body: Center( child: SingleChildScrollView( @@ -160,6 +169,7 @@ class _AuthPageState extends State { builder: (_) => ProjectSelectionPageWrapper( workspaces: store.workspaces, projects: store.projects, + clients: store.clients, ), ), ); @@ -477,6 +487,8 @@ abstract class _AuthStore with Store { List projects = []; + List clients = []; + @observable bool loginWithAPIKey = false; @@ -542,13 +554,22 @@ abstract class _AuthStore with Store { // Load projects. final projectsResponse = await apiService.getAllProjects(); - isLoading = false; if (!projectsResponse.isSuccessful) { error = projectsResponse.bodyString; return false; } projects = projectsResponse.body ?? []; + // Load clients. + final clientsResponse = await apiService.getAllClients(); + + isLoading = false; + if (!clientsResponse.isSuccessful) { + error = clientsResponse.bodyString; + return false; + } + clients = clientsResponse.body ?? []; + await box.putAll({ HiveKeys.authKey: authKey, HiveKeys.user: json.encode(user.toJson()), diff --git a/lib/pages/setup/project_selection_page.dart b/lib/pages/setup/project_selection_page.dart index a8ecbed..37a656f 100644 --- a/lib/pages/setup/project_selection_page.dart +++ b/lib/pages/setup/project_selection_page.dart @@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../../model/project.dart'; import '../../model/time_entry.dart'; +import '../../model/toggl_client.dart'; import '../../model/workspace.dart'; import '../../resources/keys.dart'; import '../../ui/back_button.dart'; @@ -31,17 +32,19 @@ part 'project_selection_page.g.dart'; class ProjectSelectionPageWrapper extends StatelessWidget { final List workspaces; final List projects; + final List clients; const ProjectSelectionPageWrapper({ super.key, required this.workspaces, required this.projects, + required this.clients, }); @override Widget build(BuildContext context) { return Provider( - create: (context) => ProjectSelectionStore(workspaces, projects), + create: (context) => ProjectSelectionStore(workspaces, projects, clients), // dispose: (context, store) => store.dispose(), child: const ProjectSelectionPage(), ); @@ -98,6 +101,38 @@ class _ProjectSelectionPageState extends State { }, ), const SizedBox(height: 24), + const FieldLabel('Select Client'), + Observer( + name: 'ClientSelection-dropdown', + builder: (context) { + return CustomDropdown( + value: store.selectedClient, + isExpanded: true, + onSelected: (value) => store.onClientSelected(value), + itemBuilder: (context, item) { + return CustomDropdownMenuItem( + value: item, + child: Text( + item.name.isNotEmpty ? item.name : 'Untitled', + style: TextStyle( + color: item.name.isEmpty + ? context.theme.textColor.withOpacity(0.5) + : null, + fontStyle: item.name.isNotEmpty + ? null + : FontStyle.italic, + ), + ), + ); + }, + items: [ + emptyClient, + ...store.filteredClients, + ], + ); + }, + ), + const SizedBox(height: 24), const FieldLabel('Select Project'), Observer( name: 'ProjectSelection-dropdown', @@ -229,11 +264,24 @@ class ProjectSelectionStore = _ProjectSelectionStore with _$ProjectSelectionStore; abstract class _ProjectSelectionStore with Store { - _ProjectSelectionStore(this.workspaces, this.projects) { + _ProjectSelectionStore(this.workspaces, this.projects, this.clients) { selectedWorkspace = workspaces.firstOrNull; authKey = box.get(HiveKeys.authKey); + selectedProject = emptyProject; - filteredProjects = [...projects]; + selectedClient = emptyClient; + + if (selectedWorkspace != null) { + filteredProjects = projects + .where((element) => element.workspaceId == selectedWorkspace!.id) + .toList(); + filteredClients = clients + .where((element) => element.wid == selectedWorkspace!.id) + .toList(); + } else { + filteredProjects = [...projects]; + filteredClients = [...clients]; + } } late final Box box = getSecretsBox(); @@ -246,28 +294,65 @@ abstract class _ProjectSelectionStore with Store { final List workspaces; final List projects; + final List clients; late final String authKey; @observable List filteredProjects = []; + @observable + List filteredClients = []; + @observable Workspace? selectedWorkspace; @observable Project? selectedProject; + @observable + TogglClient? selectedClient; + @observable TimeEntryType selectedEntryType = TimeEntryType.all; @action void onWorkspaceSelected(Workspace value) { selectedWorkspace = value; + // Set selected project to All. selectedProject = emptyProject; + // Set selected client to All. + selectedClient = emptyClient; + + // Filter clients by selected workspace. + filteredClients = + clients.where((element) => element.wid == value.id).toList(); + + // Filter projects by selected workspace. filteredProjects = projects.where((element) => element.workspaceId == value.id).toList(); } + @action + void onClientSelected(TogglClient value) { + selectedClient = value; + + // Set selected project to All. + selectedProject = emptyProject; + + if (selectedClient == emptyClient) { + // Load all projects in selected workspace. + filteredProjects = projects + .where((element) => element.workspaceId == selectedWorkspace!.id) + .toList(); + } else { + // Load projects filtered by selected client. + filteredProjects = projects + .where((element) => + element.clientId == value.id || element.cid == value.id) + .toList(); + } + } + @action Future saveAndContinue() async { if (isLoading) return false; @@ -285,6 +370,13 @@ abstract class _ProjectSelectionStore with Store { await box.put(HiveKeys.project, json.encode(selectedProject!.toJson())); } + // Save client + if (selectedClient == null || selectedClient!.id == -1) { + await box.delete(HiveKeys.clientId); + } else { + await box.put(HiveKeys.client, json.encode(selectedClient!.toJson())); + } + // Save entry type await getAppSettingsBox().put(HiveKeys.entryType, selectedEntryType.name); diff --git a/lib/pages/setup/project_selection_page.g.dart b/lib/pages/setup/project_selection_page.g.dart index 38f354d..af0feea 100644 --- a/lib/pages/setup/project_selection_page.g.dart +++ b/lib/pages/setup/project_selection_page.g.dart @@ -57,6 +57,22 @@ mixin _$ProjectSelectionStore on _ProjectSelectionStore, Store { }); } + late final _$filteredClientsAtom = + Atom(name: '_ProjectSelectionStore.filteredClients', context: context); + + @override + List get filteredClients { + _$filteredClientsAtom.reportRead(); + return super.filteredClients; + } + + @override + set filteredClients(List value) { + _$filteredClientsAtom.reportWrite(value, super.filteredClients, () { + super.filteredClients = value; + }); + } + late final _$selectedWorkspaceAtom = Atom(name: '_ProjectSelectionStore.selectedWorkspace', context: context); @@ -89,6 +105,22 @@ mixin _$ProjectSelectionStore on _ProjectSelectionStore, Store { }); } + late final _$selectedClientAtom = + Atom(name: '_ProjectSelectionStore.selectedClient', context: context); + + @override + TogglClient? get selectedClient { + _$selectedClientAtom.reportRead(); + return super.selectedClient; + } + + @override + set selectedClient(TogglClient? value) { + _$selectedClientAtom.reportWrite(value, super.selectedClient, () { + super.selectedClient = value; + }); + } + late final _$selectedEntryTypeAtom = Atom(name: '_ProjectSelectionStore.selectedEntryType', context: context); @@ -127,14 +159,27 @@ mixin _$ProjectSelectionStore on _ProjectSelectionStore, Store { } } + @override + void onClientSelected(TogglClient value) { + final _$actionInfo = _$_ProjectSelectionStoreActionController.startAction( + name: '_ProjectSelectionStore.onClientSelected'); + try { + return super.onClientSelected(value); + } finally { + _$_ProjectSelectionStoreActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' isLoading: ${isLoading}, error: ${error}, filteredProjects: ${filteredProjects}, +filteredClients: ${filteredClients}, selectedWorkspace: ${selectedWorkspace}, selectedProject: ${selectedProject}, +selectedClient: ${selectedClient}, selectedEntryType: ${selectedEntryType} '''; } diff --git a/lib/pages/setup/target_setup_page.dart b/lib/pages/setup/target_setup_page.dart index 6047864..8504155 100644 --- a/lib/pages/setup/target_setup_page.dart +++ b/lib/pages/setup/target_setup_page.dart @@ -55,7 +55,7 @@ class _TargetSetupPageState extends State { @override Widget build(BuildContext context) { return CustomScaffold( - onPopInvoked: (didPop) { + onPopInvokedWithResult: (didPop, result) { if (store.secretsBox.containsKey(HiveKeys.onboarded)) return; if (!store.secretsBox.containsKey(HiveKeys.onboarded)) { diff --git a/lib/resources/keys.dart b/lib/resources/keys.dart index edda543..f926b06 100644 --- a/lib/resources/keys.dart +++ b/lib/resources/keys.dart @@ -11,6 +11,7 @@ class HiveKeys { static const String workspaceId = 'workspace_id'; static const String workspaceName = 'workspace_name'; static const String projectId = 'project_id'; + static const String clientId = 'client_id'; static const String projectName = 'project_name'; static const String workingDays = 'working_days'; static const String weekDays = 'week_days'; @@ -32,6 +33,7 @@ class HiveKeys { static const String user = 'user'; static const String workspace = 'workspace'; static const String project = 'project'; + static const String client = 'client'; static const String showRemaining = 'show_remaining'; static const String entryType = 'entry_type'; } diff --git a/lib/ui/custom_scaffold.dart b/lib/ui/custom_scaffold.dart index ef9ff95..de18dd7 100644 --- a/lib/ui/custom_scaffold.dart +++ b/lib/ui/custom_scaffold.dart @@ -17,7 +17,7 @@ class CustomScaffold extends StatelessWidget { final Color? backgroundColor; final bool gradientBackground; final PreferredSizeWidget? appBar; - final PopInvokedCallback? onPopInvoked; + final PopInvokedWithResultCallback? onPopInvokedWithResult; final bool canPop; const CustomScaffold({ @@ -30,7 +30,7 @@ class CustomScaffold extends StatelessWidget { this.gradientBackground = false, this.appBar, this.canPop = true, - this.onPopInvoked, + this.onPopInvokedWithResult, }); @override @@ -51,7 +51,7 @@ class CustomScaffold extends StatelessWidget { ), child: PopScope( canPop: canPop, - onPopInvoked: onPopInvoked, + onPopInvokedWithResult: onPopInvokedWithResult, child: ColoredBox( color: context.theme.scaffoldBackgroundColor, child: GradientBackground( diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index b2ad244..4d5e716 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -13,6 +13,7 @@ import 'package:screwdriver/screwdriver.dart'; import '../main.dart'; import '../model/day_entry.dart'; import '../model/project.dart'; +import '../model/toggl_client.dart'; import '../model/user.dart'; import '../model/workspace.dart'; import '../pages/home/home_store.dart'; @@ -50,6 +51,13 @@ Project? getProjectFromStorage() { return Project.fromJson(json.decode(getSecretsBox().get(HiveKeys.project))); } +TogglClient? getClientFromStorage() { + if (!getSecretsBox().containsKey(HiveKeys.client)) return null; + + return TogglClient.fromJson( + json.decode(getSecretsBox().get(HiveKeys.client))); +} + List getMonthDaysFromWeekDays(DateTime month, List weekDays) { final days = []; for (var i = 1; i <= month.daysInMonth; i++) { @@ -216,4 +224,13 @@ final Project emptyProject = Project( currency: 'USD', ); +final TogglClient emptyClient = TogglClient( + id: -1, + wid: -1, + name: 'All', + archived: false, + createdAt: DateTime.now(), + creatorId: -1, +); + const double kSidePadding = 16; diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 079b3b7..2fd92c5 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ PRODUCT_MODULE_NAME = TargetMate; PRODUCT_NAME = TargetMate; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development dev.birju.targetmate macos"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development dev.birju.targetmate macos 1722148109"; SWIFT_VERSION = 5.0; }; name = Profile; @@ -574,7 +574,7 @@ PRODUCT_MODULE_NAME = TargetMate; PRODUCT_NAME = TargetMate; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development dev.birju.targetmate macos"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development dev.birju.targetmate macos 1722148109"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef64..8e02df2 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true