Skip to content

Commit a34349c

Browse files
Attachment package refactor (#311)
1 parent a2b8d76 commit a34349c

29 files changed

+2737
-796
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pubspec_overrides.yaml
99
.flutter-plugins-dependencies
1010
.flutter-plugins
1111
build
12+
**/doc/api
1213
.build
1314

1415
# Shared assets
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import 'package:path_provider/path_provider.dart';
2+
import 'package:powersync_core/attachments/attachments.dart';
3+
import 'package:powersync_core/attachments/io.dart';
4+
5+
Future<LocalStorage> localAttachmentStorage() async {
6+
final appDocDir = await getApplicationDocumentsDirectory();
7+
return IOLocalStorage(appDocDir);
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import 'package:powersync_core/attachments/attachments.dart';
2+
3+
Future<LocalStorage> localAttachmentStorage() async {
4+
// This file is imported on the web, where we don't currently have a
5+
// persistent local storage implementation.
6+
return LocalStorage.inMemory();
7+
}

demos/supabase-todolist/lib/attachments/photo_capture_widget.dart

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import 'dart:async';
2-
2+
import 'dart:io';
33
import 'package:camera/camera.dart';
44
import 'package:flutter/material.dart';
5-
import 'package:powersync/powersync.dart' as powersync;
5+
import 'package:logging/logging.dart';
66
import 'package:powersync_flutter_demo/attachments/queue.dart';
7-
import 'package:powersync_flutter_demo/models/todo_item.dart';
8-
import 'package:powersync_flutter_demo/powersync.dart';
97

108
class TakePhotoWidget extends StatefulWidget {
119
final String todoId;
@@ -23,6 +21,7 @@ class TakePhotoWidget extends StatefulWidget {
2321
class _TakePhotoWidgetState extends State<TakePhotoWidget> {
2422
late CameraController _cameraController;
2523
late Future<void> _initializeControllerFuture;
24+
final log = Logger('TakePhotoWidget');
2625

2726
@override
2827
void initState() {
@@ -37,33 +36,33 @@ class _TakePhotoWidgetState extends State<TakePhotoWidget> {
3736
}
3837

3938
@override
40-
// Dispose of the camera controller when the widget is disposed
4139
void dispose() {
4240
_cameraController.dispose();
4341
super.dispose();
4442
}
4543

4644
Future<void> _takePhoto(context) async {
4745
try {
48-
// Ensure the camera is initialized before taking a photo
46+
log.info('Taking photo for todo: ${widget.todoId}');
4947
await _initializeControllerFuture;
50-
5148
final XFile photo = await _cameraController.takePicture();
52-
// copy photo to new directory with ID as name
53-
String photoId = powersync.uuid.v4();
54-
String storageDirectory = await attachmentQueue.getStorageDirectory();
55-
await attachmentQueue.localStorage
56-
.copyFile(photo.path, '$storageDirectory/$photoId.jpg');
5749

58-
int photoSize = await photo.length();
50+
// Read the photo data as bytes
51+
final photoFile = File(photo.path);
52+
if (!await photoFile.exists()) {
53+
log.warning('Photo file does not exist: ${photo.path}');
54+
return;
55+
}
56+
57+
final photoData = photoFile.openRead();
5958

60-
TodoItem.addPhoto(photoId, widget.todoId);
61-
attachmentQueue.saveFile(photoId, photoSize);
59+
// Save the photo attachment with the byte data
60+
final attachment = await savePhotoAttachment(photoData, widget.todoId);
61+
62+
log.info('Photo attachment saved with ID: ${attachment.id}');
6263
} catch (e) {
63-
log.info('Error taking photo: $e');
64+
log.severe('Error taking photo: $e');
6465
}
65-
66-
// After taking the photo, navigate back to the previous screen
6766
Navigator.pop(context);
6867
}
6968

demos/supabase-todolist/lib/attachments/photo_widget.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import 'dart:io';
22

3+
import 'package:path_provider/path_provider.dart';
4+
import 'package:path/path.dart' as p;
35
import 'package:flutter/material.dart';
4-
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
6+
import 'package:powersync_core/attachments/attachments.dart';
57
import 'package:powersync_flutter_demo/attachments/camera_helpers.dart';
68
import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart';
7-
import 'package:powersync_flutter_demo/attachments/queue.dart';
89

910
import '../models/todo_item.dart';
11+
import '../powersync.dart';
1012

1113
class PhotoWidget extends StatefulWidget {
1214
final TodoItem todo;
@@ -37,11 +39,12 @@ class _PhotoWidgetState extends State<PhotoWidget> {
3739
if (photoId == null) {
3840
return _ResolvedPhotoState(photoPath: null, fileExists: false);
3941
}
40-
photoPath = await attachmentQueue.getLocalUri('$photoId.jpg');
42+
final appDocDir = await getApplicationDocumentsDirectory();
43+
photoPath = p.join(appDocDir.path, '$photoId.jpg');
4144

4245
bool fileExists = await File(photoPath).exists();
4346

44-
final row = await attachmentQueue.db
47+
final row = await db
4548
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);
4649

4750
if (row != null) {
@@ -98,7 +101,7 @@ class _PhotoWidgetState extends State<PhotoWidget> {
98101
String? filePath = data.photoPath;
99102
bool fileIsDownloading = !data.fileExists;
100103
bool fileArchived =
101-
data.attachment?.state == AttachmentState.archived.index;
104+
data.attachment?.state == AttachmentState.archived;
102105

103106
if (fileArchived) {
104107
return Column(
Lines changed: 51 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,64 @@
11
import 'dart:async';
22

3+
import 'package:logging/logging.dart';
34
import 'package:powersync/powersync.dart';
4-
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
5-
import 'package:powersync_flutter_demo/app_config.dart';
5+
import 'package:powersync_core/attachments/attachments.dart';
6+
67
import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';
78

8-
import 'package:powersync_flutter_demo/models/schema.dart';
9+
import 'local_storage_unsupported.dart'
10+
if (dart.library.io) 'local_storage_native.dart';
911

10-
/// Global reference to the queue
11-
late final PhotoAttachmentQueue attachmentQueue;
12+
late AttachmentQueue attachmentQueue;
1213
final remoteStorage = SupabaseStorageAdapter();
14+
final logger = Logger('AttachmentQueue');
1315

14-
/// Function to handle errors when downloading attachments
15-
/// Return false if you want to archive the attachment
16-
Future<bool> onDownloadError(Attachment attachment, Object exception) async {
17-
if (exception.toString().contains('Object not found')) {
18-
return false;
19-
}
20-
return true;
21-
}
22-
23-
class PhotoAttachmentQueue extends AbstractAttachmentQueue {
24-
PhotoAttachmentQueue(db, remoteStorage)
25-
: super(
26-
db: db,
27-
remoteStorage: remoteStorage,
28-
onDownloadError: onDownloadError);
29-
30-
@override
31-
init() async {
32-
if (AppConfig.supabaseStorageBucket.isEmpty) {
33-
log.info(
34-
'No Supabase bucket configured, skip setting up PhotoAttachmentQueue watches');
35-
return;
36-
}
37-
38-
await super.init();
39-
}
40-
41-
@override
42-
Future<Attachment> saveFile(String fileId, int size,
43-
{mediaType = 'image/jpeg'}) async {
44-
String filename = '$fileId.jpg';
16+
Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
17+
attachmentQueue = AttachmentQueue(
18+
db: db,
19+
remoteStorage: remoteStorage,
20+
logger: logger,
21+
localStorage: await localAttachmentStorage(),
22+
watchAttachments: () => db.watch('''
23+
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
24+
''').map(
25+
(results) => [
26+
for (final row in results)
27+
WatchedAttachmentItem(
28+
id: row['id'] as String,
29+
fileExtension: 'jpg',
30+
)
31+
],
32+
),
33+
);
4534

46-
Attachment photoAttachment = Attachment(
47-
id: fileId,
48-
filename: filename,
49-
state: AttachmentState.queuedUpload.index,
50-
mediaType: mediaType,
51-
localUri: getLocalFilePathSuffix(filename),
52-
size: size,
53-
);
54-
55-
return attachmentsService.saveAttachment(photoAttachment);
56-
}
57-
58-
@override
59-
Future<Attachment> deleteFile(String fileId) async {
60-
String filename = '$fileId.jpg';
61-
62-
Attachment photoAttachment = Attachment(
63-
id: fileId,
64-
filename: filename,
65-
state: AttachmentState.queuedDelete.index);
66-
67-
return attachmentsService.saveAttachment(photoAttachment);
68-
}
35+
await attachmentQueue.startSync();
36+
}
6937

70-
@override
71-
StreamSubscription<void> watchIds({String fileExtension = 'jpg'}) {
72-
log.info('Watching photos in $todosTable...');
73-
return db.watch('''
74-
SELECT photo_id FROM $todosTable
75-
WHERE photo_id IS NOT NULL
76-
''').map((results) {
77-
return results.map((row) => row['photo_id'] as String).toList();
78-
}).listen((ids) async {
79-
List<String> idsInQueue = await attachmentsService.getAttachmentIds();
80-
List<String> relevantIds =
81-
ids.where((element) => !idsInQueue.contains(element)).toList();
82-
syncingService.processIds(relevantIds, fileExtension);
83-
});
84-
}
38+
Future<Attachment> savePhotoAttachment(
39+
Stream<List<int>> photoData, String todoId,
40+
{String mediaType = 'image/jpeg'}) async {
41+
// Save the file using the AttachmentQueue API
42+
return await attachmentQueue.saveFile(
43+
data: photoData,
44+
mediaType: mediaType,
45+
fileExtension: 'jpg',
46+
metaData: 'Photo attachment for todo: $todoId',
47+
updateHook: (context, attachment) async {
48+
// Update the todo item to reference this attachment
49+
await context.execute(
50+
'UPDATE todos SET photo_id = ? WHERE id = ?',
51+
[attachment.id, todoId],
52+
);
53+
},
54+
);
8555
}
8656

87-
initializeAttachmentQueue(PowerSyncDatabase db) async {
88-
attachmentQueue = PhotoAttachmentQueue(db, remoteStorage);
89-
await attachmentQueue.init();
57+
Future<Attachment> deletePhotoAttachment(String fileId) async {
58+
return await attachmentQueue.deleteFile(
59+
attachmentId: fileId,
60+
updateHook: (context, attachment) async {
61+
// Optionally update relationships in the same transaction
62+
},
63+
);
9064
}

demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,96 @@
11
import 'dart:io';
22
import 'dart:typed_data';
3-
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
3+
4+
import 'package:powersync_core/attachments/attachments.dart';
45
import 'package:powersync_flutter_demo/app_config.dart';
56
import 'package:supabase_flutter/supabase_flutter.dart';
6-
import 'package:image/image.dart' as img;
7+
import 'package:logging/logging.dart';
8+
9+
class SupabaseStorageAdapter implements RemoteStorage {
10+
static final _log = Logger('SupabaseStorageAdapter');
711

8-
class SupabaseStorageAdapter implements AbstractRemoteStorageAdapter {
912
@override
10-
Future<void> uploadFile(String filename, File file,
11-
{String mediaType = 'text/plain'}) async {
13+
Future<void> uploadFile(
14+
Stream<List<int>> fileData, Attachment attachment) async {
1215
_checkSupabaseBucketIsConfigured();
1316

17+
// Check if attachment size is specified (required for buffer allocation)
18+
final byteSize = attachment.size;
19+
if (byteSize == null) {
20+
throw Exception('Cannot upload a file with no byte size specified');
21+
}
22+
23+
_log.info('uploadFile: ${attachment.filename} (size: $byteSize bytes)');
24+
25+
// Collect all stream data into a single Uint8List buffer
26+
final buffer = Uint8List(byteSize);
27+
var position = 0;
28+
29+
await for (final chunk in fileData) {
30+
if (position + chunk.length > byteSize) {
31+
throw Exception('File data exceeds specified size');
32+
}
33+
buffer.setRange(position, position + chunk.length, chunk);
34+
position += chunk.length;
35+
}
36+
37+
if (position != byteSize) {
38+
throw Exception(
39+
'File data size ($position) does not match specified size ($byteSize)');
40+
}
41+
42+
// Create a temporary file from the buffer for upload
43+
final tempFile =
44+
File('${Directory.systemTemp.path}/${attachment.filename}');
1445
try {
46+
await tempFile.writeAsBytes(buffer);
47+
1548
await Supabase.instance.client.storage
1649
.from(AppConfig.supabaseStorageBucket)
17-
.upload(filename, file,
18-
fileOptions: FileOptions(contentType: mediaType));
50+
.upload(attachment.filename, tempFile,
51+
fileOptions: FileOptions(
52+
contentType:
53+
attachment.mediaType ?? 'application/octet-stream'));
54+
55+
_log.info('Successfully uploaded ${attachment.filename}');
1956
} catch (error) {
57+
_log.severe('Error uploading ${attachment.filename}', error);
2058
throw Exception(error);
59+
} finally {
60+
if (await tempFile.exists()) {
61+
await tempFile.delete();
62+
}
2163
}
2264
}
2365

2466
@override
25-
Future<Uint8List> downloadFile(String filePath) async {
67+
Future<Stream<List<int>>> downloadFile(Attachment attachment) async {
2668
_checkSupabaseBucketIsConfigured();
2769
try {
70+
_log.info('downloadFile: ${attachment.filename}');
71+
2872
Uint8List fileBlob = await Supabase.instance.client.storage
2973
.from(AppConfig.supabaseStorageBucket)
30-
.download(filePath);
31-
final image = img.decodeImage(fileBlob);
32-
Uint8List blob = img.JpegEncoder().encode(image!);
33-
return blob;
74+
.download(attachment.filename);
75+
76+
_log.info(
77+
'Successfully downloaded ${attachment.filename} (${fileBlob.length} bytes)');
78+
79+
// Return the raw file data as a stream
80+
return Stream.value(fileBlob);
3481
} catch (error) {
82+
_log.severe('Error downloading ${attachment.filename}', error);
3583
throw Exception(error);
3684
}
3785
}
3886

3987
@override
40-
Future<void> deleteFile(String filename) async {
88+
Future<void> deleteFile(Attachment attachment) async {
4189
_checkSupabaseBucketIsConfigured();
42-
4390
try {
4491
await Supabase.instance.client.storage
4592
.from(AppConfig.supabaseStorageBucket)
46-
.remove([filename]);
93+
.remove([attachment.filename]);
4794
} catch (error) {
4895
throw Exception(error);
4996
}

0 commit comments

Comments
 (0)