Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_core/attachments/web.dart';

Future<LocalStorage> localAttachmentStorage() async {
return OpfsLocalStorage('powersync_attachments');
}
250 changes: 152 additions & 98 deletions demos/supabase-todolist/lib/attachments/photo_widget.dart
Original file line number Diff line number Diff line change
@@ -1,136 +1,190 @@
import 'dart:io';
import 'dart:typed_data';

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

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

class PhotoWidget extends StatefulWidget {
class PhotoWidget extends StatelessWidget {
final TodoItem todo;

PhotoWidget({
required this.todo,
}) : super(key: ObjectKey(todo.id));

@override
State<StatefulWidget> createState() {
return _PhotoWidgetState();
Widget build(BuildContext context) {
return StreamBuilder(
stream: _attachmentState(todo.photoId),
builder: (context, snapshot) {
if (snapshot.data == null) {
return Container();
}
final data = snapshot.data!;
final attachment = data.attachment;
if (todo.photoId == null || attachment == null) {
return TakePhotoButton(todoId: todo.id);
}

var fileArchived = data.attachment?.state == AttachmentState.archived;

if (fileArchived) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Unavailable"),
const SizedBox(height: 8),
TakePhotoButton(todoId: todo.id),
],
);
}

if (!data.fileExists) {
return const Text('Downloading...');
}

if (kIsWeb) {
// We can't use Image.file on the web, so fall back to loading the
// image from OPFS.
return _WebAttachmentImage(attachment: attachment);
} else {
final path =
(localStorage as IOLocalStorage).pathFor(attachment.filename);
return Image.file(
key: ValueKey(attachment),
File(path),
width: 50,
height: 50,
);
}
},
);
}

static Stream<_AttachmentState> _attachmentState(String? id) {
return db.watch('SELECT * FROM attachments_queue WHERE id = ?',
parameters: [id]).asyncMap((rows) async {
if (rows.isEmpty) {
return const _AttachmentState(null, false);
}

final attachment = Attachment.fromRow(rows.single);
final exists = await localStorage.fileExists(attachment.filename);
return _AttachmentState(attachment, exists);
});
}
}

class _ResolvedPhotoState {
String? photoPath;
bool fileExists;
Attachment? attachment;
class TakePhotoButton extends StatelessWidget {
final String todoId;

_ResolvedPhotoState(
{required this.photoPath, required this.fileExists, this.attachment});
const TakePhotoButton({super.key, required this.todoId});

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final camera = await setupCamera();
if (!context.mounted) return;

if (camera == null) {
const snackBar = SnackBar(
content: Text('No camera available'),
backgroundColor: Colors.red, // Optional: to highlight it's an error
);

ScaffoldMessenger.of(context).showSnackBar(snackBar);
return;
}

Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TakePhotoWidget(todoId: todoId, camera: camera),
),
);
},
child: const Text('Take Photo'),
);
}
}

class _PhotoWidgetState extends State<PhotoWidget> {
late String photoPath;
final class _AttachmentState {
final Attachment? attachment;
final bool fileExists;

Future<_ResolvedPhotoState> _getPhotoState(photoId) async {
if (photoId == null) {
return _ResolvedPhotoState(photoPath: null, fileExists: false);
}
final appDocDir = await getApplicationDocumentsDirectory();
photoPath = p.join(appDocDir.path, '$photoId.jpg');
const _AttachmentState(this.attachment, this.fileExists);
}

bool fileExists = await File(photoPath).exists();
/// A widget showing an [Attachment] as an image by loading it into memory.
///
/// On native platforms, using a file path is more efficient.
class _WebAttachmentImage extends StatefulWidget {
final Attachment attachment;

final row = await db
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);
const _WebAttachmentImage({required this.attachment});

if (row != null) {
Attachment attachment = Attachment.fromRow(row);
return _ResolvedPhotoState(
photoPath: photoPath, fileExists: fileExists, attachment: attachment);
}
@override
State<_WebAttachmentImage> createState() => _AttachmentImageState();
}

return _ResolvedPhotoState(
photoPath: photoPath, fileExists: fileExists, attachment: null);
class _AttachmentImageState extends State<_WebAttachmentImage> {
Future<Uint8List?>? _imageBytes;

void _loadBytes() {
setState(() {
_imageBytes = Future(() async {
final buffer = BytesBuilder();
if (!await localStorage.fileExists(widget.attachment.filename)) {
return null;
}

await localStorage
.readFile(widget.attachment.filename)
.forEach(buffer.add);
return buffer.takeBytes();
});
});
}

@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _getPhotoState(widget.todo.photoId),
builder: (BuildContext context,
AsyncSnapshot<_ResolvedPhotoState> snapshot) {
if (snapshot.data == null) {
return Container();
}
final data = snapshot.data!;
Widget takePhotoButton = ElevatedButton(
onPressed: () async {
final camera = await setupCamera();
if (!context.mounted) return;

if (camera == null) {
const snackBar = SnackBar(
content: Text('No camera available'),
backgroundColor:
Colors.red, // Optional: to highlight it's an error
);

ScaffoldMessenger.of(context).showSnackBar(snackBar);
return;
}

Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TakePhotoWidget(todoId: widget.todo.id, camera: camera),
),
);
},
child: const Text('Take Photo'),
);
void initState() {
super.initState();
_loadBytes();
}

if (widget.todo.photoId == null) {
return takePhotoButton;
}

String? filePath = data.photoPath;
bool fileIsDownloading = !data.fileExists;
bool fileArchived =
data.attachment?.state == AttachmentState.archived;

if (fileArchived) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Unavailable"),
const SizedBox(height: 8),
takePhotoButton
],
);
}

if (fileIsDownloading) {
return const Text("Downloading...");
}

File imageFile = File(filePath!);
int lastModified = imageFile.existsSync()
? imageFile.lastModifiedSync().millisecondsSinceEpoch
: 0;
Key key = ObjectKey('$filePath:$lastModified');
@override
void didUpdateWidget(covariant _WebAttachmentImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.attachment != widget.attachment) {
_loadBytes();
}
}

return Image.file(
key: key,
imageFile,
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _imageBytes,
builder: (context, snapshot) {
if (snapshot.data case final bytes?) {
return Image.memory(
bytes,
width: 50,
height: 50,
);
});
} else {
return Container();
}
},
);
}
}
4 changes: 3 additions & 1 deletion demos/supabase-todolist/lib/attachments/queue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';

import 'local_storage_unsupported.dart'
if (dart.library.js_interop) 'local_storage_web.dart'
if (dart.library.io) 'local_storage_native.dart';

late AttachmentQueue attachmentQueue;
late LocalStorage localStorage;
final remoteStorage = SupabaseStorageAdapter();
final logger = Logger('AttachmentQueue');

Expand All @@ -18,7 +20,7 @@ Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
db: db,
remoteStorage: remoteStorage,
logger: logger,
localStorage: await localAttachmentStorage(),
localStorage: localStorage = await localAttachmentStorage(),
watchAttachments: () => db.watch('''
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
''').map(
Expand Down
2 changes: 1 addition & 1 deletion packages/powersync_core/lib/attachments/attachments.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ library;

export '../src/attachments/attachment.dart';
export '../src/attachments/attachment_queue_service.dart';
export '../src/attachments/local_storage.dart';
export '../src/attachments/storage/local_storage.dart';
export '../src/attachments/remote_storage.dart';
export '../src/attachments/sync_error_handler.dart';
6 changes: 3 additions & 3 deletions packages/powersync_core/lib/attachments/io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
/// {@category attachments}
library;

import '../src/attachments/io_local_storage.dart';
import '../src/attachments/local_storage.dart';
import '../src/attachments/storage/io_local_storage.dart';
import '../src/attachments/storage/local_storage.dart';

export '../src/attachments/io_local_storage.dart';
export '../src/attachments/storage/io_local_storage.dart';
12 changes: 12 additions & 0 deletions packages/powersync_core/lib/attachments/web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// A platform-specific import supporting attachments on the web.
///
/// This library exports the [OpfsLocalStorage] class, implementing the
/// [LocalStorage] interface by storing files under a root directory.
///
/// {@category attachments}
library;

import '../src/attachments/storage/web_opfs_storage.dart';
import '../src/attachments/storage/local_storage.dart';

export '../src/attachments/storage/web_opfs_storage.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import 'package:sqlite_async/sqlite_async.dart';

import 'attachment.dart';
import 'implementations/attachment_context.dart';
import 'local_storage.dart';
import 'storage/local_storage.dart';
import 'remote_storage.dart';
import 'sync_error_handler.dart';
import 'implementations/attachment_service.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ final class IOLocalStorage implements LocalStorage {

const IOLocalStorage(this._root);

File _fileFor(String filePath) => File(p.join(_root.path, filePath));
/// Returns the path of a relative [filePath] resolved against this local
/// storage implementation.
String pathFor(String filePath) => p.join(_root.path, filePath);

File _fileFor(String filePath) => File(pathFor(filePath));

@override
Future<int> saveFile(String filePath, Stream<List<int>> data) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ abstract interface class LocalStorage {
/// [filePath] - Path of the file to read
///
/// Returns a stream of binary data
Stream<Uint8List> readFile(String filePath);
Stream<Uint8List> readFile(String filePath, {String? mediaType});

/// Deletes a file at the specified path
///
Expand Down Expand Up @@ -79,7 +79,7 @@ final class _InMemoryStorage implements LocalStorage {
Future<void> initialize() async {}

@override
Stream<Uint8List> readFile(String filePath) {
Stream<Uint8List> readFile(String filePath, {String? mediaType}) {
return switch (content[_keyForPath(filePath)]) {
null =>
Stream.error('file at $filePath does not exist in in-memory storage'),
Expand Down
Loading