Skip to content

Commit e7353c5

Browse files
authored
Add attachments to MAUITodo demo (#74)
1 parent ca9bafe commit e7353c5

11 files changed

Lines changed: 280 additions & 35 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using PowerSync.Common.Attachments;
2+
3+
namespace MAUITodo.Attachments;
4+
5+
public class NodeRemoteStorageAdapter : IRemoteStorageAdapter
6+
{
7+
private readonly HttpClient _client;
8+
private readonly string _backendUrl;
9+
10+
public NodeRemoteStorageAdapter(HttpClient client, string backendUrl)
11+
{
12+
_client = client;
13+
_backendUrl = backendUrl;
14+
}
15+
16+
public async Task UploadFileAsync(Stream fileData, Attachment attachment)
17+
{
18+
var content = new StreamContent(fileData);
19+
content.Headers.ContentType = new(attachment.MediaType ?? "application/octet-stream");
20+
var response = await _client.PutAsync($"{_backendUrl}/api/attachments/{attachment.Id}", content);
21+
response.EnsureSuccessStatusCode();
22+
}
23+
24+
public async Task<Stream> DownloadFileAsync(Attachment attachment)
25+
{
26+
var response = await _client.GetAsync($"{_backendUrl}/api/attachments/{attachment.Id}");
27+
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
28+
return Stream.Null;
29+
response.EnsureSuccessStatusCode();
30+
return await response.Content.ReadAsStreamAsync();
31+
}
32+
33+
public async Task DeleteFileAsync(Attachment attachment)
34+
{
35+
var response = await _client.DeleteAsync($"{_backendUrl}/api/attachments/{attachment.Id}");
36+
if (response.StatusCode != System.Net.HttpStatusCode.NotFound)
37+
response.EnsureSuccessStatusCode();
38+
}
39+
}

demos/MAUITodo/Data/AppSchema.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using MAUITodo.Models;
22

3+
using PowerSync.Common.Attachments;
34
using PowerSync.Common.DB.Schema;
45

56
class AppSchema
67
{
78
public static Table Todos = new Table(typeof(TodoItem));
89
public static Table Lists = new Table(typeof(TodoList));
10+
public static Table Attachments = new Table(typeof(Attachment));
911

10-
public static Schema PowerSyncSchema = new Schema(Todos, Lists);
12+
public static Schema PowerSyncSchema = new Schema(Todos, Lists, Attachments);
1113
}

demos/MAUITodo/Data/PowerSyncData.cs

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
using MAUITodo.Models;
1+
using System.Runtime.CompilerServices;
2+
3+
using MAUITodo.Attachments;
4+
using MAUITodo.Models;
25

36
using Microsoft.Extensions.Logging;
47

8+
using PowerSync.Common.Attachments;
59
using PowerSync.Common.Client;
610
using PowerSync.Common.MDSQLite;
711
using PowerSync.Maui.SQLite;
@@ -11,6 +15,7 @@ namespace MAUITodo.Data;
1115
public class PowerSyncData
1216
{
1317
public PowerSyncDatabase Db;
18+
public AttachmentQueue PhotoAttachmentQueue { get; }
1419
private string UserId { get; }
1520

1621
public PowerSyncData()
@@ -39,8 +44,38 @@ public PowerSyncData()
3944
UserId = nodeConnector.UserId;
4045

4146
Db.Connect(nodeConnector);
47+
48+
var attachmentsDir = Path.Combine(FileSystem.AppDataDirectory, "attachments");
49+
var localStorage = new FileManagerLocalStorage(attachmentsDir);
50+
var remoteStorage = new NodeRemoteStorageAdapter(new HttpClient(), nodeConnector.BackendUrl);
51+
52+
PhotoAttachmentQueue = new AttachmentQueue(new AttachmentQueueOptions
53+
{
54+
Db = Db,
55+
LocalStorage = localStorage,
56+
RemoteStorage = remoteStorage,
57+
WatchAttachments = ct => WatchTodoPhotos(Db, ct),
58+
});
59+
60+
_ = Task.Run(() => PhotoAttachmentQueue.StartSyncAsync());
61+
}
62+
63+
private static async IAsyncEnumerable<WatchedAttachmentItem[]> WatchTodoPhotos(
64+
PowerSyncDatabase db,
65+
[EnumeratorCancellation] CancellationToken ct = default)
66+
{
67+
var stream = db.Watch<PhotoIdResult>(
68+
"SELECT photo_id FROM todos WHERE photo_id IS NOT NULL",
69+
[],
70+
new SQLWatchOptions { TriggerImmediately = true, Signal = ct });
71+
await foreach (var rows in stream.WithCancellation(ct))
72+
{
73+
yield return [.. rows.Select(r => new WatchedAttachmentItem(r.photo_id, fileExtension: "jpg"))];
74+
}
4275
}
4376

77+
private record PhotoIdResult(string photo_id);
78+
4479
public async Task SaveListAsync(TodoList list)
4580
{
4681
if (list.ID != "")
@@ -59,32 +94,45 @@ await Db.Execute(
5994

6095
public async Task DeleteListAsync(TodoList list)
6196
{
62-
var listId = list.ID;
63-
// First delete all todo items in this list
64-
await Db.Execute("DELETE FROM todos WHERE list_id = ?", [listId]);
65-
await Db.Execute("DELETE FROM lists WHERE id = ?", [listId]);
97+
await Db.WriteTransaction(async tx =>
98+
{
99+
// Prevent attachments from being orphaned when their owning todo is deleted;
100+
// `has_synced = 0` prevents the attachments from becoming archived instead of deleted.
101+
//
102+
// It would be nice to be able to do this sort of bulk-delete using the attachments API directly.
103+
await tx.Execute(
104+
@"UPDATE attachments
105+
SET state = ?, has_synced = 0
106+
WHERE id IN (SELECT photo_id FROM todos WHERE list_id = ? AND photo_id IS NOT NULL)",
107+
[(int)AttachmentState.QueuedDelete, list.ID]);
108+
await tx.Execute("DELETE FROM todos WHERE list_id = ?", [list.ID]);
109+
await tx.Execute("DELETE FROM lists WHERE id = ?", [list.ID]);
110+
});
111+
112+
// Force the attachments queue to acknowledge the changes immediately
113+
PhotoAttachmentQueue.TriggerSync();
66114
}
67115

68116
public async Task SaveItemAsync(TodoItem item)
69117
{
70118
if (item.ID != "")
71119
{
72120
await Db.Execute(
73-
@"UPDATE todos
121+
@"UPDATE todos
74122
SET description = ?, completed = ?, completed_at = ?, completed_by = ?
75123
WHERE id = ?",
76124
[
77125
item.Description,
78-
item.Completed ? 1 : 0,
79-
item.CompletedAt!,
126+
item.Completed,
127+
item.CompletedAt,
80128
item.Completed ? UserId : null,
81129
item.ID
82130
]);
83131
}
84132
else
85133
{
86134
await Db.Execute(
87-
@"INSERT INTO todos
135+
@"INSERT INTO todos
88136
(id, list_id, description, created_at, created_by, completed, completed_at, completed_by)
89137
VALUES (uuid(), ?, ?, datetime(), ?, ?, ?, ?)",
90138
[
@@ -98,12 +146,30 @@ await Db.Execute(
98146
}
99147
}
100148

149+
public async Task SaveTodoPhotoAsync(string todoId, Stream photoData)
150+
{
151+
await PhotoAttachmentQueue.SaveFileAsync(
152+
data: photoData,
153+
fileExtension: "jpg",
154+
mediaType: "image/jpeg",
155+
updateHook: (tx, attachment) =>
156+
tx.Execute("UPDATE todos SET photo_id = ? WHERE id = ?", [attachment.Id, todoId]));
157+
}
158+
159+
public async Task RemoveTodoPhotoAsync(string todoId, string photoId)
160+
{
161+
await PhotoAttachmentQueue.DeleteFileAsync(
162+
id: photoId,
163+
updateHook: (tx, _) =>
164+
tx.Execute("UPDATE todos SET photo_id = NULL WHERE id = ?", [todoId]));
165+
}
166+
101167
public async Task SaveTodoCompletedAsync(string todoId, bool completed)
102168
{
103169
if (completed)
104170
{
105171
await Db.Execute(
106-
@"UPDATE todos
172+
@"UPDATE todos
107173
SET completed = 1, completed_at = datetime(), completed_by = ?
108174
WHERE id = ?",
109175
[
@@ -114,7 +180,7 @@ await Db.Execute(
114180
else
115181
{
116182
await Db.Execute(
117-
@"UPDATE todos
183+
@"UPDATE todos
118184
SET completed = 0, completed_at = NULL, completed_by = NULL
119185
WHERE id = ?",
120186
[
@@ -125,6 +191,26 @@ await Db.Execute(
125191

126192
public async Task DeleteItemAsync(TodoItem item)
127193
{
128-
await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]);
194+
if (item.PhotoId != null)
195+
{
196+
// DeleteFileAsync can fail if a `todos` row is synced with a photo_id, but the
197+
// corresponding `attachments` row/item hasn't been created yet. Fallback to regular
198+
// `DELETE FROM todos`.
199+
try
200+
{
201+
await PhotoAttachmentQueue.DeleteFileAsync(
202+
id: item.PhotoId,
203+
updateHook: (tx, _) =>
204+
tx.Execute("DELETE FROM todos WHERE id = ?", [item.ID]));
205+
}
206+
catch
207+
{
208+
await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]);
209+
}
210+
}
211+
else
212+
{
213+
await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]);
214+
}
129215
}
130216
}

demos/MAUITodo/Models/TodoItem.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
using PowerSync.Common.DB.Schema.Attributes;
44

55
[
6-
Table("todos"),
6+
Table("todos", IgnoreEmptyUpdates = true),
77
Index("list", ["list_id"])
88
]
99
public class TodoItem
@@ -31,4 +31,23 @@ public class TodoItem
3131

3232
[Column("completed")]
3333
public bool Completed { get; set; }
34+
35+
[Column("photo_id")]
36+
public string? PhotoId { get; set; }
37+
38+
// Display-only properties. [Ignored] keeps them out of the PowerSync schema (they are not
39+
// real columns on the `todos` table), while [Column] still lets Dapper hydrate PhotoLocalUri
40+
// from the `photo_local_uri` alias produced by the attachments LEFT JOIN in the watch query.
41+
[Ignored]
42+
[Column("photo_local_uri")]
43+
public string? PhotoLocalUri { get; set; }
44+
45+
[Ignored]
46+
public bool HasNoPhoto => PhotoId == null;
47+
48+
[Ignored]
49+
public bool IsDownloading => PhotoId != null && PhotoLocalUri == null;
50+
51+
[Ignored]
52+
public bool IsPhotoReady => PhotoLocalUri != null;
3453
}

demos/MAUITodo/Models/TodoList.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ namespace MAUITodo.Models;
22

33
using PowerSync.Common.DB.Schema.Attributes;
44

5-
[Table("lists")]
5+
[Table("lists", IgnoreEmptyUpdates = true)]
66
public class TodoList
77
{
88
[Column("id")]

demos/MAUITodo/Platforms/Android/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,12 @@
33
<application android:usesCleartextTraffic="true" android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
44
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
55
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
7+
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
8+
<uses-permission android:name="android.permission.CAMERA" />
9+
<queries>
10+
<intent>
11+
<action android:name="android.media.action.IMAGE_CAPTURE" />
12+
</intent>
13+
</queries>
614
</manifest>

demos/MAUITodo/Platforms/MacCatalyst/Info.plist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,9 @@
2828
<string>Assets.xcassets/appicon.appiconset</string>
2929
<key>UIUserInterfaceStyle</key>
3030
<string>Light</string>
31+
<key>NSPhotoLibraryUsageDescription</key>
32+
<string>Used to attach photos to todo items.</string>
33+
<key>NSCameraUsageDescription</key>
34+
<string>Used to take photos to attach to todo items.</string>
3135
</dict>
3236
</plist>

demos/MAUITodo/Platforms/iOS/Info.plist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,9 @@
2828
</array>
2929
<key>XSAppIconAssets</key>
3030
<string>Assets.xcassets/appicon.appiconset</string>
31+
<key>NSPhotoLibraryUsageDescription</key>
32+
<string>Used to attach photos to todo items.</string>
33+
<key>NSCameraUsageDescription</key>
34+
<string>Used to take photos to attach to todo items.</string>
3135
</dict>
3236
</plist>

demos/MAUITodo/Views/ListsPage.xaml.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ namespace MAUITodo.Views;
88
public partial class ListsPage
99
{
1010
private readonly PowerSyncData database;
11+
private CancellationTokenSource? _watchCts;
1112

1213
public ListsPage(PowerSyncData powerSyncData)
1314
{
@@ -16,29 +17,39 @@ public ListsPage(PowerSyncData powerSyncData)
1617
WifiStatusItem.IconImageSource = "wifi_off.png";
1718
}
1819

19-
protected override async void OnAppearing()
20+
protected override void OnAppearing()
2021
{
2122
base.OnAppearing();
2223

24+
_watchCts?.Cancel();
25+
_watchCts = new CancellationTokenSource();
26+
var ct = _watchCts.Token;
27+
2328
_ = Task.Run(async () =>
2429
{
25-
await foreach (var update in database.Db.Events.OnStatusChanged.ListenAsync(new CancellationToken()))
30+
await foreach (var update in database.Db.Events.OnStatusChanged.ListenAsync(ct))
2631
{
2732
MainThread.BeginInvokeOnMainThread(() =>
2833
{
2934
WifiStatusItem.IconImageSource = update.Status.Connected ? "wifi.png" : "wifi_off.png";
3035
});
3136
}
32-
});
37+
}, ct);
3338

34-
var listener = database.Db.Watch<TodoList>("select * from lists", null, new() { TriggerImmediately = true });
39+
var listener = database.Db.Watch<TodoList>("select * from lists", null, new() { TriggerImmediately = true, Signal = ct });
3540
_ = Task.Run(async () =>
3641
{
3742
await foreach (var results in listener)
3843
{
3944
MainThread.BeginInvokeOnMainThread(() => { ListsCollection.ItemsSource = results.ToList(); });
4045
}
41-
});
46+
}, ct);
47+
}
48+
49+
protected override void OnDisappearing()
50+
{
51+
base.OnDisappearing();
52+
_watchCts?.Cancel();
4253
}
4354

4455
private async void OnAddClicked(object sender, EventArgs e)

0 commit comments

Comments
 (0)