Skip to content

Commit a60952e

Browse files
committed
Web support for attachments
1 parent 1172e8a commit a60952e

File tree

6 files changed

+164
-102
lines changed

6 files changed

+164
-102
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import 'package:powersync_core/attachments/attachments.dart';
2+
import 'package:powersync_core/attachments/web.dart';
3+
4+
Future<LocalStorage> localAttachmentStorage() async {
5+
return OpfsLocalStorage('powersync_attachments');
6+
}
Lines changed: 137 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,173 @@
1-
import 'dart:io';
1+
import 'dart:typed_data';
22

3-
import 'package:path_provider/path_provider.dart';
4-
import 'package:path/path.dart' as p;
53
import 'package:flutter/material.dart';
64
import 'package:powersync_core/attachments/attachments.dart';
75
import 'package:powersync_flutter_demo/attachments/camera_helpers.dart';
86
import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart';
97

108
import '../models/todo_item.dart';
119
import '../powersync.dart';
10+
import 'queue.dart';
1211

13-
class PhotoWidget extends StatefulWidget {
12+
class PhotoWidget extends StatelessWidget {
1413
final TodoItem todo;
1514

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

2019
@override
21-
State<StatefulWidget> createState() {
22-
return _PhotoWidgetState();
20+
Widget build(BuildContext context) {
21+
return StreamBuilder(
22+
stream: _attachmentState(todo.photoId),
23+
builder: (context, snapshot) {
24+
if (snapshot.data == null) {
25+
return Container();
26+
}
27+
final data = snapshot.data!;
28+
final attachment = data.attachment;
29+
if (todo.photoId == null || attachment == null) {
30+
return TakePhotoButton(todoId: todo.id);
31+
}
32+
33+
var fileArchived = data.attachment?.state == AttachmentState.archived;
34+
35+
if (fileArchived) {
36+
return Column(
37+
crossAxisAlignment: CrossAxisAlignment.center,
38+
mainAxisAlignment: MainAxisAlignment.center,
39+
children: [
40+
const Text("Unavailable"),
41+
const SizedBox(height: 8),
42+
TakePhotoButton(todoId: todo.id),
43+
],
44+
);
45+
}
46+
47+
return AttachmentImage(attachment: attachment);
48+
},
49+
);
50+
}
51+
52+
static Stream<_AttachmentState> _attachmentState(String? id) {
53+
return db.watch('SELECT * FROM attachments_queue WHERE id = ?',
54+
parameters: [id]).map((rows) {
55+
if (rows.isEmpty) {
56+
return const _AttachmentState(null);
57+
}
58+
59+
return _AttachmentState(Attachment.fromRow(rows.single));
60+
});
2361
}
2462
}
2563

26-
class _ResolvedPhotoState {
27-
String? photoPath;
28-
bool fileExists;
29-
Attachment? attachment;
64+
class TakePhotoButton extends StatelessWidget {
65+
final String todoId;
66+
67+
const TakePhotoButton({super.key, required this.todoId});
3068

31-
_ResolvedPhotoState(
32-
{required this.photoPath, required this.fileExists, this.attachment});
69+
@override
70+
Widget build(BuildContext context) {
71+
return ElevatedButton(
72+
onPressed: () async {
73+
final camera = await setupCamera();
74+
if (!context.mounted) return;
75+
76+
if (camera == null) {
77+
const snackBar = SnackBar(
78+
content: Text('No camera available'),
79+
backgroundColor: Colors.red, // Optional: to highlight it's an error
80+
);
81+
82+
ScaffoldMessenger.of(context).showSnackBar(snackBar);
83+
return;
84+
}
85+
86+
Navigator.push(
87+
context,
88+
MaterialPageRoute(
89+
builder: (context) =>
90+
TakePhotoWidget(todoId: todoId, camera: camera),
91+
),
92+
);
93+
},
94+
child: const Text('Take Photo'),
95+
);
96+
}
3397
}
3498

35-
class _PhotoWidgetState extends State<PhotoWidget> {
36-
late String photoPath;
99+
final class _AttachmentState {
100+
final Attachment? attachment;
37101

38-
Future<_ResolvedPhotoState> _getPhotoState(photoId) async {
39-
if (photoId == null) {
40-
return _ResolvedPhotoState(photoPath: null, fileExists: false);
41-
}
42-
final appDocDir = await getApplicationDocumentsDirectory();
43-
photoPath = p.join(appDocDir.path, '$photoId.jpg');
102+
const _AttachmentState(this.attachment);
103+
}
44104

45-
bool fileExists = await File(photoPath).exists();
105+
/// A widget showing an [Attachment] as an image.
106+
///
107+
/// For better web support, always loads the attachment into memory first. If
108+
/// you're only targeting native platforms, a more efficient mechanism would be
109+
/// to use `IOLocalStorage.pathFor` with an [Image.file] image.
110+
class AttachmentImage extends StatefulWidget {
111+
final Attachment attachment;
46112

47-
final row = await db
48-
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);
113+
const AttachmentImage({super.key, required this.attachment});
49114

50-
if (row != null) {
51-
Attachment attachment = Attachment.fromRow(row);
52-
return _ResolvedPhotoState(
53-
photoPath: photoPath, fileExists: fileExists, attachment: attachment);
54-
}
115+
@override
116+
State<AttachmentImage> createState() => _AttachmentImageState();
117+
}
55118

56-
return _ResolvedPhotoState(
57-
photoPath: photoPath, fileExists: fileExists, attachment: null);
119+
class _AttachmentImageState extends State<AttachmentImage> {
120+
Future<Uint8List?>? _imageBytes;
121+
122+
void _loadBytes() {
123+
setState(() {
124+
_imageBytes = Future(() async {
125+
final buffer = BytesBuilder();
126+
if (!await localStorage.fileExists(widget.attachment.filename)) {
127+
return null;
128+
}
129+
130+
await localStorage
131+
.readFile(widget.attachment.filename)
132+
.forEach(buffer.add);
133+
return buffer.takeBytes();
134+
});
135+
});
58136
}
59137

60138
@override
61-
Widget build(BuildContext context) {
62-
return FutureBuilder(
63-
future: _getPhotoState(widget.todo.photoId),
64-
builder: (BuildContext context,
65-
AsyncSnapshot<_ResolvedPhotoState> snapshot) {
66-
if (snapshot.data == null) {
67-
return Container();
68-
}
69-
final data = snapshot.data!;
70-
Widget takePhotoButton = ElevatedButton(
71-
onPressed: () async {
72-
final camera = await setupCamera();
73-
if (!context.mounted) return;
74-
75-
if (camera == null) {
76-
const snackBar = SnackBar(
77-
content: Text('No camera available'),
78-
backgroundColor:
79-
Colors.red, // Optional: to highlight it's an error
80-
);
81-
82-
ScaffoldMessenger.of(context).showSnackBar(snackBar);
83-
return;
84-
}
85-
86-
Navigator.push(
87-
context,
88-
MaterialPageRoute(
89-
builder: (context) =>
90-
TakePhotoWidget(todoId: widget.todo.id, camera: camera),
91-
),
92-
);
93-
},
94-
child: const Text('Take Photo'),
95-
);
139+
void initState() {
140+
super.initState();
141+
_loadBytes();
142+
}
96143

97-
if (widget.todo.photoId == null) {
98-
return takePhotoButton;
99-
}
144+
@override
145+
void didUpdateWidget(covariant AttachmentImage oldWidget) {
146+
super.didUpdateWidget(oldWidget);
147+
if (oldWidget.attachment != widget.attachment) {
148+
_loadBytes();
149+
}
150+
}
100151

101-
String? filePath = data.photoPath;
102-
bool fileIsDownloading = !data.fileExists;
103-
bool fileArchived =
104-
data.attachment?.state == AttachmentState.archived;
105-
106-
if (fileArchived) {
107-
return Column(
108-
crossAxisAlignment: CrossAxisAlignment.center,
109-
mainAxisAlignment: MainAxisAlignment.center,
110-
children: [
111-
const Text("Unavailable"),
112-
const SizedBox(height: 8),
113-
takePhotoButton
114-
],
152+
@override
153+
Widget build(BuildContext context) {
154+
return FutureBuilder(
155+
future: _imageBytes,
156+
builder: (context, snapshot) {
157+
if (snapshot.connectionState == ConnectionState.done) {
158+
if (snapshot.data case final bytes?) {
159+
return Image.memory(
160+
bytes,
161+
width: 50,
162+
height: 50,
115163
);
164+
} else {
165+
return const Text('Downloading...');
116166
}
117-
118-
if (fileIsDownloading) {
119-
return const Text("Downloading...");
120-
}
121-
122-
File imageFile = File(filePath!);
123-
int lastModified = imageFile.existsSync()
124-
? imageFile.lastModifiedSync().millisecondsSinceEpoch
125-
: 0;
126-
Key key = ObjectKey('$filePath:$lastModified');
127-
128-
return Image.file(
129-
key: key,
130-
imageFile,
131-
width: 50,
132-
height: 50,
133-
);
134-
});
167+
} else {
168+
return Container();
169+
}
170+
},
171+
);
135172
}
136173
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import 'package:powersync_core/attachments/attachments.dart';
77
import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';
88

99
import 'local_storage_unsupported.dart'
10+
if (dart.library.js_interop) 'local_storage_web.dart'
1011
if (dart.library.io) 'local_storage_native.dart';
1112

1213
late AttachmentQueue attachmentQueue;
14+
late LocalStorage localStorage;
1315
final remoteStorage = SupabaseStorageAdapter();
1416
final logger = Logger('AttachmentQueue');
1517

@@ -18,7 +20,7 @@ Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
1820
db: db,
1921
remoteStorage: remoteStorage,
2022
logger: logger,
21-
localStorage: await localAttachmentStorage(),
23+
localStorage: localStorage = await localAttachmentStorage(),
2224
watchAttachments: () => db.watch('''
2325
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
2426
''').map(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// A platform-specific import supporting attachments on the web.
2+
///
3+
/// This library exports the [OpfsLocalStorage] class, implementing the
4+
/// [LocalStorage] interface by storing files under a root directory.
5+
///
6+
/// {@category attachments}
7+
library;
8+
9+
import '../src/attachments/storage/web_opfs_storage.dart';
10+
import '../src/attachments/storage/local_storage.dart';
11+
12+
export '../src/attachments/storage/web_opfs_storage.dart';

packages/powersync_core/lib/src/attachments/storage/io_local_storage.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ final class IOLocalStorage implements LocalStorage {
2222

2323
const IOLocalStorage(this._root);
2424

25-
File _fileFor(String filePath) => File(p.join(_root.path, filePath));
25+
/// Returns the path of a relative [filePath] resolved against this local
26+
/// storage implementation.
27+
String pathFor(String filePath) => p.join(_root.path, filePath);
28+
29+
File _fileFor(String filePath) => File(pathFor(filePath));
2630

2731
@override
2832
Future<int> saveFile(String filePath, Stream<List<int>> data) async {

packages/powersync_core/test/attachments/local_storage_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ void main() {
2929
final data = Uint8List.fromList([1, 2, 3, 4, 5]);
3030
final size = await storage.saveFile(filePath, Stream.value(data));
3131
expect(size, equals(data.length));
32+
expect(storage.pathFor(filePath), d.path('test_file'));
3233

3334
final resultStream = storage.readFile(filePath);
3435
final result = await resultStream.toList();

0 commit comments

Comments
 (0)