Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
37f9017
initial boilerplate of new attachments package
dean-journeyapps Jul 17, 2025
dc9952c
Update dependencies and enhance LocalStorage interface with metadata …
dean-journeyapps Jul 22, 2025
8846e72
init attachments package stream and demo with implementation
dean-journeyapps Jul 29, 2025
d1fc46d
Merge branch 'attachment-package-refactor' of github.com:powersync-ja…
dean-journeyapps Aug 5, 2025
8848f70
Refactor attachment handling and logging in the powersync_attachments…
dean-journeyapps Aug 11, 2025
c4a8a77
Added comprehensive comments and descriptions for key classes and met…
dean-journeyapps Aug 11, 2025
d76bcc2
Removed supabase-todolist-new-attachment demo and added the new atatc…
dean-journeyapps Aug 11, 2025
e747c76
Refactor attachment queue initialization and implemented default base…
dean-journeyapps Aug 11, 2025
be8d4af
Removed redundant logger names.
dean-journeyapps Aug 11, 2025
1d2c32e
Renamed SyncErrorHandler to AbstractSyncErrorHandler. Updated related…
dean-journeyapps Aug 11, 2025
4d08952
Refactor local storage implementation in powersync_attachments_stream…
dean-journeyapps Aug 12, 2025
934eb9c
Refactor logger variable name in attachment queue initialization for …
dean-journeyapps Aug 12, 2025
320fab4
Removed unecessary comment
dean-journeyapps Aug 12, 2025
525700c
Added attachment queue service as an export in common.dart
dean-journeyapps Aug 13, 2025
9a69c89
Refactor photo attachment handling in the Supabase Todo List demo. Up…
dean-journeyapps Aug 13, 2025
5ca3917
Updated documentation in SyncingService to streamline error handling …
dean-journeyapps Aug 13, 2025
9ec641e
Remoed dynamic variables, implemented context.deleteAttachment() and …
dean-journeyapps Aug 14, 2025
f788773
Formatting fixes
dean-journeyapps Aug 14, 2025
bd26d9e
Moved attachments stream implementation to powersync_core and marked …
dean-journeyapps Aug 18, 2025
248d748
Reduce public interface
simolus3 Aug 19, 2025
a3ea19d
Rename library
simolus3 Aug 19, 2025
d7daefc
Fix local storage tests
simolus3 Aug 19, 2025
57a4214
Unit tests for attachments
simolus3 Aug 20, 2025
aceecbf
Update demo
simolus3 Aug 21, 2025
c0e5584
Remove unused mime type in readFile
simolus3 Aug 21, 2025
7ea7b91
Restore web support
simolus3 Aug 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pubspec_overrides.yaml
.flutter-plugins-dependencies
.flutter-plugins
build
**/doc/api


# Shared assets
assets/*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.powersync.powersync_flutter_demo

import io.flutter.embedding.android.FlutterActivity

class MainActivity : FlutterActivity()
2 changes: 1 addition & 1 deletion demos/supabase-todolist/ios/Flutter/AppFrameworkInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>
2 changes: 1 addition & 1 deletion demos/supabase-todolist/ios/Podfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '12.0'
platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down
33 changes: 18 additions & 15 deletions demos/supabase-todolist/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- app_links (0.0.2):
- app_links (6.4.1):
- Flutter
- camera_avfoundation (0.0.1):
- Flutter
Expand All @@ -14,28 +14,31 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (3.49.2):
- sqlite3/common (= 3.49.2)
- sqlite3/common (3.49.2)
- sqlite3/dbstatvtab (3.49.2):
- sqlite3 (3.50.4):
- sqlite3/common (= 3.50.4)
- sqlite3/common (3.50.4)
- sqlite3/dbstatvtab (3.50.4):
- sqlite3/common
- sqlite3/fts5 (3.49.2):
- sqlite3/fts5 (3.50.4):
- sqlite3/common
- sqlite3/math (3.49.2):
- sqlite3/math (3.50.4):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.2):
- sqlite3/perf-threadsafe (3.50.4):
- sqlite3/common
- sqlite3/rtree (3.49.2):
- sqlite3/rtree (3.50.4):
- sqlite3/common
- sqlite3/session (3.50.4):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.1)
- sqlite3 (~> 3.50.4)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- url_launcher_ios (0.0.1):
- Flutter

Expand Down Expand Up @@ -73,17 +76,17 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"

SPEC CHECKSUMS:
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
powersync-sqlite-core: 954b7c4f068e21e6e759a7f487f0d7da4062e858
powersync_flutter_libs: ecbd37268a3705351178a05c81434592f0dcc6e5
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d

PODFILE CHECKSUM: f7b3cb7384a2d5da4b22b090e1f632de7f377987
PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f

COCOAPODS: 1.16.2
6 changes: 3 additions & 3 deletions demos/supabase-todolist/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
Expand Down Expand Up @@ -419,7 +419,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
Expand Down Expand Up @@ -468,7 +468,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:path_provider/path_provider.dart';
import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_core/attachments/io.dart';

Future<LocalStorage> localAttachmentStorage() async {
final appDocDir = await getApplicationDocumentsDirectory();
return IOLocalStorage(appDocDir);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:powersync_core/attachments/attachments.dart';

Future<LocalStorage> localAttachmentStorage() async {
// This file is imported on the web, where we don't currently have a
// persistent local storage implementation.
return LocalStorage.inMemory();
}
35 changes: 17 additions & 18 deletions demos/supabase-todolist/lib/attachments/photo_capture_widget.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import 'dart:async';

import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:powersync/powersync.dart' as powersync;
import 'package:logging/logging.dart';
import 'package:powersync_flutter_demo/attachments/queue.dart';
import 'package:powersync_flutter_demo/models/todo_item.dart';
import 'package:powersync_flutter_demo/powersync.dart';

class TakePhotoWidget extends StatefulWidget {
final String todoId;
Expand All @@ -23,6 +21,7 @@ class TakePhotoWidget extends StatefulWidget {
class _TakePhotoWidgetState extends State<TakePhotoWidget> {
late CameraController _cameraController;
late Future<void> _initializeControllerFuture;
final log = Logger('TakePhotoWidget');

@override
void initState() {
Expand All @@ -37,33 +36,33 @@ class _TakePhotoWidgetState extends State<TakePhotoWidget> {
}

@override
// Dispose of the camera controller when the widget is disposed
void dispose() {
_cameraController.dispose();
super.dispose();
}

Future<void> _takePhoto(context) async {
try {
// Ensure the camera is initialized before taking a photo
log.info('Taking photo for todo: ${widget.todoId}');
await _initializeControllerFuture;

final XFile photo = await _cameraController.takePicture();
// copy photo to new directory with ID as name
String photoId = powersync.uuid.v4();
String storageDirectory = await attachmentQueue.getStorageDirectory();
await attachmentQueue.localStorage
.copyFile(photo.path, '$storageDirectory/$photoId.jpg');

int photoSize = await photo.length();
// Read the photo data as bytes
final photoFile = File(photo.path);
if (!await photoFile.exists()) {
log.warning('Photo file does not exist: ${photo.path}');
return;
}

final photoData = photoFile.openRead();

TodoItem.addPhoto(photoId, widget.todoId);
attachmentQueue.saveFile(photoId, photoSize);
// Save the photo attachment with the byte data
final attachment = await savePhotoAttachment(photoData, widget.todoId);

log.info('Photo attachment saved with ID: ${attachment.id}');
} catch (e) {
log.info('Error taking photo: $e');
log.severe('Error taking photo: $e');
}

// After taking the photo, navigate back to the previous screen
Navigator.pop(context);
}

Expand Down
13 changes: 8 additions & 5 deletions demos/supabase-todolist/lib/attachments/photo_widget.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'dart:io';

import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:flutter/material.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_flutter_demo/attachments/camera_helpers.dart';
import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart';
import 'package:powersync_flutter_demo/attachments/queue.dart';

import '../models/todo_item.dart';
import '../powersync.dart';

class PhotoWidget extends StatefulWidget {
final TodoItem todo;
Expand Down Expand Up @@ -37,11 +39,12 @@ class _PhotoWidgetState extends State<PhotoWidget> {
if (photoId == null) {
return _ResolvedPhotoState(photoPath: null, fileExists: false);
}
photoPath = await attachmentQueue.getLocalUri('$photoId.jpg');
final appDocDir = await getApplicationDocumentsDirectory();
photoPath = p.join(appDocDir.path, '$photoId.jpg');

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

final row = await attachmentQueue.db
final row = await db
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);

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

if (fileArchived) {
return Column(
Expand Down
128 changes: 51 additions & 77 deletions demos/supabase-todolist/lib/attachments/queue.dart
Original file line number Diff line number Diff line change
@@ -1,90 +1,64 @@
import 'dart:async';

import 'package:logging/logging.dart';
import 'package:powersync/powersync.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_flutter_demo/app_config.dart';
import 'package:powersync_core/attachments/attachments.dart';

import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';

import 'package:powersync_flutter_demo/models/schema.dart';
import 'local_storage_unsupported.dart'
if (dart.library.io) 'local_storage_native.dart';

/// Global reference to the queue
late final PhotoAttachmentQueue attachmentQueue;
late AttachmentQueue attachmentQueue;
final remoteStorage = SupabaseStorageAdapter();
final logger = Logger('AttachmentQueue');

/// Function to handle errors when downloading attachments
/// Return false if you want to archive the attachment
Future<bool> onDownloadError(Attachment attachment, Object exception) async {
if (exception.toString().contains('Object not found')) {
return false;
}
return true;
}

class PhotoAttachmentQueue extends AbstractAttachmentQueue {
PhotoAttachmentQueue(db, remoteStorage)
: super(
db: db,
remoteStorage: remoteStorage,
onDownloadError: onDownloadError);

@override
init() async {
if (AppConfig.supabaseStorageBucket.isEmpty) {
log.info(
'No Supabase bucket configured, skip setting up PhotoAttachmentQueue watches');
return;
}

await super.init();
}

@override
Future<Attachment> saveFile(String fileId, int size,
{mediaType = 'image/jpeg'}) async {
String filename = '$fileId.jpg';
Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
attachmentQueue = AttachmentQueue(
db: db,
remoteStorage: remoteStorage,
logger: logger,
localStorage: await localAttachmentStorage(),
watchAttachments: () => db.watch('''
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
''').map(
(results) => [
for (final row in results)
WatchedAttachmentItem(
id: row['id'] as String,
fileExtension: 'jpg',
)
],
),
);

Attachment photoAttachment = Attachment(
id: fileId,
filename: filename,
state: AttachmentState.queuedUpload.index,
mediaType: mediaType,
localUri: getLocalFilePathSuffix(filename),
size: size,
);

return attachmentsService.saveAttachment(photoAttachment);
}

@override
Future<Attachment> deleteFile(String fileId) async {
String filename = '$fileId.jpg';

Attachment photoAttachment = Attachment(
id: fileId,
filename: filename,
state: AttachmentState.queuedDelete.index);

return attachmentsService.saveAttachment(photoAttachment);
}
await attachmentQueue.startSync();
}

@override
StreamSubscription<void> watchIds({String fileExtension = 'jpg'}) {
log.info('Watching photos in $todosTable...');
return db.watch('''
SELECT photo_id FROM $todosTable
WHERE photo_id IS NOT NULL
''').map((results) {
return results.map((row) => row['photo_id'] as String).toList();
}).listen((ids) async {
List<String> idsInQueue = await attachmentsService.getAttachmentIds();
List<String> relevantIds =
ids.where((element) => !idsInQueue.contains(element)).toList();
syncingService.processIds(relevantIds, fileExtension);
});
}
Future<Attachment> savePhotoAttachment(
Stream<List<int>> photoData, String todoId,
{String mediaType = 'image/jpeg'}) async {
// Save the file using the AttachmentQueue API
return await attachmentQueue.saveFile(
data: photoData,
mediaType: mediaType,
fileExtension: 'jpg',
metaData: 'Photo attachment for todo: $todoId',
updateHook: (context, attachment) async {
// Update the todo item to reference this attachment
await context.execute(
'UPDATE todos SET photo_id = ? WHERE id = ?',
[attachment.id, todoId],
);
},
);
}

initializeAttachmentQueue(PowerSyncDatabase db) async {
attachmentQueue = PhotoAttachmentQueue(db, remoteStorage);
await attachmentQueue.init();
Future<Attachment> deletePhotoAttachment(String fileId) async {
return await attachmentQueue.deleteFile(
attachmentId: fileId,
updateHook: (context, attachment) async {
// Optionally update relationships in the same transaction
},
);
}
Loading
Loading