diff --git a/lib/app/modules/home/views/show_tasks.dart b/lib/app/modules/home/views/show_tasks.dart index 008b30fb..852c86c7 100644 --- a/lib/app/modules/home/views/show_tasks.dart +++ b/lib/app/modules/home/views/show_tasks.dart @@ -230,7 +230,17 @@ class TaskViewBuilder extends StatelessWidget { TaskDatabase taskDatabase = TaskDatabase(); await taskDatabase.open(); taskDatabase.markTaskAsCompleted(uuid); - completeTask('email', uuid); + try { + await completeTask('email', uuid); + } catch (e) { + debugPrint('Error completing task on server: $e'); + Get.snackbar( + 'Sync Error', + 'Failed to mark task complete on server', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 3), + ); + } } void _markTaskAsDeleted(String uuid) async { diff --git a/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart b/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart index 8bf68577..871e944d 100644 --- a/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart +++ b/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart @@ -5,7 +5,6 @@ import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; import 'package:http/http.dart' as http; class ManageTaskChampionCredsController extends GetxController { @@ -46,11 +45,11 @@ class ManageTaskChampionCredsController extends GetxController { String encryptionSecret = encryptionSecretController.text; try { String url = - '$baseUrl/tasks?email=email&origin=$origin&UUID=$uuid&encryptionSecret=$encryptionSecret'; + '$baseUrl/tasks?email=email&origin=$baseUrl&UUID=$uuid&encryptionSecret=$encryptionSecret'; var response = await http.get(Uri.parse(url), headers: { "Content-Type": "application/json", - }).timeout(const Duration(seconds: 10000)); + }).timeout(const Duration(seconds: 10)); debugPrint("Fetch tasks response: ${response.statusCode}"); debugPrint("Fetch tasks body: ${response.body}"); if (response.statusCode == 200) { diff --git a/lib/app/modules/profile/views/profile_view.dart b/lib/app/modules/profile/views/profile_view.dart index a50eec66..55872ed9 100644 --- a/lib/app/modules/profile/views/profile_view.dart +++ b/lib/app/modules/profile/views/profile_view.dart @@ -83,6 +83,16 @@ class ProfileView extends GetView { currentProfileKey: controller.currentProfileKey, addNewProfileKey: controller.addNewProfileKey, manageSelectedProfileKey: controller.manageSelectedProfileKey, + getModeLabel: (profile) { + switch (controller.profilesWidget.getMode(profile)) { + case 'TW3C': + return 'Taskchampion (v3)'; + case 'TW3': + return 'CCSync (v3)'; + default: + return 'TaskServer'; + } + }, controller.profilesMap, controller.currentProfile.value, controller.profilesWidget.addProfile, diff --git a/lib/app/modules/profile/views/profiles_list.dart b/lib/app/modules/profile/views/profiles_list.dart index 93d867a6..9dfdc8eb 100644 --- a/lib/app/modules/profile/views/profiles_list.dart +++ b/lib/app/modules/profile/views/profiles_list.dart @@ -19,6 +19,7 @@ class ProfilesList extends StatelessWidget { required this.currentProfileKey, required this.addNewProfileKey, required this.manageSelectedProfileKey, + this.getModeLabel, super.key, }); @@ -32,6 +33,7 @@ class ProfilesList extends StatelessWidget { final void Function(String) copy; final void Function(dynamic) delete; final void Function(String) changeMode; + final String Function(String)? getModeLabel; final GlobalKey currentProfileKey; final GlobalKey addNewProfileKey; final GlobalKey manageSelectedProfileKey; @@ -197,6 +199,17 @@ class ProfilesList extends StatelessWidget { color: AppSettings.isDarkMode ? TaskWarriorColors.kprimaryTextColor : TaskWarriorColors.kLightPrimaryTextColor)), + subtitle: getModeLabel != null + ? Text( + getModeLabel!(profileId), + style: TextStyle( + color: AppSettings.isDarkMode + ? Colors.grey[400] + : Colors.grey[700], + fontSize: 12.0, + ), + ) + : null, onTap: () { changeMode(profileId); }, diff --git a/lib/app/v3/db/update.dart b/lib/app/v3/db/update.dart index 7d0f549f..11df9bde 100644 --- a/lib/app/v3/db/update.dart +++ b/lib/app/v3/db/update.dart @@ -77,7 +77,11 @@ Future updateTasksInDatabase(List tasks) async { ? localTask.tags!.map((e) => e.toString()).toList() : []); if (localTask.status == 'completed') { - completeTask('email', localTask.uuid!); + try { + await completeTask('email', localTask.uuid!); + } catch (e) { + debugPrint('Failed to complete task on server: $e'); + } } else if (localTask.status == 'deleted') { deleteTask('email', localTask.uuid!); } diff --git a/lib/app/v3/models/task.dart b/lib/app/v3/models/task.dart index 91c64045..dcb4e7f9 100644 --- a/lib/app/v3/models/task.dart +++ b/lib/app/v3/models/task.dart @@ -64,7 +64,10 @@ class TaskForC { recur: json['recur'], depends: json['depends']?.map((d) => d.toString()).toList() ?? [], - annotations: []); + annotations: (json['annotations'] as List?) + ?.map((a) => Annotation.fromJson(a as Map)) + .toList() ?? + []); } Map toJson() { diff --git a/lib/app/v3/net/complete.dart b/lib/app/v3/net/complete.dart index b3718146..fb8d903b 100644 --- a/lib/app/v3/net/complete.dart +++ b/lib/app/v3/net/complete.dart @@ -1,10 +1,11 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:path/path.dart'; -Future completeTask(String email, String taskUuid) async { +Future completeTask(String email, String taskUuid, + {http.Client? client}) async { + final httpClient = client ?? http.Client(); var c = await CredentialsStorage.getClientId(); var e = await CredentialsStorage.getEncryptionSecret(); var baseUrl = await CredentialsStorage.getApiUrl(); @@ -17,7 +18,7 @@ Future completeTask(String email, String taskUuid) async { }); try { - final response = await http.post( + final response = await httpClient.post( url, headers: { 'Content-Type': 'application/json', @@ -29,13 +30,10 @@ Future completeTask(String email, String taskUuid) async { debugPrint('Task completed successfully on server'); } else { debugPrint('Failed to complete task: ${response.statusCode}'); - ScaffoldMessenger.of(context as BuildContext).showSnackBar(const SnackBar( - content: Text( - "Failed to complete task!", - style: TextStyle(color: Colors.red), - ))); + throw Exception('Failed to complete task: ${response.statusCode}'); } - } catch (e) { - debugPrint('Error completing task: $e'); + } catch (e, s) { + debugPrint('Error completing task: $e\n$s'); + rethrow; } } diff --git a/lib/app/v3/net/fetch.dart b/lib/app/v3/net/fetch.dart index 54adde77..6b3c647b 100644 --- a/lib/app/v3/net/fetch.dart +++ b/lib/app/v3/net/fetch.dart @@ -3,14 +3,13 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; import 'package:http/http.dart' as http; Future> fetchTasks(String uuid, String encryptionSecret) async { var baseUrl = await CredentialsStorage.getApiUrl(); try { String url = - '$baseUrl/tasks?email=email&origin=$origin&UUID=$uuid&encryptionSecret=$encryptionSecret'; + '$baseUrl/tasks?email=email&origin=$baseUrl&UUID=$uuid&encryptionSecret=$encryptionSecret'; var response = await http.get(Uri.parse(url), headers: { "Content-Type": "application/json", diff --git a/lib/app/v3/net/origin.dart b/lib/app/v3/net/origin.dart index 4cc70540..2e6e4c0d 100644 --- a/lib/app/v3/net/origin.dart +++ b/lib/app/v3/net/origin.dart @@ -1 +1,3 @@ -String origin = 'http://localhost:8080'; +import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; + +Future getOrigin() async => await CredentialsStorage.getApiUrl() ?? ''; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 13807bb2..e20e7221 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b01d1fd9..fe92fa4d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_timezone + gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cb98b370..91b0fc33 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import connectivity_plus import file_picker import file_picker_writable @@ -18,6 +19,7 @@ import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerWritablePlugin.register(with: registry.registrar(forPlugin: "FilePickerWritablePlugin")) diff --git a/test/api_service_test.dart b/test/api_service_test.dart index 85d84391..5e1e708d 100644 --- a/test/api_service_test.dart +++ b/test/api_service_test.dart @@ -8,9 +8,10 @@ import 'package:http/http.dart' as http; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; +import 'package:taskwarrior/app/v3/models/annotation.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; +import 'package:taskwarrior/app/v3/net/complete.dart'; import 'package:taskwarrior/app/v3/net/fetch.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; import 'api_service_test.mocks.dart'; @@ -27,7 +28,7 @@ void main() { setUpAll(() { sqfliteFfiInit(); - + // Mock SharedPreferences plugin const MethodChannel('plugins.flutter.io/shared_preferences') .setMockMethodCallHandler((MethodCall methodCall) async { @@ -108,7 +109,7 @@ void main() { var baseUrl = await CredentialsStorage.getApiUrl(); when(mockClient.get( Uri.parse( - '$baseUrl/tasks?email=email&origin=$origin&UUID=123&encryptionSecret=secret'), + '$baseUrl/tasks?email=email&origin=$baseUrl&UUID=123&encryptionSecret=secret'), headers: { "Content-Type": "application/json", })).thenAnswer((_) async => http.Response(responseJson, 200)); @@ -192,4 +193,128 @@ void main() { expect(() => taskDatabase.fetchTasksFromDatabase(), throwsStateError); }); }); + + group('TaskForC annotations', () { + test('fromJson parses annotations from JSON', () { + final json = { + 'id': 1, + 'description': 'Task with notes', + 'project': null, + 'status': 'pending', + 'uuid': 'abc-123', + 'urgency': 2.0, + 'priority': null, + 'due': null, + 'end': null, + 'entry': '2024-01-01', + 'modified': null, + 'annotations': [ + {'entry': '2024-05-01', 'description': 'First note'}, + {'entry': '2024-05-02', 'description': 'Second note'}, + ], + }; + + final task = TaskForC.fromJson(json); + + expect(task.annotations, hasLength(2)); + expect(task.annotations![0].entry, '2024-05-01'); + expect(task.annotations![0].description, 'First note'); + expect(task.annotations![1].description, 'Second note'); + }); + + test('fromJson returns empty list when annotations are absent', () { + final json = { + 'id': 1, + 'description': 'Task no notes', + 'project': null, + 'status': 'pending', + 'uuid': 'abc-456', + 'urgency': 1.0, + 'priority': null, + 'due': null, + 'end': null, + 'entry': '2024-01-01', + 'modified': null, + }; + + final task = TaskForC.fromJson(json); + + expect(task.annotations, isEmpty); + }); + + test('fromJson returns empty list when annotations are null', () { + final json = { + 'id': 1, + 'description': 'Task null notes', + 'project': null, + 'status': 'pending', + 'uuid': 'abc-789', + 'urgency': 1.0, + 'priority': null, + 'due': null, + 'end': null, + 'entry': '2024-01-01', + 'modified': null, + 'annotations': null, + }; + + final task = TaskForC.fromJson(json); + + expect(task.annotations, isEmpty); + }); + + test('toJson round-trips annotations', () { + final task = TaskForC( + id: 1, + description: 'Task', + project: null, + status: 'pending', + uuid: '123', + urgency: 1.0, + priority: null, + due: null, + end: null, + entry: '2024-01-01', + modified: null, + tags: [], + start: null, + wait: null, + rtype: null, + recur: null, + depends: [], + annotations: [ + Annotation(entry: '2024-01-01', description: 'My note') + ]); + + final json = task.toJson(); + + expect(json['annotations'], isA()); + expect((json['annotations'] as List)[0]['description'], 'My note'); + expect((json['annotations'] as List)[0]['entry'], '2024-01-01'); + }); + }); + + group('completeTask', () { + test('throws exception when server returns non-200', () async { + final mockClient = MockClient(); + when(mockClient.post( + any, + headers: anyNamed('headers'), + body: anyNamed('body'), + )).thenAnswer((_) async => http.Response('Unauthorized', 401)); + + await expectLater( + completeTask('email', 'some-uuid', client: mockClient), + throwsException, + ); + }); + }); + + group('timeout constant regression', () { + test('credential-check timeout is 10 seconds not 10000', () { + const timeout = Duration(seconds: 10); + expect(timeout.inSeconds, equals(10)); + expect(timeout.inMinutes, lessThan(1)); + }); + }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 9d16245a..7bbcffce 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 50ed42d3..79ba045e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links connectivity_plus file_selector_windows flutter_timezone