Merge pull request #16 from SteelDynamite/sync-selective-modes-last-sync-has-time

sync-selective-modes-last-sync-has-time
This commit is contained in:
SteelDynamite 2026-04-01 01:38:47 -07:00 committed by GitHub
commit 6aa87b6df9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1420 additions and 215 deletions

View file

@ -56,11 +56,11 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- **Kebab menus**: Tasks, lists, and workspaces all use kebab → submenu pattern for delete.
- **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning).
### Current state (2026-03-31)
### Current state (2026-04-01)
- **Phase 1** (Core + CLI): Complete
- **Phase 2** (WebDAV sync): Backend done, CLI done, GUI wired (settings auto-populates credentials)
- **Phase 3** (GUI MVP): Near complete — core features working, both Tauri and Flutter GUIs maintained
- **Phase 3** (GUI MVP): Complete — both Tauri and Flutter GUIs at feature parity
### GUI features done
@ -73,7 +73,7 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- Workspace switcher drop-up with add/remove
- Dark mode (GNOME-style neutral grays, cyan-blue accent)
- Completed tasks section with animated show/hide
- Due date picker/editor (DateTimePicker in new task + task detail)
- Due date picker/editor (DateTimePicker in new task + task detail); `has_time: bool` field tracks whether time is set
- Move task between lists (kebab menu → "Move to..." submenu)
- List rename (inline input via list kebab menu)
- Group-by-due-date toggle per list (list kebab menu)
@ -81,15 +81,17 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- WebDAV setup flow (settings auto-populates URL/credentials from config + keychain)
- File watcher (notify crate, 500ms debounce, auto-reloads on external changes)
- Setup screen with window dragging + "Open Existing Folder" option
- Sync status indicators (last-sync time + upload/download counts chip)
- Push/pull/full sync mode selection (session-only, in settings)
- Desktop packaging (Linux: AppImage + .deb)
- Flutter GUI at full parity with Tauri (WebDAV UI, has_time, sync status, sync mode)
### GUI features NOT yet done
- Push-only / pull-only sync modes
- Sync status view/indicators
- Workspace retarget/migrate
- Subtask hierarchy (data model exists, not used anywhere)
- Search/filter tasks
- Desktop packaging (Windows, Linux, macOS)
- Desktop packaging for Windows and macOS
## Roadmap

View file

@ -716,17 +716,17 @@ WorkspaceConfig {
- [x] Dark mode (GNOME-style neutral theme, cyan-blue accent)
- [x] Animated completed section show/hide
- [x] Move task between lists (kebab menu → "Move to..." submenu in task detail view)
- [ ] Optional time on due dates (backend `due_date` is `DateTime<Utc>` — needs a separate `due_time` field or a nullable time component so date-only tasks don't default to midnight; currently the GUI uses `hours == 0 && minutes == 0` as a heuristic for "no time set" which breaks for actual midnight times)
- [x] Optional time on due dates (`has_time: bool` field on Task with `#[serde(default)]` for backward compat; replaces the hours==0 heuristic)
- [x] Due date picker/editor (DateTimePicker component in both new task toast + task detail view)
- [x] WebDAV setup flow with credentials (settings auto-populates URL/username/password from config + keychain on open)
- [x] List rename (inline input via list kebab menu in drawer)
- [x] Keyboard shortcuts (Escape closes settings → detail → drawer → menus in priority order)
- [ ] Sync status indicators (per workspace)
- [ ] Push/pull sync mode selection
- [x] Sync status indicators (last-sync time + upload/download counts chip in TasksScreen)
- [x] Push/pull sync mode selection (session-only sync direction selector in SettingsScreen)
- [x] Group-by-due-date toggle per list (checkmark toggle in list kebab menu)
- [ ] Subtask hierarchy (data model exists, needs UI)
- [ ] Search/filter tasks
- [ ] Desktop packaging (Windows, Linux, macOS)
- [x] Desktop packaging (Linux: AppImage + .deb; Windows/macOS not yet verified)
- [x] File watcher (notify crate, 500ms debounce, auto-reloads UI on external file changes)
### Deliverables
@ -735,6 +735,7 @@ WorkspaceConfig {
- [ ] Sub-300ms startup time (not yet measured/optimized)
- [x] Clean, minimal UI
- [ ] Feature parity with CLI
- [x] Flutter GUI at feature parity with Tauri (WebDAV, has_time, sync status, sync mode)
### Build & Release

View file

@ -87,6 +87,53 @@ Future<void> setGroupByDueDate({
Future<bool> getGroupByDueDate({required String listId}) =>
RustLib.instance.api.crateApiGetGroupByDueDate(listId: listId);
Future<void> storeCredentials({
required String domain,
required String username,
required String password,
}) => RustLib.instance.api.crateApiStoreCredentials(
domain: domain,
username: username,
password: password,
);
Future<List<String>> loadCredentials({required String domain}) =>
RustLib.instance.api.crateApiLoadCredentials(domain: domain);
Future<void> setWebdavConfig({
required String workspaceName,
required String webdavUrl,
}) => RustLib.instance.api.crateApiSetWebdavConfig(
workspaceName: workspaceName,
webdavUrl: webdavUrl,
);
Future<void> testWebdavConnection({
required String url,
required String username,
required String password,
}) => RustLib.instance.api.crateApiTestWebdavConnection(
url: url,
username: username,
password: password,
);
Future<SyncResultDto> syncWorkspaceCmd({
required String workspaceName,
required String workspacePath,
required String webdavUrl,
required String username,
required String password,
required String mode,
}) => RustLib.instance.api.crateApiSyncWorkspaceCmd(
workspaceName: workspaceName,
workspacePath: workspacePath,
webdavUrl: webdavUrl,
username: username,
password: password,
mode: mode,
);
Future<Stream<void>> watchWorkspaceChanges({required String path}) =>
RustLib.instance.api.crateApiWatchWorkspaceChanges(path: path);
@ -111,12 +158,52 @@ class AppConfigDto {
currentWorkspace == other.currentWorkspace;
}
class SyncResultDto {
final int uploaded;
final int downloaded;
final int deletedLocal;
final int deletedRemote;
final int conflicts;
final List<String> errors;
const SyncResultDto({
required this.uploaded,
required this.downloaded,
required this.deletedLocal,
required this.deletedRemote,
required this.conflicts,
required this.errors,
});
@override
int get hashCode =>
uploaded.hashCode ^
downloaded.hashCode ^
deletedLocal.hashCode ^
deletedRemote.hashCode ^
conflicts.hashCode ^
errors.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncResultDto &&
runtimeType == other.runtimeType &&
uploaded == other.uploaded &&
downloaded == other.downloaded &&
deletedLocal == other.deletedLocal &&
deletedRemote == other.deletedRemote &&
conflicts == other.conflicts &&
errors == other.errors;
}
class TaskDto {
final String id;
final String title;
final String description;
final String status;
final String? dueDate;
final bool hasTime;
final String createdAt;
final String updatedAt;
final String? parentId;
@ -127,6 +214,7 @@ class TaskDto {
required this.description,
required this.status,
this.dueDate,
required this.hasTime,
required this.createdAt,
required this.updatedAt,
this.parentId,
@ -139,6 +227,7 @@ class TaskDto {
description.hashCode ^
status.hashCode ^
dueDate.hashCode ^
hasTime.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
parentId.hashCode;
@ -153,6 +242,7 @@ class TaskDto {
description == other.description &&
status == other.status &&
dueDate == other.dueDate &&
hasTime == other.hasTime &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
parentId == other.parentId;

View file

@ -64,7 +64,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.11.1';
@override
int get rustContentHash => -75020133;
int get rustContentHash => -1094746925;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@ -107,6 +107,8 @@ abstract class RustLibApi extends BaseApi {
Future<List<TaskDto>> crateApiListTasks({required String listId});
Future<List<String>> crateApiLoadCredentials({required String domain});
Future<void> crateApiMoveTask({
required String fromListId,
required String toListId,
@ -133,6 +135,32 @@ abstract class RustLibApi extends BaseApi {
required bool enabled,
});
Future<void> crateApiSetWebdavConfig({
required String workspaceName,
required String webdavUrl,
});
Future<void> crateApiStoreCredentials({
required String domain,
required String username,
required String password,
});
Future<SyncResultDto> crateApiSyncWorkspaceCmd({
required String workspaceName,
required String workspacePath,
required String webdavUrl,
required String username,
required String password,
required String mode,
});
Future<void> crateApiTestWebdavConnection({
required String url,
required String username,
required String password,
});
Future<TaskDto> crateApiToggleTask({
required String listId,
required String taskId,
@ -482,6 +510,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiListTasksConstMeta =>
const TaskConstMeta(debugName: "list_tasks", argNames: ["listId"]);
@override
Future<List<String>> crateApiLoadCredentials({required String domain}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(domain, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_String,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiLoadCredentialsConstMeta,
argValues: [domain],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiLoadCredentialsConstMeta =>
const TaskConstMeta(debugName: "load_credentials", argNames: ["domain"]);
@override
Future<void> crateApiMoveTask({
required String fromListId,
@ -498,7 +554,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 13,
port: port_,
);
},
@ -528,7 +584,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 14,
port: port_,
);
},
@ -560,7 +616,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 14,
funcId: 15,
port: port_,
);
},
@ -596,7 +652,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 15,
funcId: 16,
port: port_,
);
},
@ -626,7 +682,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 16,
funcId: 17,
port: port_,
);
},
@ -661,7 +717,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 17,
funcId: 18,
port: port_,
);
},
@ -681,6 +737,169 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["listId", "enabled"],
);
@override
Future<void> crateApiSetWebdavConfig({
required String workspaceName,
required String webdavUrl,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(workspaceName, serializer);
sse_encode_String(webdavUrl, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiSetWebdavConfigConstMeta,
argValues: [workspaceName, webdavUrl],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetWebdavConfigConstMeta => const TaskConstMeta(
debugName: "set_webdav_config",
argNames: ["workspaceName", "webdavUrl"],
);
@override
Future<void> crateApiStoreCredentials({
required String domain,
required String username,
required String password,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(domain, serializer);
sse_encode_String(username, serializer);
sse_encode_String(password, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiStoreCredentialsConstMeta,
argValues: [domain, username, password],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiStoreCredentialsConstMeta => const TaskConstMeta(
debugName: "store_credentials",
argNames: ["domain", "username", "password"],
);
@override
Future<SyncResultDto> crateApiSyncWorkspaceCmd({
required String workspaceName,
required String workspacePath,
required String webdavUrl,
required String username,
required String password,
required String mode,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(workspaceName, serializer);
sse_encode_String(workspacePath, serializer);
sse_encode_String(webdavUrl, serializer);
sse_encode_String(username, serializer);
sse_encode_String(password, serializer);
sse_encode_String(mode, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_sync_result_dto,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiSyncWorkspaceCmdConstMeta,
argValues: [
workspaceName,
workspacePath,
webdavUrl,
username,
password,
mode,
],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSyncWorkspaceCmdConstMeta => const TaskConstMeta(
debugName: "sync_workspace_cmd",
argNames: [
"workspaceName",
"workspacePath",
"webdavUrl",
"username",
"password",
"mode",
],
);
@override
Future<void> crateApiTestWebdavConnection({
required String url,
required String username,
required String password,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(url, serializer);
sse_encode_String(username, serializer);
sse_encode_String(password, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiTestWebdavConnectionConstMeta,
argValues: [url, username, password],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiTestWebdavConnectionConstMeta =>
const TaskConstMeta(
debugName: "test_webdav_connection",
argNames: ["url", "username", "password"],
);
@override
Future<TaskDto> crateApiToggleTask({
required String listId,
@ -695,7 +914,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 18,
funcId: 23,
port: port_,
);
},
@ -729,7 +948,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
funcId: 24,
port: port_,
);
},
@ -763,7 +982,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 25,
port: port_,
);
},
@ -827,6 +1046,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_task_dto(raw);
}
@protected
List<String> dco_decode_list_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return (raw as List<dynamic>).map(dco_decode_String).toList();
}
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -857,21 +1082,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_String(raw);
}
@protected
SyncResultDto dco_decode_sync_result_dto(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 6)
throw Exception('unexpected arr length: expect 6 but see ${arr.length}');
return SyncResultDto(
uploaded: dco_decode_u_32(arr[0]),
downloaded: dco_decode_u_32(arr[1]),
deletedLocal: dco_decode_u_32(arr[2]),
deletedRemote: dco_decode_u_32(arr[3]),
conflicts: dco_decode_u_32(arr[4]),
errors: dco_decode_list_String(arr[5]),
);
}
@protected
TaskDto dco_decode_task_dto(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 8)
throw Exception('unexpected arr length: expect 8 but see ${arr.length}');
if (arr.length != 9)
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
return TaskDto(
id: dco_decode_String(arr[0]),
title: dco_decode_String(arr[1]),
description: dco_decode_String(arr[2]),
status: dco_decode_String(arr[3]),
dueDate: dco_decode_opt_String(arr[4]),
createdAt: dco_decode_String(arr[5]),
updatedAt: dco_decode_String(arr[6]),
parentId: dco_decode_opt_String(arr[7]),
hasTime: dco_decode_bool(arr[5]),
createdAt: dco_decode_String(arr[6]),
updatedAt: dco_decode_String(arr[7]),
parentId: dco_decode_opt_String(arr[8]),
);
}
@ -967,6 +1209,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_task_dto(deserializer));
}
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
var ans_ = <String>[];
for (var idx_ = 0; idx_ < len_; ++idx_) {
ans_.add(sse_decode_String(deserializer));
}
return ans_;
}
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1025,6 +1279,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
SyncResultDto sse_decode_sync_result_dto(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_uploaded = sse_decode_u_32(deserializer);
var var_downloaded = sse_decode_u_32(deserializer);
var var_deletedLocal = sse_decode_u_32(deserializer);
var var_deletedRemote = sse_decode_u_32(deserializer);
var var_conflicts = sse_decode_u_32(deserializer);
var var_errors = sse_decode_list_String(deserializer);
return SyncResultDto(
uploaded: var_uploaded,
downloaded: var_downloaded,
deletedLocal: var_deletedLocal,
deletedRemote: var_deletedRemote,
conflicts: var_conflicts,
errors: var_errors,
);
}
@protected
TaskDto sse_decode_task_dto(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1033,6 +1306,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_description = sse_decode_String(deserializer);
var var_status = sse_decode_String(deserializer);
var var_dueDate = sse_decode_opt_String(deserializer);
var var_hasTime = sse_decode_bool(deserializer);
var var_createdAt = sse_decode_String(deserializer);
var var_updatedAt = sse_decode_String(deserializer);
var var_parentId = sse_decode_opt_String(deserializer);
@ -1042,6 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
description: var_description,
status: var_status,
dueDate: var_dueDate,
hasTime: var_hasTime,
createdAt: var_createdAt,
updatedAt: var_updatedAt,
parentId: var_parentId,
@ -1154,6 +1429,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_task_dto(self, serializer);
}
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
for (final item in self) {
sse_encode_String(item, serializer);
}
}
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self,
@ -1207,6 +1491,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_sync_result_dto(
SyncResultDto self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_u_32(self.uploaded, serializer);
sse_encode_u_32(self.downloaded, serializer);
sse_encode_u_32(self.deletedLocal, serializer);
sse_encode_u_32(self.deletedRemote, serializer);
sse_encode_u_32(self.conflicts, serializer);
sse_encode_list_String(self.errors, serializer);
}
@protected
void sse_encode_task_dto(TaskDto self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1215,6 +1513,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(self.description, serializer);
sse_encode_String(self.status, serializer);
sse_encode_opt_String(self.dueDate, serializer);
sse_encode_bool(self.hasTime, serializer);
sse_encode_String(self.createdAt, serializer);
sse_encode_String(self.updatedAt, serializer);
sse_encode_opt_String(self.parentId, serializer);

View file

@ -36,6 +36,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TaskDto dco_decode_box_autoadd_task_dto(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@ -51,6 +54,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
SyncResultDto dco_decode_sync_result_dto(dynamic raw);
@protected
TaskDto dco_decode_task_dto(dynamic raw);
@ -89,6 +95,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TaskDto sse_decode_box_autoadd_task_dto(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@ -106,6 +115,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
SyncResultDto sse_decode_sync_result_dto(SseDeserializer deserializer);
@protected
TaskDto sse_decode_task_dto(SseDeserializer deserializer);
@ -151,6 +163,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_box_autoadd_task_dto(TaskDto self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self,
@ -175,6 +190,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_sync_result_dto(SyncResultDto self, SseSerializer serializer);
@protected
void sse_encode_task_dto(TaskDto self, SseSerializer serializer);

View file

@ -38,6 +38,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TaskDto dco_decode_box_autoadd_task_dto(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@ -53,6 +56,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
SyncResultDto dco_decode_sync_result_dto(dynamic raw);
@protected
TaskDto dco_decode_task_dto(dynamic raw);
@ -91,6 +97,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TaskDto sse_decode_box_autoadd_task_dto(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@ -108,6 +117,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
SyncResultDto sse_decode_sync_result_dto(SseDeserializer deserializer);
@protected
TaskDto sse_decode_task_dto(SseDeserializer deserializer);
@ -153,6 +165,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_box_autoadd_task_dto(TaskDto self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self,
@ -177,6 +192,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_sync_result_dto(SyncResultDto self, SseSerializer serializer);
@protected
void sse_encode_task_dto(TaskDto self, SseSerializer serializer);

View file

@ -1,15 +1,131 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../rust/api.dart' as api;
import '../state/app_state.dart';
import '../theme.dart';
class SettingsScreen extends StatelessWidget {
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _urlController = TextEditingController();
final _userController = TextEditingController();
final _passController = TextEditingController();
String _testStatus = 'idle'; // idle | testing | ok | fail
@override
void initState() {
super.initState();
_loadCredentials();
}
@override
void dispose() {
_urlController.dispose();
_userController.dispose();
_passController.dispose();
super.dispose();
}
Future<void> _loadCredentials() async {
final state = context.read<AppState>();
final wsName = state.config?.currentWorkspace;
if (wsName == null) return;
final ws = state.config!.workspaces.cast<api.WorkspaceEntry?>().firstWhere(
(w) => w?.name == wsName, orElse: () => null);
if (ws?.webdavUrl != null) {
_urlController.text = ws!.webdavUrl!;
try {
final domain = Uri.parse(ws.webdavUrl!).host;
final creds = await api.loadCredentials(domain: domain);
if (mounted) {
setState(() {
_userController.text = creds[0];
_passController.text = creds[1];
});
}
} catch (_) {}
}
}
Future<void> _testConnection() async {
setState(() => _testStatus = 'testing');
try {
await api.testWebdavConnection(
url: _urlController.text,
username: _userController.text,
password: _passController.text,
);
if (mounted) setState(() => _testStatus = 'ok');
} catch (_) {
if (mounted) setState(() => _testStatus = 'fail');
}
}
Future<void> _save() async {
final state = context.read<AppState>();
final wsName = state.config?.currentWorkspace;
if (wsName == null || _urlController.text.trim().isEmpty) return;
await api.setWebdavConfig(
workspaceName: wsName,
webdavUrl: _urlController.text.trim(),
);
if (_userController.text.isNotEmpty && _passController.text.isNotEmpty) {
final domain = Uri.parse(_urlController.text.trim()).host;
await api.storeCredentials(
domain: domain,
username: _userController.text,
password: _passController.text,
);
}
await state.loadConfig();
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final borderColor = isDark ? AppTheme.borderDark : AppTheme.borderLight;
final inputDecoration = InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: borderColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppTheme.primary),
),
);
final wsName = state.config?.currentWorkspace;
final ws = wsName == null ? null : state.config!.workspaces.cast<api.WorkspaceEntry?>()
.firstWhere((w) => w?.name == wsName, orElse: () => null);
final lastSync = ws?.lastSync;
String? relTime;
if (lastSync != null) {
final t = DateTime.tryParse(lastSync);
if (t != null) {
final secsAgo = DateTime.now().difference(t).inSeconds;
if (secsAgo < 60) {
relTime = 'just now';
} else if (secsAgo < 3600) {
relTime = '${secsAgo ~/ 60}m ago';
} else {
relTime = '${secsAgo ~/ 3600}h ago';
}
}
}
return GestureDetector(
onTap: () => state.setScreen('tasks'),
child: Container(
@ -20,131 +136,233 @@ class SettingsScreen extends StatelessWidget {
),
child: GestureDetector(
onTap: () {},
child: AnimatedScale(
scale: 1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Container(
decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.7), blurRadius: 60, offset: const Offset(0, 25)),
BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 20, offset: const Offset(0, 10)),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
// Header (matching Tauri: text-lg font-bold, border-b, px-4 py-3)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)),
),
child: Row(
children: [
const Text('Settings', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
GestureDetector(
onTap: () => state.setScreen('tasks'),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.close, size: 20,
color: isDark ? AppTheme.textDark : AppTheme.textLight),
),
),
],
),
child: Container(
decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.7), blurRadius: 60, offset: const Offset(0, 25)),
BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 20, offset: const Offset(0, 10)),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: borderColor, width: 0.5)),
),
// Scrollable content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// WebDAV Sync section (matching Tauri order: sync first)
Text('WEBDAV SYNC',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5),
)),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight),
),
child: Text(
'WebDAV sync not yet available in Flutter build',
style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight),
),
child: Row(
children: [
const Text('Settings', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
GestureDetector(
onTap: () => state.setScreen('tasks'),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.close, size: 20,
color: isDark ? AppTheme.textDark : AppTheme.textLight),
),
),
],
),
),
// Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// WebDAV section header
Text('WEBDAV SYNC',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5))),
const SizedBox(height: 12),
// Credentials card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
const SizedBox(height: 24),
// Appearance section
Text('APPEARANCE',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5),
)),
const SizedBox(height: 12),
// Dark mode toggle in bordered card (matching Tauri)
GestureDetector(
onTap: () => state.toggleDarkMode(),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight),
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Server URL', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500,
color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
const SizedBox(height: 4),
TextField(controller: _urlController, style: const TextStyle(fontSize: 13),
decoration: inputDecoration.copyWith(hintText: 'https://dav.example.com/tasks/')),
const SizedBox(height: 10),
Text('Username', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500,
color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
const SizedBox(height: 4),
TextField(controller: _userController, style: const TextStyle(fontSize: 13),
decoration: inputDecoration),
const SizedBox(height: 10),
Text('Password', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500,
color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
const SizedBox(height: 4),
TextField(controller: _passController, obscureText: true,
style: const TextStyle(fontSize: 13), decoration: inputDecoration),
const SizedBox(height: 14),
Row(
children: [
const Text('Dark mode', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const Spacer(),
// Toggle switch (matching Tauri: h-6 w-11)
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 44,
height: 24,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 150),
alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: 20,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
Expanded(
child: OutlinedButton(
onPressed: _urlController.text.isEmpty ? null : _testConnection,
style: OutlinedButton.styleFrom(
side: BorderSide(color: borderColor),
padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
_testStatus == 'testing' ? 'Testing…'
: _testStatus == 'ok' ? 'Connected'
: _testStatus == 'fail' ? 'Failed — Retry'
: 'Test Connection',
style: const TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: _urlController.text.isEmpty ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Save', style: TextStyle(fontSize: 13)),
),
),
],
),
],
),
),
// Sync direction + Sync Now
if (wsName != null) ...[
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(8),
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
),
child: DropdownButton<String>(
value: state.syncMode,
isExpanded: true,
underline: const SizedBox.shrink(),
style: TextStyle(fontSize: 13,
color: isDark ? AppTheme.textDark : AppTheme.textLight),
dropdownColor: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
items: const [
DropdownMenuItem(value: 'full', child: Text('Sync both ways')),
DropdownMenuItem(value: 'push', child: Text('Push only')),
DropdownMenuItem(value: 'pull', child: Text('Pull only')),
],
onChanged: (v) { if (v != null) state.setSyncMode(v); },
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: state.syncing ? null : () => state.triggerSync(),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
disabledBackgroundColor: AppTheme.primary.withValues(alpha: 0.4),
),
child: Text(state.syncing ? 'Syncing…' : 'Sync Now',
style: const TextStyle(fontSize: 13)),
),
),
],
),
if (relTime != null) ...[
const SizedBox(height: 6),
Row(
children: [
Text('Last sync: $relTime',
style: TextStyle(fontSize: 11,
color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))),
if (state.lastSyncResult != null) ...[
Text(' · ↑${state.lastSyncResult!.uploaded}${state.lastSyncResult!.downloaded}',
style: TextStyle(fontSize: 11,
color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))),
],
],
),
],
],
const SizedBox(height: 24),
// Appearance
Text('APPEARANCE',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5))),
const SizedBox(height: 12),
GestureDetector(
onTap: () => state.toggleDarkMode(),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Row(
children: [
const Text('Dark mode', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const Spacer(),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 44, height: 24,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 150),
alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: 20, height: 20,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
),
),
),
],
),
),
const SizedBox(height: 32),
Center(
child: Text('Flutter + Rust', style: TextStyle(fontSize: 12,
),
const SizedBox(height: 32),
Center(
child: Text('Flutter + Rust',
style: TextStyle(fontSize: 12,
color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.3))),
),
],
),
),
],
),
),
],
),
),
],
),
),
),

View file

@ -69,13 +69,13 @@ class _TasksScreenState extends State<TasksScreen> with SingleTickerProviderStat
});
}
Future<void> _handleCreateTask(String title, String desc, {String? dueDate}) async {
Future<void> _handleCreateTask(String title, String desc, {String? dueDate, bool hasTime = false}) async {
final state = context.read<AppState>();
final task = await state.createTask(title, desc);
if (task != null && dueDate != null) {
await state.updateTask(api.TaskDto(
id: task.id, title: task.title, description: task.description,
status: task.status, dueDate: dueDate,
status: task.status, dueDate: dueDate, hasTime: hasTime,
createdAt: task.createdAt, updatedAt: task.updatedAt, parentId: task.parentId,
));
}
@ -257,6 +257,32 @@ class _TasksScreenState extends State<TasksScreen> with SingleTickerProviderStat
),
),
),
// Sync status indicator
Positioned(
bottom: 16,
right: 16,
child: IgnorePointer(
child: state.syncing
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppTheme.primary))
: state.lastSyncResult != null
? Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.lastSyncResult!.uploaded}${state.lastSyncResult!.downloaded}',
style: TextStyle(fontSize: 11,
color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.6)),
),
)
: const SizedBox.shrink(),
),
),
],
);
}

View file

@ -11,6 +11,8 @@ class AppState extends ChangeNotifier {
bool darkMode = true;
StreamSubscription? _watcherSub;
bool syncing = false;
String syncMode = 'full';
api.SyncResultDto? lastSyncResult;
String? error;
// Selected task for detail view
@ -264,6 +266,45 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
void setSyncMode(String mode) {
syncMode = mode;
notifyListeners();
}
Future<void> triggerSync() async {
if (config?.currentWorkspace == null) return;
final wsName = config!.currentWorkspace!;
final ws = config!.workspaces.firstWhere((w) => w.name == wsName);
if (ws.webdavUrl == null) {
error = 'No WebDAV URL configured';
notifyListeners();
return;
}
syncing = true;
error = null;
notifyListeners();
try {
final domain = Uri.parse(ws.webdavUrl!).host;
final creds = await api.loadCredentials(domain: domain);
final result = await api.syncWorkspaceCmd(
workspaceName: wsName,
workspacePath: ws.path,
webdavUrl: ws.webdavUrl!,
username: creds[0],
password: creds[1],
mode: syncMode,
);
lastSyncResult = result;
if (result.errors.isNotEmpty) error = result.errors.join('; ');
config = await api.getConfig();
await loadLists();
} catch (e) {
error = e.toString();
}
syncing = false;
notifyListeners();
}
void toggleDarkMode() {
darkMode = !darkMode;
notifyListeners();

View file

@ -3,10 +3,11 @@ import '../theme.dart';
class DateTimePicker extends StatefulWidget {
final DateTime? initialDate;
final void Function(DateTime date) onDone;
final bool initialHasTime;
final void Function(DateTime date, bool hasTime) onDone;
final VoidCallback onClear;
const DateTimePicker({super.key, this.initialDate, required this.onDone, required this.onClear});
const DateTimePicker({super.key, this.initialDate, this.initialHasTime = false, required this.onDone, required this.onClear});
@override
State<DateTimePicker> createState() => _DateTimePickerState();
@ -27,7 +28,7 @@ class _DateTimePickerState extends State<DateTimePicker> {
if (widget.initialDate != null) {
_hour = widget.initialDate!.hour;
_minute = widget.initialDate!.minute;
_showTime = _hour != 0 || _minute != 0;
_showTime = widget.initialHasTime;
}
}
@ -39,7 +40,7 @@ class _DateTimePickerState extends State<DateTimePicker> {
final result = _showTime
? DateTime(_selected!.year, _selected!.month, _selected!.day, _hour, _minute)
: DateTime(_selected!.year, _selected!.month, _selected!.day);
widget.onDone(result);
widget.onDone(result, _showTime);
Navigator.of(context).pop();
}
@ -137,33 +138,17 @@ class _DateTimePickerState extends State<DateTimePicker> {
);
}),
const SizedBox(height: 8),
// Time toggle
// Time section
Container(
padding: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)),
),
child: Column(
children: [
GestureDetector(
onTap: () => setState(() => _showTime = !_showTime),
child: Row(
children: [
Icon(Icons.access_time, size: 16, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight),
const SizedBox(width: 8),
Text(_showTime ? 'Time' : 'Set time',
style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
const Spacer(),
Icon(_showTime ? Icons.expand_less : Icons.expand_more, size: 18,
color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight),
],
),
),
if (_showTime) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
child: _showTime
? Row(
children: [
Text('Time', style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
const SizedBox(width: 12),
// Hour
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@ -180,7 +165,7 @@ class _DateTimePickerState extends State<DateTimePicker> {
onChanged: (v) => setState(() => _hour = v!),
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text(':', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))),
const Padding(padding: EdgeInsets.symmetric(horizontal: 6), child: Text(':', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))),
// Minute
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@ -197,11 +182,17 @@ class _DateTimePickerState extends State<DateTimePicker> {
onChanged: (v) => setState(() => _minute = v!),
),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _showTime = false),
child: Icon(Icons.close, size: 18, color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withAlpha(160)),
),
],
)
: GestureDetector(
onTap: () => setState(() { _showTime = true; if (_selected == null) _selected = DateTime.now(); }),
child: Text('Set time', style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)),
),
],
],
),
),
// Clear button
if (widget.initialDate != null) ...[

View file

@ -3,7 +3,7 @@ import '../theme.dart';
import 'date_time_picker.dart';
class NewTaskInput extends StatefulWidget {
final Future<void> Function(String title, String description, {String? dueDate}) onCreate;
final Future<void> Function(String title, String description, {String? dueDate, bool hasTime}) onCreate;
const NewTaskInput({super.key, required this.onCreate});
@ -16,6 +16,7 @@ class _NewTaskInputState extends State<NewTaskInput> {
final _descController = TextEditingController();
final _titleFocus = FocusNode();
DateTime? _selectedDate;
bool _selectedHasTime = false;
@override
void initState() {
@ -34,7 +35,7 @@ class _NewTaskInputState extends State<NewTaskInput> {
Future<void> _submit() async {
final title = _titleController.text.trim();
if (title.isEmpty) return;
await widget.onCreate(title, _descController.text.trim(), dueDate: _selectedDate?.toUtc().toIso8601String());
await widget.onCreate(title, _descController.text.trim(), dueDate: _selectedDate?.toUtc().toIso8601String(), hasTime: _selectedHasTime);
}
void _pickDate() {
@ -47,8 +48,9 @@ class _NewTaskInputState extends State<NewTaskInput> {
),
builder: (_) => DateTimePicker(
initialDate: _selectedDate,
onDone: (date) => setState(() => _selectedDate = date),
onClear: () => setState(() => _selectedDate = null),
initialHasTime: _selectedHasTime,
onDone: (date, hasTime) => setState(() { _selectedDate = date; _selectedHasTime = hasTime; }),
onClear: () => setState(() { _selectedDate = null; _selectedHasTime = false; }),
),
);
}
@ -60,8 +62,7 @@ class _NewTaskInputState extends State<NewTaskInput> {
final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
final day = dayNames[d.weekday % 7];
final pad = (int n) => n.toString().padLeft(2, '0');
final hasTime = d.hour != 0 || d.minute != 0;
final timePart = hasTime ? ', ${pad(d.hour)}:${pad(d.minute)}' : '';
final timePart = _selectedHasTime ? ', ${pad(d.hour)}:${pad(d.minute)}' : '';
if (taskDate == today) return 'Today$timePart';
return '$day, ${pad(d.day)}/${pad(d.month)}$timePart';
}

View file

@ -55,7 +55,7 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
super.dispose();
}
void _scheduleUpdate({String? dueDate}) {
void _scheduleUpdate({String? dueDate, bool? hasTime}) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
final state = context.read<AppState>();
@ -65,6 +65,7 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
description: _descController.text,
status: widget.task.status,
dueDate: dueDate ?? widget.task.dueDate,
hasTime: hasTime ?? widget.task.hasTime,
createdAt: widget.task.createdAt,
updatedAt: widget.task.updatedAt,
parentId: widget.task.parentId,
@ -72,7 +73,7 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
});
}
void _updateDueDate(String? dueDate) {
void _updateDueDate(String? dueDate, {bool hasTime = false}) {
final state = context.read<AppState>();
state.updateTask(api.TaskDto(
id: widget.task.id,
@ -80,6 +81,7 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
description: _descController.text,
status: widget.task.status,
dueDate: dueDate,
hasTime: hasTime,
createdAt: widget.task.createdAt,
updatedAt: widget.task.updatedAt,
parentId: widget.task.parentId,
@ -96,7 +98,8 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
),
builder: (_) => DateTimePicker(
initialDate: widget.task.dueDate != null ? DateTime.tryParse(widget.task.dueDate!) : null,
onDone: (date) => _updateDueDate(date.toUtc().toIso8601String()),
initialHasTime: widget.task.hasTime,
onDone: (date, hasTime) => _updateDueDate(date.toUtc().toIso8601String(), hasTime: hasTime),
onClear: () => _updateDueDate(null),
),
);
@ -112,8 +115,7 @@ class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProvid
final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
final day = dayNames[local.weekday % 7];
final pad = (int n) => n.toString().padLeft(2, '0');
final hasTime = local.hour != 0 || local.minute != 0;
final timePart = hasTime ? ', ${pad(local.hour)}:${pad(local.minute)}' : '';
final timePart = widget.task.hasTime ? ', ${pad(local.hour)}:${pad(local.minute)}' : '';
if (taskDate == today) return 'Today$timePart';
return '$day, ${pad(local.day)}/${pad(local.month)}$timePart';
}

View file

@ -1194,6 +1194,7 @@ dependencies = [
"notify-debouncer-mini",
"once_cell",
"onyx-core",
"tokio",
"uuid",
]

View file

@ -14,3 +14,4 @@ chrono = { version = "0.4", features = ["serde"] }
once_cell = "1"
notify = "7"
notify-debouncer-mini = "0.5"
tokio = { version = "1", features = ["full"] }

View file

@ -11,6 +11,8 @@ use onyx_core::{
config::{AppConfig, WorkspaceConfig},
models::{Task, TaskStatus},
repository::TaskRepository,
sync::{self, SyncMode},
webdav,
};
// ── State ───────────────────────────────────────────────────────────
@ -44,11 +46,21 @@ pub struct TaskDto {
pub description: String,
pub status: String,
pub due_date: Option<String>,
pub has_time: bool,
pub created_at: String,
pub updated_at: String,
pub parent_id: Option<String>,
}
pub struct SyncResultDto {
pub uploaded: u32,
pub downloaded: u32,
pub deleted_local: u32,
pub deleted_remote: u32,
pub conflicts: u32,
pub errors: Vec<String>,
}
pub struct TaskListDto {
pub id: String,
pub title: String,
@ -79,6 +91,7 @@ fn task_to_dto(t: &Task) -> TaskDto {
TaskStatus::Completed => "completed".into(),
},
due_date: t.due_date.map(|d| d.to_rfc3339()),
has_time: t.has_time,
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
parent_id: t.parent_id.map(|id| id.to_string()),
@ -218,6 +231,7 @@ pub fn update_task(list_id: String, task: TaskDto) -> Result<(), String> {
.as_deref()
.and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok())
.map(|d| d.with_timezone(&chrono::Utc));
existing.has_time = task.has_time;
s.repo.as_mut().unwrap().update_task(lid, existing).map_err(|e| e.to_string())
}
@ -295,6 +309,74 @@ pub fn get_group_by_due_date(list_id: String) -> Result<bool, String> {
s.repo.as_ref().unwrap().get_group_by_due_date(id).map_err(|e| e.to_string())
}
// ── Sync commands ──────────────────────────────────────────────────
pub fn store_credentials(domain: String, username: String, password: String) -> Result<(), String> {
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
}
pub fn load_credentials(domain: String) -> Result<Vec<String>, String> {
let (u, p) = webdav::load_credentials(&domain).map_err(|e| e.to_string())?;
Ok(vec![u, p])
}
pub fn set_webdav_config(workspace_name: String, webdav_url: String) -> Result<(), String> {
let mut s = STATE.lock().unwrap();
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.webdav_url = Some(webdav_url);
}
let config_path = AppConfig::get_config_path();
s.config.save_to_file(&config_path).map_err(|e| e.to_string())
}
pub async fn test_webdav_connection(url: String, username: String, password: String) -> Result<(), String> {
let client = webdav::WebDavClient::new(&url, &username, &password);
client.test_connection().await.map_err(|e| e.to_string())
}
pub async fn sync_workspace_cmd(
workspace_name: String,
workspace_path: String,
webdav_url: String,
username: String,
password: String,
mode: String,
) -> Result<SyncResultDto, String> {
let sync_mode = match mode.as_str() {
"push" => SyncMode::Push,
"pull" => SyncMode::Pull,
_ => SyncMode::Full,
};
let result = sync::sync_workspace(
&PathBuf::from(&workspace_path),
&webdav_url,
&username,
&password,
sync_mode,
None,
)
.await
.map_err(|e| e.to_string())?;
{
let mut s = STATE.lock().unwrap();
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.last_sync = Some(chrono::Utc::now());
}
let config_path = AppConfig::get_config_path();
s.config.save_to_file(&config_path).map_err(|e| e.to_string())?;
}
Ok(SyncResultDto {
uploaded: result.uploaded,
downloaded: result.downloaded,
deleted_local: result.deleted_local,
deleted_remote: result.deleted_remote,
conflicts: result.conflicts,
errors: result.errors,
})
}
// ── File watcher ───────────────────────────────────────────────────
static WATCHER: Mutex<Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>> =

View file

@ -37,7 +37,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -75020133;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1094746925;
// Section: executor
@ -411,6 +411,39 @@ fn wire__crate__api__list_tasks_impl(
},
)
}
fn wire__crate__api__load_credentials_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "load_credentials",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_domain = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, String>((move || {
let output_ok = crate::api::load_credentials(api_domain)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__move_task_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -617,6 +650,166 @@ fn wire__crate__api__set_group_by_due_date_impl(
},
)
}
fn wire__crate__api__set_webdav_config_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_webdav_config",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_workspace_name = <String>::sse_decode(&mut deserializer);
let api_webdav_url = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, String>((move || {
let output_ok =
crate::api::set_webdav_config(api_workspace_name, api_webdav_url)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__store_credentials_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "store_credentials",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_domain = <String>::sse_decode(&mut deserializer);
let api_username = <String>::sse_decode(&mut deserializer);
let api_password = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, String>((move || {
let output_ok =
crate::api::store_credentials(api_domain, api_username, api_password)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__sync_workspace_cmd_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "sync_workspace_cmd",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_workspace_name = <String>::sse_decode(&mut deserializer);
let api_workspace_path = <String>::sse_decode(&mut deserializer);
let api_webdav_url = <String>::sse_decode(&mut deserializer);
let api_username = <String>::sse_decode(&mut deserializer);
let api_password = <String>::sse_decode(&mut deserializer);
let api_mode = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, String>(
(move || async move {
let output_ok = crate::api::sync_workspace_cmd(
api_workspace_name,
api_workspace_path,
api_webdav_url,
api_username,
api_password,
api_mode,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__test_webdav_connection_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "test_webdav_connection",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_url = <String>::sse_decode(&mut deserializer);
let api_username = <String>::sse_decode(&mut deserializer);
let api_password = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, String>(
(move || async move {
let output_ok =
crate::api::test_webdav_connection(api_url, api_username, api_password)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__toggle_task_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -770,6 +963,18 @@ impl SseDecode for bool {
}
}
impl SseDecode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = vec![];
for idx_ in 0..len_ {
ans_.push(<String>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -829,6 +1034,26 @@ impl SseDecode for Option<String> {
}
}
impl SseDecode for crate::api::SyncResultDto {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_uploaded = <u32>::sse_decode(deserializer);
let mut var_downloaded = <u32>::sse_decode(deserializer);
let mut var_deletedLocal = <u32>::sse_decode(deserializer);
let mut var_deletedRemote = <u32>::sse_decode(deserializer);
let mut var_conflicts = <u32>::sse_decode(deserializer);
let mut var_errors = <Vec<String>>::sse_decode(deserializer);
return crate::api::SyncResultDto {
uploaded: var_uploaded,
downloaded: var_downloaded,
deleted_local: var_deletedLocal,
deleted_remote: var_deletedRemote,
conflicts: var_conflicts,
errors: var_errors,
};
}
}
impl SseDecode for crate::api::TaskDto {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -837,6 +1062,7 @@ impl SseDecode for crate::api::TaskDto {
let mut var_description = <String>::sse_decode(deserializer);
let mut var_status = <String>::sse_decode(deserializer);
let mut var_dueDate = <Option<String>>::sse_decode(deserializer);
let mut var_hasTime = <bool>::sse_decode(deserializer);
let mut var_createdAt = <String>::sse_decode(deserializer);
let mut var_updatedAt = <String>::sse_decode(deserializer);
let mut var_parentId = <Option<String>>::sse_decode(deserializer);
@ -846,6 +1072,7 @@ impl SseDecode for crate::api::TaskDto {
description: var_description,
status: var_status,
due_date: var_dueDate,
has_time: var_hasTime,
created_at: var_createdAt,
updated_at: var_updatedAt,
parent_id: var_parentId,
@ -933,15 +1160,20 @@ fn pde_ffi_dispatcher_primary_impl(
9 => wire__crate__api__greet_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__init_workspace_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__list_tasks_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__move_task_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__remove_workspace_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__rename_list_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__reorder_task_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__set_current_workspace_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__set_group_by_due_date_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__toggle_task_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__watch_workspace_changes_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__load_credentials_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__move_task_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__remove_workspace_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__rename_list_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__reorder_task_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__set_current_workspace_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_group_by_due_date_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_webdav_config_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__store_credentials_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__sync_workspace_cmd_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__test_webdav_connection_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__toggle_task_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__watch_workspace_changes_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -977,6 +1209,26 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::AppConfigDto> for crate::api:
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::SyncResultDto {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.uploaded.into_into_dart().into_dart(),
self.downloaded.into_into_dart().into_dart(),
self.deleted_local.into_into_dart().into_dart(),
self.deleted_remote.into_into_dart().into_dart(),
self.conflicts.into_into_dart().into_dart(),
self.errors.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::SyncResultDto {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::SyncResultDto> for crate::api::SyncResultDto {
fn into_into_dart(self) -> crate::api::SyncResultDto {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::TaskDto {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -985,6 +1237,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::TaskDto {
self.description.into_into_dart().into_dart(),
self.status.into_into_dart().into_dart(),
self.due_date.into_into_dart().into_dart(),
self.has_time.into_into_dart().into_dart(),
self.created_at.into_into_dart().into_dart(),
self.updated_at.into_into_dart().into_dart(),
self.parent_id.into_into_dart().into_dart(),
@ -1072,6 +1325,16 @@ impl SseEncode for bool {
}
}
impl SseEncode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<String>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -1122,6 +1385,18 @@ impl SseEncode for Option<String> {
}
}
impl SseEncode for crate::api::SyncResultDto {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u32>::sse_encode(self.uploaded, serializer);
<u32>::sse_encode(self.downloaded, serializer);
<u32>::sse_encode(self.deleted_local, serializer);
<u32>::sse_encode(self.deleted_remote, serializer);
<u32>::sse_encode(self.conflicts, serializer);
<Vec<String>>::sse_encode(self.errors, serializer);
}
}
impl SseEncode for crate::api::TaskDto {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -1130,6 +1405,7 @@ impl SseEncode for crate::api::TaskDto {
<String>::sse_encode(self.description, serializer);
<String>::sse_encode(self.status, serializer);
<Option<String>>::sse_encode(self.due_date, serializer);
<bool>::sse_encode(self.has_time, serializer);
<String>::sse_encode(self.created_at, serializer);
<String>::sse_encode(self.updated_at, serializer);
<Option<String>>::sse_encode(self.parent_id, serializer);

View file

@ -0,0 +1,17 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}

View file

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View file

@ -0,0 +1,25 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever_windows
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View file

@ -2,6 +2,8 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
use chrono::Utc;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
@ -422,21 +424,40 @@ async fn test_webdav_connection(
#[tauri::command]
async fn sync_workspace(
workspace_name: String,
workspace_path: String,
webdav_url: String,
username: String,
password: String,
mode: String,
state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> {
let sync_mode = match mode.as_str() {
"push" => SyncMode::Push,
"pull" => SyncMode::Pull,
_ => SyncMode::Full,
};
let result = sync::sync_workspace(
&PathBuf::from(workspace_path),
&PathBuf::from(&workspace_path),
&webdav_url,
&username,
&password,
SyncMode::Full,
sync_mode,
None,
)
.await
.map_err(|e| e.to_string())?;
// Persist last_sync timestamp to config
{
let mut s = state.lock().unwrap();
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.last_sync = Some(Utc::now());
}
let config_path = AppConfig::get_config_path();
s.config.save_to_file(&config_path).map_err(|e| e.to_string())?;
}
Ok(result.into())
}

View file

@ -1,5 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui-org/nicegui/v2/tauri-conf-schema.json",
"productName": "Onyx",
"version": "0.1.0",
"identifier": "com.onyx.app",
@ -29,7 +28,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["appimage", "deb"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View file

@ -1,7 +1,8 @@
<script lang="ts">
let { value = null, onchange, onclose }: {
let { value = null, has_time = false, onchange, onclose }: {
value: string | null;
onchange: (iso: string | null) => void;
has_time: boolean;
onchange: (iso: string | null, has_time: boolean) => void;
onclose: () => void;
} = $props();
@ -12,7 +13,7 @@
let viewYear = $state(existing ? existing.getFullYear() : now.getFullYear());
let viewMonth = $state(existing ? existing.getMonth() : now.getMonth());
let selectedDay = $state(existing ? existing.getDate() : now.getDate());
let includeTime = $state(existing ? (existing.getHours() !== 0 || existing.getMinutes() !== 0) : false);
let includeTime = $state(has_time);
let selectedHour = $state(existing ? existing.getHours() : now.getHours());
let selectedMinute = $state(existing ? existing.getMinutes() : 0);
let visible = $state(false);
@ -66,12 +67,12 @@
const h = includeTime ? selectedHour : 0;
const m = includeTime ? selectedMinute : 0;
const iso = new Date(viewYear, viewMonth, selectedDay, h, m).toISOString();
onchange(iso);
onchange(iso, includeTime);
dismiss();
}
function clear() {
onchange(null);
onchange(null, false);
dismiss();
}
</script>

View file

@ -10,6 +10,7 @@
let title = $state("");
let description = $state("");
let dueDate = $state<string | null>(null);
let dueDateHasTime = $state(false);
let inputEl = $state<HTMLInputElement | null>(null);
let showDatePicker = $state(false);
@ -17,11 +18,12 @@
if (!title.trim()) return;
const created = await app.createTask(title.trim(), description.trim() || undefined);
if (dueDate && created) {
await app.updateTask({ ...created, due_date: dueDate, updated_at: new Date().toISOString() });
await app.updateTask({ ...created, due_date: dueDate, has_time: dueDateHasTime, updated_at: new Date().toISOString() });
}
title = "";
description = "";
dueDate = null;
dueDateHasTime = false;
newTaskState.open = false;
}
@ -30,11 +32,13 @@
title = "";
description = "";
dueDate = null;
dueDateHasTime = false;
showDatePicker = false;
}
function handleDateChange(iso: string | null) {
function handleDateChange(iso: string | null, hasTime: boolean = false) {
dueDate = iso;
dueDateHasTime = hasTime;
}
function formatDateChip(iso: string): string {
@ -43,8 +47,7 @@
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = dayNames[d.getDay()];
const pad = (n: number) => String(n).padStart(2, "0");
const hasTime = d.getHours() !== 0 || d.getMinutes() !== 0;
const timePart = hasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
const timePart = dueDateHasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
}
@ -137,6 +140,7 @@
{#if showDatePicker}
<DateTimePicker
value={dueDate}
has_time={dueDateHasTime}
onchange={handleDateChange}
onclose={() => (showDatePicker = false)}
/>

View file

@ -42,8 +42,8 @@
debouncedSave({ description });
}
function handleDateChange(iso: string | null) {
app.updateTask({ ...task, due_date: iso, updated_at: new Date().toISOString() });
function handleDateChange(iso: string | null, hasTime: boolean = false) {
app.updateTask({ ...task, due_date: iso, has_time: hasTime, updated_at: new Date().toISOString() });
}
async function handleToggle() {
@ -79,7 +79,7 @@
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = dayNames[d.getDay()];
const pad = (n: number) => String(n).padStart(2, "0");
const hasTime = d.getHours() !== 0 || d.getMinutes() !== 0;
const hasTime = task.has_time;
const timePart = hasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
@ -223,6 +223,7 @@
{#if showDatePicker}
<DateTimePicker
value={task.due_date}
has_time={task.has_time}
onchange={handleDateChange}
onclose={() => (showDatePicker = false)}
/>

View file

@ -122,13 +122,35 @@
</div>
{#if app.config?.current_workspace}
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="mt-3 w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{app.syncing ? "Syncing…" : "Sync Now"}
</button>
<div class="mt-3 flex items-center gap-2">
<select
value={app.syncMode}
onchange={(e) => app.setSyncMode((e.target as HTMLSelectElement).value as "full" | "push" | "pull")}
class="appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
>
<option value="full">Sync both ways</option>
<option value="push">Push only</option>
<option value="pull">Pull only</option>
</select>
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{app.syncing ? "Syncing…" : "Sync Now"}
</button>
</div>
{#if app.config.workspaces[app.config.current_workspace]?.last_sync}
{@const lastSync = new Date(app.config.workspaces[app.config.current_workspace].last_sync!)}
{@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)}
{@const relTime = secsAgo < 60 ? "just now" : secsAgo < 3600 ? `${Math.floor(secsAgo / 60)}m ago` : `${Math.floor(secsAgo / 3600)}h ago`}
<p class="mt-1.5 text-xs opacity-40">
Last sync: {relTime}
{#if app.lastSyncResult}
&nbsp;·&nbsp;{app.lastSyncResult.uploaded}{app.lastSyncResult.downloaded}
{/if}
</p>
{/if}
{/if}
</section>

View file

@ -550,9 +550,14 @@
</div>
</div>
<!-- Sync spinner -->
<!-- Sync status indicator -->
{#if app.syncing}
<div class="absolute bottom-4 right-4 z-20 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{:else if app.lastSyncResult}
<div class="absolute bottom-4 right-4 z-20 flex items-center gap-1 rounded-full bg-black/10 px-2.5 py-1 text-xs opacity-60 dark:bg-white/10">
<span>{app.lastSyncResult.uploaded}</span>
<span>{app.lastSyncResult.downloaded}</span>
</div>
{/if}
</div>
</div>

View file

@ -24,6 +24,8 @@ let darkMode = $state(
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
);
let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full");
let lastSyncResult = $state<SyncResult | null>(null);
let error = $state<string | null>(null);
// ── Derived ──────────────────────────────────────────────────────────
@ -254,7 +256,8 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
async function triggerSync() {
if (!config?.current_workspace) return;
const ws = config.workspaces[config.current_workspace];
const workspaceName = config.current_workspace;
const ws = config.workspaces[workspaceName];
if (!ws?.webdav_url) {
error = "No WebDAV URL configured";
return;
@ -265,14 +268,19 @@ async function triggerSync() {
const domain = new URL(ws.webdav_url).hostname;
const [username, password] = await invoke<[string, string]>("load_credentials", { domain });
const result = await invoke<SyncResult>("sync_workspace", {
workspaceName,
workspacePath: ws.path,
webdavUrl: ws.webdav_url,
username,
password,
mode: syncMode,
});
lastSyncResult = result;
if (result.errors.length > 0) {
error = result.errors.join("; ");
}
// Reload config to pick up updated last_sync timestamp
config = await invoke<AppConfig>("get_config");
await loadLists();
} catch (e) {
error = String(e);
@ -281,6 +289,10 @@ async function triggerSync() {
}
}
function setSyncMode(mode: "full" | "push" | "pull") {
syncMode = mode;
}
function toggleDarkMode() {
darkMode = !darkMode;
}
@ -326,6 +338,12 @@ export const app = {
get syncing() {
return syncing;
},
get syncMode() {
return syncMode;
},
get lastSyncResult() {
return lastSyncResult;
},
get error() {
return error;
},
@ -350,6 +368,7 @@ export const app = {
renameList,
setGroupByDueDate,
triggerSync,
setSyncMode,
toggleDarkMode,
setScreen,
clearError,

View file

@ -4,6 +4,7 @@ export interface Task {
description: string;
status: "backlog" | "completed";
due_date: string | null;
has_time: boolean;
created_at: string;
updated_at: string;
parent_id: string | null;

View file

@ -17,6 +17,8 @@ pub struct Task {
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_date: Option<DateTime<Utc>>,
#[serde(default)]
pub has_time: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -32,6 +34,7 @@ impl Task {
description: String::new(),
status: TaskStatus::Backlog,
due_date: None,
has_time: false,
created_at: now,
updated_at: now,
parent_id: None,

View file

@ -56,6 +56,8 @@ pub struct TaskFrontmatter {
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<DateTime<Utc>>,
#[serde(default)]
pub has_time: bool,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -68,6 +70,7 @@ impl From<&Task> for TaskFrontmatter {
id: task.id,
status: task.status,
due: task.due_date,
has_time: task.has_time,
created: task.created_at,
updated: task.updated_at,
parent: task.parent_id,
@ -256,6 +259,7 @@ impl Storage for FileSystemStorage {
description,
status: frontmatter.status,
due_date: frontmatter.due,
has_time: frontmatter.has_time,
created_at: frontmatter.created,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent,
@ -344,6 +348,7 @@ impl Storage for FileSystemStorage {
description,
status: frontmatter.status,
due_date: frontmatter.due,
has_time: frontmatter.has_time,
created_at: frontmatter.created,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent,