diff --git a/demos/supabase-todolist-drift/.gitignore b/demos/supabase-todolist-drift/.gitignore index b5e97dcc..f4850867 100644 --- a/demos/supabase-todolist-drift/.gitignore +++ b/demos/supabase-todolist-drift/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/demos/supabase-todolist-drift/README.md b/demos/supabase-todolist-drift/README.md index 7b8d632e..0fd2661a 100644 --- a/demos/supabase-todolist-drift/README.md +++ b/demos/supabase-todolist-drift/README.md @@ -1,6 +1,7 @@ # PowerSync + Supabase + Drift Flutter Demo: Todo List App This demo app is an extension of the [Supabase Todo List App](../supabase-todolist/README.md) and showcases how to set up and use the [drift_sqlite_async](https://pub.dev/packages/drift_sqlite_async) library (Drift ORM) with PowerSync. +This demo also uses [riverpod](https://riverpod.dev) to highlight best practices for state management. Notes about the Drift usage are [further below](#drift). @@ -52,5 +53,5 @@ Insert the credentials of your new Supabase and PowerSync projects into `lib/app The `database.g.dart` file containing the \_$AppDatabase class has to be generated if there are changes made to the `database.dart` file. - `dart run build_runner build` generates all the required code once. -- `dart run build_runner build --delete-conflicting-outputs` deletes previously generated files and generates the required code once. +- `dart run build_runner build -d` deletes previously generated files and generates the required code once. - `dart run build_runner watch` watches for changes in your sources and generates code with incremental rebuilds. This is better for development. diff --git a/demos/supabase-todolist-drift/analysis_options.yaml b/demos/supabase-todolist-drift/analysis_options.yaml index 8648a0cd..adcc25de 100644 --- a/demos/supabase-todolist-drift/analysis_options.yaml +++ b/demos/supabase-todolist-drift/analysis_options.yaml @@ -1,3 +1,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: [lib/**.g.dart] + +linter: + rules: + - prefer_relative_imports diff --git a/demos/supabase-todolist-drift/database.sql b/demos/supabase-todolist-drift/database.sql new file mode 100644 index 00000000..eecc9768 --- /dev/null +++ b/demos/supabase-todolist-drift/database.sql @@ -0,0 +1,71 @@ +-- Create tables +create table + public.lists ( + id uuid not null default gen_random_uuid (), + created_at timestamp with time zone not null default now(), + name text not null, + owner_id uuid not null, + constraint lists_pkey primary key (id), + constraint lists_owner_id_fkey foreign key (owner_id) references auth.users (id) on delete cascade + ) tablespace pg_default; + +create table + public.todos ( + id uuid not null default gen_random_uuid (), + created_at timestamp with time zone not null default now(), + completed_at timestamp with time zone null, + description text not null, + completed boolean not null default false, + created_by uuid null, + completed_by uuid null, + list_id uuid not null, + photo_id uuid null, + constraint todos_pkey primary key (id), + constraint todos_created_by_fkey foreign key (created_by) references auth.users (id) on delete set null, + constraint todos_completed_by_fkey foreign key (completed_by) references auth.users (id) on delete set null, + constraint todos_list_id_fkey foreign key (list_id) references lists (id) on delete cascade + ) tablespace pg_default; + +-- Create publication for powersync +create publication powersync for table lists, todos; + +-- Set up Row Level Security (RLS) +-- See https://supabase.com/docs/guides/auth/row-level-security for more details. +alter table public.lists + enable row level security; + +alter table public.todos + enable row level security; + +create policy "owned lists" on public.lists for ALL using ( + auth.uid() = owner_id +); + +create policy "todos in owned lists" on public.todos for ALL using ( + auth.uid() IN ( + SELECT lists.owner_id FROM lists WHERE (lists.id = todos.list_id) + ) +); + +-- This trigger automatically creates some sample data when a user registers. +-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. +create function public.handle_new_user_sample_data() +returns trigger as $$ +declare + new_list_id uuid; +begin + insert into public.lists (name, owner_id) + values ('Shopping list', new.id) + returning id into new_list_id; + + insert into public.todos(description, list_id, created_by) + values ('Bread', new_list_id, new.id); + + insert into public.todos(description, list_id, created_by) + values ('Apples', new_list_id, new.id); + + return new; +end; +$$ language plpgsql security definer; + +create trigger new_user_sample_data after insert on auth.users for each row execute procedure public.handle_new_user_sample_data(); diff --git a/demos/supabase-todolist-drift/lib/attachments/camera_helpers.dart b/demos/supabase-todolist-drift/lib/attachments/camera_helpers.dart deleted file mode 100644 index 8aab6291..00000000 --- a/demos/supabase-todolist-drift/lib/attachments/camera_helpers.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:camera/camera.dart'; -import 'package:flutter/widgets.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -Future setupCamera() async { - // Ensure that plugin services are initialized so that `availableCameras()` - // can be called before `runApp()` - WidgetsFlutterBinding.ensureInitialized(); - // Obtain a list of the available cameras on the device. - try { - final cameras = await availableCameras(); - // Get a specific camera from the list of available cameras. - final camera = cameras.isNotEmpty ? cameras.first : null; - return camera; - } catch (e) { - // Camera is not supported on all platforms - log.warning('Failed to setup camera: $e'); - return null; - } -} diff --git a/demos/supabase-todolist-drift/lib/attachments/photo_widget.dart b/demos/supabase-todolist-drift/lib/attachments/photo_widget.dart deleted file mode 100644 index 7d3799b7..00000000 --- a/demos/supabase-todolist-drift/lib/attachments/photo_widget.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; -import 'package:supabase_todolist_drift/attachments/camera_helpers.dart'; -import 'package:supabase_todolist_drift/attachments/photo_capture_widget.dart'; -import 'package:supabase_todolist_drift/attachments/queue.dart'; -import 'package:supabase_todolist_drift/database.dart'; - -class PhotoWidget extends StatefulWidget { - final TodoItem todo; - - PhotoWidget({ - required this.todo, - }) : super(key: ObjectKey(todo.id)); - - @override - State createState() { - return _PhotoWidgetState(); - } -} - -class _ResolvedPhotoState { - String? photoPath; - bool fileExists; - Attachment? attachment; - - _ResolvedPhotoState( - {required this.photoPath, required this.fileExists, this.attachment}); -} - -class _PhotoWidgetState extends State { - late String photoPath; - - Future<_ResolvedPhotoState> _getPhotoState(photoId) async { - if (photoId == null) { - return _ResolvedPhotoState(photoPath: null, fileExists: false); - } - photoPath = await attachmentQueue.getLocalUri('$photoId.jpg'); - - bool fileExists = await File(photoPath).exists(); - - final row = await attachmentQueue.db - .getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]); - - if (row != null) { - Attachment attachment = Attachment.fromRow(row); - return _ResolvedPhotoState( - photoPath: photoPath, fileExists: fileExists, attachment: attachment); - } - - return _ResolvedPhotoState( - photoPath: photoPath, fileExists: fileExists, attachment: null); - } - - @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'), - ); - - if (widget.todo.photoId == null) { - return takePhotoButton; - } - - String? filePath = data.photoPath; - bool fileIsDownloading = !data.fileExists; - bool fileArchived = - data.attachment?.state == AttachmentState.archived.index; - - 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'); - - return Image.file( - key: key, - imageFile, - width: 50, - height: 50, - ); - }); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/status_app_bar.dart b/demos/supabase-todolist-drift/lib/components/app_bar.dart similarity index 67% rename from demos/supabase-todolist-drift/lib/widgets/status_app_bar.dart rename to demos/supabase-todolist-drift/lib/components/app_bar.dart index 75098d69..79de9a32 100644 --- a/demos/supabase-todolist-drift/lib/widgets/status_app_bar.dart +++ b/demos/supabase-todolist-drift/lib/components/app_bar.dart @@ -1,50 +1,32 @@ -import 'dart:async'; - +import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:powersync/powersync.dart'; -import 'package:supabase_todolist_drift/widgets/fts_search_delegate.dart'; -import '../powersync.dart'; - -class StatusAppBar extends StatefulWidget implements PreferredSizeWidget { - const StatusAppBar({super.key, required this.title}); - final String title; +import '../powersync/powersync.dart'; +import '../screens/search.dart'; - @override - State createState() => _StatusAppBarState(); +final appBar = AppBar( + title: const Text('PowerSync Flutter Demo'), +); - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} +final class StatusAppBar extends ConsumerWidget implements PreferredSizeWidget { + final Widget title; -class _StatusAppBarState extends State { - late SyncStatus _connectionState; - StreamSubscription? _syncStatusSubscription; - - @override - void initState() { - super.initState(); - _connectionState = db.currentStatus; - _syncStatusSubscription = db.statusStream.listen((event) { - setState(() { - _connectionState = db.currentStatus; - }); - }); - } + const StatusAppBar({super.key, required this.title}); @override - void dispose() { - super.dispose(); - _syncStatusSubscription?.cancel(); - } + Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override - Widget build(BuildContext context) { - final statusIcon = _getStatusIcon(_connectionState); + Widget build(BuildContext context, WidgetRef ref) { + final syncState = ref.watch(syncStatus); + final statusIcon = _getStatusIcon(syncState); return AppBar( - title: Text(widget.title), + leading: const AutoLeadingButton(), + title: title, actions: [ IconButton( onPressed: () { diff --git a/demos/supabase-todolist-drift/lib/components/page_layout.dart b/demos/supabase-todolist-drift/lib/components/page_layout.dart new file mode 100644 index 00000000..523d6c93 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/components/page_layout.dart @@ -0,0 +1,67 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../navigation.gr.dart'; +import '../supabase.dart'; +import 'app_bar.dart'; + +final class PageLayout extends ConsumerWidget { + final Widget content; + final Widget? title; + final Widget? floatingActionButton; + final bool showDrawer; + + const PageLayout({ + super.key, + required this.content, + this.title, + this.floatingActionButton, + this.showDrawer = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: StatusAppBar( + title: title ?? const Text('PowerSync Demo'), + ), + body: Center(child: content), + floatingActionButton: floatingActionButton, + drawer: showDrawer + ? Drawer( + // Add a ListView to the drawer. This ensures the user can scroll + // through the options in the drawer if there isn't enough vertical + // space to fit everything. + child: ListView( + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration( + color: Colors.blue, + ), + child: Text(''), + ), + ListTile( + title: const Text('SQL Console'), + onTap: () { + final route = context.topRoute; + if (route.name != SqlConsoleRoute.name) { + context.pushRoute(const SqlConsoleRoute()); + } + }, + ), + ListTile( + title: const Text('Sign Out'), + onTap: () async { + ref.read(authNotifierProvider.notifier).signOut(); + }, + ), + ], + ), + ) + : null, + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/components/photo_widget.dart b/demos/supabase-todolist-drift/lib/components/photo_widget.dart new file mode 100644 index 00000000..2b007717 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/components/photo_widget.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../navigation.dart'; +import '../powersync/attachments/queue.dart'; +import '../powersync/database.dart'; + +part 'photo_widget.g.dart'; + +final class PhotoWidget extends ConsumerWidget { + final TodoItem todo; + + const PhotoWidget({super.key, required this.todo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final photoState = ref.watch(_getPhotoStateProvider(todo.photoId)); + if (!photoState.hasValue) { + return Container(); + } + + final data = photoState.requireValue; + 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; + } + + context.pushRoute(TakePhotoRoute(todoId: todo.id, camera: camera)); + }, + child: const Text('Take Photo'), + ); + + if (todo.photoId == null) { + return takePhotoButton; + } + + String? filePath = data.photoPath; + bool fileIsDownloading = !data.fileExists; + bool fileArchived = + data.attachment?.state == AttachmentState.archived.index; + + 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'); + + return Image.file( + key: key, + imageFile, + width: 50, + height: 50, + ); + } +} + +class _ResolvedPhotoState { + String? photoPath; + bool fileExists; + Attachment? attachment; + + _ResolvedPhotoState( + {required this.photoPath, required this.fileExists, this.attachment}); +} + +@riverpod +Future<_ResolvedPhotoState> _getPhotoState(Ref ref, String? photoId) async { + if (photoId == null) { + return _ResolvedPhotoState(photoPath: null, fileExists: false); + } + final queue = await ref.read(attachmentQueueProvider.future); + final photoPath = await queue.getLocalUri('$photoId.jpg'); + + bool fileExists = await File(photoPath).exists(); + + final row = await queue.db + .getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]); + + if (row != null) { + Attachment attachment = Attachment.fromRow(row); + return _ResolvedPhotoState( + photoPath: photoPath, fileExists: fileExists, attachment: attachment); + } + + return _ResolvedPhotoState( + photoPath: photoPath, fileExists: fileExists, attachment: null); +} + +final _log = Logger('setupCamera'); + +Future setupCamera() async { + // Ensure that plugin services are initialized so that `availableCameras()` + // can be called before `runApp()` + WidgetsFlutterBinding.ensureInitialized(); + // Obtain a list of the available cameras on the device. + try { + final cameras = await availableCameras(); + // Get a specific camera from the list of available cameras. + final camera = cameras.isNotEmpty ? cameras.first : null; + return camera; + } catch (e) { + // Camera is not supported on all platforms + _log.warning('Failed to setup camera: $e'); + return null; + } +} diff --git a/demos/supabase-todolist-drift/lib/components/photo_widget.g.dart b/demos/supabase-todolist-drift/lib/components/photo_widget.g.dart new file mode 100644 index 00000000..84441775 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/components/photo_widget.g.dart @@ -0,0 +1,162 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'photo_widget.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$getPhotoStateHash() => r'9dd805dcfabe9288a1e8c125bae75c34d29c494b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [_getPhotoState]. +@ProviderFor(_getPhotoState) +const _getPhotoStateProvider = _GetPhotoStateFamily(); + +/// See also [_getPhotoState]. +class _GetPhotoStateFamily extends Family> { + /// See also [_getPhotoState]. + const _GetPhotoStateFamily(); + + /// See also [_getPhotoState]. + _GetPhotoStateProvider call( + String? photoId, + ) { + return _GetPhotoStateProvider( + photoId, + ); + } + + @override + _GetPhotoStateProvider getProviderOverride( + covariant _GetPhotoStateProvider provider, + ) { + return call( + provider.photoId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'_getPhotoStateProvider'; +} + +/// See also [_getPhotoState]. +class _GetPhotoStateProvider + extends AutoDisposeFutureProvider<_ResolvedPhotoState> { + /// See also [_getPhotoState]. + _GetPhotoStateProvider( + String? photoId, + ) : this._internal( + (ref) => _getPhotoState( + ref as _GetPhotoStateRef, + photoId, + ), + from: _getPhotoStateProvider, + name: r'_getPhotoStateProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$getPhotoStateHash, + dependencies: _GetPhotoStateFamily._dependencies, + allTransitiveDependencies: + _GetPhotoStateFamily._allTransitiveDependencies, + photoId: photoId, + ); + + _GetPhotoStateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.photoId, + }) : super.internal(); + + final String? photoId; + + @override + Override overrideWith( + FutureOr<_ResolvedPhotoState> Function(_GetPhotoStateRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: _GetPhotoStateProvider._internal( + (ref) => create(ref as _GetPhotoStateRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + photoId: photoId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement<_ResolvedPhotoState> createElement() { + return _GetPhotoStateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is _GetPhotoStateProvider && other.photoId == photoId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, photoId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin _GetPhotoStateRef on AutoDisposeFutureProviderRef<_ResolvedPhotoState> { + /// The parameter `photoId` of this provider. + String? get photoId; +} + +class _GetPhotoStateProviderElement + extends AutoDisposeFutureProviderElement<_ResolvedPhotoState> + with _GetPhotoStateRef { + _GetPhotoStateProviderElement(super.provider); + + @override + String? get photoId => (origin as _GetPhotoStateProvider).photoId; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/database.dart b/demos/supabase-todolist-drift/lib/database.dart deleted file mode 100644 index cf307d72..00000000 --- a/demos/supabase-todolist-drift/lib/database.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:powersync/powersync.dart' show PowerSyncDatabase, uuid; -import 'package:drift_sqlite_async/drift_sqlite_async.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -part 'database.g.dart'; - -class TodoItems extends Table { - @override - String get tableName => 'todos'; - - TextColumn get id => text().clientDefault(() => uuid.v4())(); - TextColumn get listId => text().named('list_id').references(ListItems, #id)(); - TextColumn get photoId => text().nullable().named('photo_id')(); - DateTimeColumn get createdAt => dateTime().nullable().named('created_at')(); - DateTimeColumn get completedAt => - dateTime().nullable().named('completed_at')(); - BoolColumn get completed => boolean().nullable()(); - TextColumn get description => text()(); - TextColumn get createdBy => text().nullable().named('created_by')(); - TextColumn get completedBy => text().nullable().named('completed_by')(); -} - -class ListItems extends Table { - @override - String get tableName => 'lists'; - - TextColumn get id => text().clientDefault(() => uuid.v4())(); - DateTimeColumn get createdAt => - dateTime().named('created_at').clientDefault(() => DateTime.now())(); - TextColumn get name => text()(); - TextColumn get ownerId => text().nullable().named('owner_id')(); -} - -class ListItemWithStats { - late ListItem self; - int completedCount; - int pendingCount; - - ListItemWithStats( - this.self, - this.completedCount, - this.pendingCount, - ); -} - -@DriftDatabase(tables: [TodoItems, ListItems], include: {'queries.drift'}) -class AppDatabase extends _$AppDatabase { - AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncDriftConnection(db)); - - @override - int get schemaVersion => 1; - - Stream> watchLists() { - return (select(listItems) - ..orderBy([(l) => OrderingTerm(expression: l.createdAt)])) - .watch(); - } - - Stream> watchListsWithStats() { - return listsWithStats().watch(); - } - - Future createList(String name) async { - return into(listItems).insertReturning( - ListItemsCompanion.insert(name: name, ownerId: Value(getUserId()))); - } - - Future deleteList(ListItem list) async { - await (delete(listItems)..where((t) => t.id.equals(list.id))).go(); - } - - Stream> watchTodoItems(ListItem list) { - return (select(todoItems) - ..where((t) => t.listId.equals(list.id)) - ..orderBy([(t) => OrderingTerm(expression: t.createdAt)])) - .watch(); - } - - Future deleteTodo(TodoItem todo) async { - await (delete(todoItems)..where((t) => t.id.equals(todo.id))).go(); - } - - Future addTodo(ListItem list, String description) async { - return into(todoItems).insertReturning(TodoItemsCompanion.insert( - listId: list.id, - description: description, - completed: const Value(false), - createdBy: Value(getUserId()))); - } - - Future toggleTodo(TodoItem todo) async { - if (todo.completed != true) { - await (update(todoItems)..where((t) => t.id.equals(todo.id))).write( - TodoItemsCompanion( - completed: const Value(true), - completedAt: Value(DateTime.now()), - completedBy: Value(getUserId()))); - } else { - await (update(todoItems)..where((t) => t.id.equals(todo.id))).write( - const TodoItemsCompanion( - completed: Value(false), - completedAt: Value.absent(), - completedBy: Value.absent())); - } - } - - Future addTodoPhoto(String todoId, String photoId) async { - await (update(todoItems)..where((t) => t.id.equals(todoId))) - .write(TodoItemsCompanion(photoId: Value(photoId))); - } - - Future findList(String id) { - return (select(listItems)..where((t) => t.id.equals(id))).getSingle(); - } -} diff --git a/demos/supabase-todolist-drift/lib/main.dart b/demos/supabase-todolist-drift/lib/main.dart index fe48f040..7cfea76a 100644 --- a/demos/supabase-todolist-drift/lib/main.dart +++ b/demos/supabase-todolist-drift/lib/main.dart @@ -1,16 +1,14 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; -import 'package:supabase_todolist_drift/app_config.dart'; -import 'package:supabase_todolist_drift/attachments/queue.dart'; -import 'package:supabase_todolist_drift/models/schema.dart'; -import 'powersync.dart'; -import 'widgets/lists_page.dart'; -import 'widgets/login_page.dart'; -import 'widgets/query_widget.dart'; -import 'widgets/signup_page.dart'; -import 'widgets/status_app_bar.dart'; +import 'navigation.dart'; +import 'supabase.dart'; +import 'utils/provider_observer.dart'; void main() async { Logger.root.level = Level.INFO; @@ -28,103 +26,43 @@ void main() async { } }); - WidgetsFlutterBinding - .ensureInitialized(); //required to get sqlite filepath from path_provider before UI has initialized - await openDatabase(); - - if (AppConfig.supabaseStorageBucket.isNotEmpty) { - initializeAttachmentQueue(db); - } - - final loggedIn = isLoggedIn(); - runApp(MyApp(loggedIn: loggedIn)); -} - -const defaultQuery = 'SELECT * from $todosTable'; - -const listsPage = ListsPage(); -const homePage = listsPage; - -const sqlConsolePage = Scaffold( - appBar: StatusAppBar(title: 'SQL Console'), - body: QueryWidget(defaultQuery: defaultQuery)); - -const loginPage = LoginPage(); + //required to get sqlite filepath from path_provider before UI has initialized + WidgetsFlutterBinding.ensureInitialized(); + await loadSupabase(); -const signupPage = SignupPage(); - -class MyApp extends StatelessWidget { - final bool loggedIn; - - const MyApp({super.key, required this.loggedIn}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'PowerSync Flutter Demo', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - home: loggedIn ? homePage : loginPage); - } + runApp(const ProviderScope( + observers: [LoggingProviderObserver()], + child: MyApp(), + )); } -class MyHomePage extends StatelessWidget { - const MyHomePage( - {super.key, - required this.title, - required this.content, - this.floatingActionButton}); - - final String title; - final Widget content; - final Widget? floatingActionButton; +class MyApp extends HookConsumerWidget { + const MyApp({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: StatusAppBar(title: title), - body: Center(child: content), - floatingActionButton: floatingActionButton, - drawer: Drawer( - // Add a ListView to the drawer. This ensures the user can scroll - // through the options in the drawer if there isn't enough vertical - // space to fit everything. - child: ListView( - // Important: Remove any padding from the ListView. - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - decoration: BoxDecoration( - color: Colors.blue, - ), - child: Text(''), - ), - ListTile( - title: const Text('SQL Console'), - onTap: () { - var navigator = Navigator.of(context); - navigator.pop(); - - navigator.push(MaterialPageRoute( - builder: (context) => sqlConsolePage, - )); - }, - ), - ListTile( - title: const Text('Sign Out'), - onTap: () async { - var navigator = Navigator.of(context); - navigator.pop(); - await logout(); + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouter); + + // Bridge riverpod session provider to the listenable that auto_route wants + // to re-evaluate route guards. + final sessionNotifier = useValueNotifier(ref.read(isLoggedInProvider)); + ref.listen(isLoggedInProvider, (prev, now) { + if (sessionNotifier.value != now) { + // Using Timer.run() here to work around an issue with auto_route during + // initialization. + Timer.run(() { + sessionNotifier.value = now; + }); + } + }); - navigator.pushReplacement(MaterialPageRoute( - builder: (context) => loginPage, - )); - }, - ), - ], - ), + return MaterialApp.router( + routerConfig: router.config( + reevaluateListenable: sessionNotifier, + ), + title: 'PowerSync Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, ), ); } diff --git a/demos/supabase-todolist-drift/lib/migrations/fts_setup.dart b/demos/supabase-todolist-drift/lib/migrations/fts_setup.dart deleted file mode 100644 index 9754e44e..00000000 --- a/demos/supabase-todolist-drift/lib/migrations/fts_setup.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:powersync/powersync.dart'; -import 'package:powersync/sqlite_async.dart'; - -import 'helpers.dart'; -import '../models/schema.dart'; - -final migrations = SqliteMigrations(); - -/// Create a Full Text Search table for the given table and columns -/// with an option to use a different tokenizer otherwise it defaults -/// to unicode61. It also creates the triggers that keep the FTS table -/// and the PowerSync table in sync. -SqliteMigration createFtsMigration( - {required int migrationVersion, - required String tableName, - required List columns, - String tokenizationMethod = 'unicode61'}) { - String internalName = - schema.tables.firstWhere((table) => table.name == tableName).internalName; - String stringColumns = columns.join(', '); - - return SqliteMigration(migrationVersion, (tx) async { - // Add FTS table - await tx.execute(''' - CREATE VIRTUAL TABLE IF NOT EXISTS fts_$tableName - USING fts5(id UNINDEXED, $stringColumns, tokenize='$tokenizationMethod'); - '''); - // Copy over records already in table - await tx.execute(''' - INSERT INTO fts_$tableName(rowid, id, $stringColumns) - SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM $internalName; - '''); - // Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table - await tx.execute(''' - CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_$tableName AFTER INSERT ON $internalName - BEGIN - INSERT INTO fts_$tableName(rowid, id, $stringColumns) - VALUES ( - NEW.rowid, - NEW.id, - ${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)} - ); - END; - '''); - await tx.execute(''' - CREATE TRIGGER IF NOT EXISTS fts_update_trigger_$tableName AFTER UPDATE ON $internalName BEGIN - UPDATE fts_$tableName - SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)} - WHERE rowid = NEW.rowid; - END; - '''); - await tx.execute(''' - CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_$tableName AFTER DELETE ON $internalName BEGIN - DELETE FROM fts_$tableName WHERE rowid = OLD.rowid; - END; - '''); - }); -} - -/// This is where you can add more migrations to generate FTS tables -/// that correspond to the tables in your schema and populate them -/// with the data you would like to search on -Future configureFts(PowerSyncDatabase db) async { - migrations - ..add(createFtsMigration( - migrationVersion: 1, - tableName: 'lists', - columns: ['name'], - tokenizationMethod: 'porter unicode61')) - ..add(createFtsMigration( - migrationVersion: 2, - tableName: 'todos', - columns: ['description', 'list_id'], - )); - await migrations.migrate(db); -} diff --git a/demos/supabase-todolist-drift/lib/migrations/helpers.dart b/demos/supabase-todolist-drift/lib/migrations/helpers.dart deleted file mode 100644 index c1a779e1..00000000 --- a/demos/supabase-todolist-drift/lib/migrations/helpers.dart +++ /dev/null @@ -1,38 +0,0 @@ -typedef ExtractGenerator = String Function(String, String); - -enum ExtractType { - columnOnly, - columnInOperation, -} - -typedef ExtractGeneratorMap = Map; - -String _createExtract(String jsonColumnName, String columnName) => - 'json_extract($jsonColumnName, \'\$.$columnName\')'; - -ExtractGeneratorMap extractGeneratorsMap = { - ExtractType.columnOnly: ( - String jsonColumnName, - String columnName, - ) => - _createExtract(jsonColumnName, columnName), - ExtractType.columnInOperation: ( - String jsonColumnName, - String columnName, - ) => - '$columnName = ${_createExtract(jsonColumnName, columnName)}', -}; - -String generateJsonExtracts( - ExtractType type, String jsonColumnName, List columns) { - ExtractGenerator? generator = extractGeneratorsMap[type]; - if (generator == null) { - throw StateError('Unexpected null generator for key: $type'); - } - - if (columns.length == 1) { - return generator(jsonColumnName, columns.first); - } - - return columns.map((column) => generator(jsonColumnName, column)).join(', '); -} diff --git a/demos/supabase-todolist-drift/lib/navigation.dart b/demos/supabase-todolist-drift/lib/navigation.dart new file mode 100644 index 00000000..6649a8d2 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/navigation.dart @@ -0,0 +1,82 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'navigation.gr.dart'; +import 'supabase.dart'; + +export 'navigation.gr.dart'; + +@AutoRouterConfig() +final class AppRouter extends RootStackRouter { + final _AuthGuard _authGuard; + + AppRouter(Ref ref) : _authGuard = _AuthGuard(ref); + + @override + RouteType get defaultRouteType => const RouteType.material(); + + @override + List get routes { + return [ + AutoRoute(page: LoginRoute.page), + AutoRoute(page: SignupRoute.page), + AutoRoute( + page: LoggedInRoot.page, + initial: true, + guards: [_authGuard], + children: [ + AutoRoute( + initial: true, + page: ListsRoute.page, + ), + _dialogRoute(AddListRoute.page), + AutoRoute(page: ListsDetailsRoute.page), + _dialogRoute(AddItemRoute.page), + AutoRoute(page: TakePhotoRoute.page), + AutoRoute(page: SqlConsoleRoute.page), + ], + ), + ]; + } + + static CustomRoute _dialogRoute(PageInfo page) { + return CustomRoute( + page: page, + customRouteBuilder: (context, child, page) { + return DialogRoute( + context: context, + builder: (_) => child, + settings: page, + ); + }, + ); + } +} + +@RoutePage(name: 'LoggedInRoot') +final class LoggedInContents extends StatelessWidget { + const LoggedInContents({super.key}); + + @override + Widget build(BuildContext context) { + return const AutoRouter(); + } +} + +final class _AuthGuard extends AutoRouteGuard { + final Ref _ref; + + _AuthGuard(this._ref); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) { + if (_ref.read(isLoggedInProvider)) { + resolver.next(true); + } else { + resolver.redirectUntil(const LoginRoute()); + } + } +} + +final appRouter = Provider((ref) => AppRouter(ref)); diff --git a/demos/supabase-todolist-drift/lib/navigation.gr.dart b/demos/supabase-todolist-drift/lib/navigation.gr.dart new file mode 100644 index 00000000..27b35a2d --- /dev/null +++ b/demos/supabase-todolist-drift/lib/navigation.gr.dart @@ -0,0 +1,241 @@ +// dart format width=80 +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// AutoRouterGenerator +// ************************************************************************** + +// ignore_for_file: type=lint +// coverage:ignore-file + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:auto_route/auto_route.dart' as _i10; +import 'package:camera/camera.dart' as _i12; +import 'package:flutter/material.dart' as _i11; +import 'package:supabase_todolist_drift/navigation.dart' as _i5; +import 'package:supabase_todolist_drift/screens/add_item_dialog.dart' as _i1; +import 'package:supabase_todolist_drift/screens/add_list_dialog.dart' as _i2; +import 'package:supabase_todolist_drift/screens/list_details.dart' as _i3; +import 'package:supabase_todolist_drift/screens/lists.dart' as _i4; +import 'package:supabase_todolist_drift/screens/login.dart' as _i6; +import 'package:supabase_todolist_drift/screens/signup.dart' as _i7; +import 'package:supabase_todolist_drift/screens/sql_console.dart' as _i8; +import 'package:supabase_todolist_drift/screens/take_photo.dart' as _i9; + +/// generated route for +/// [_i1.AddItemDialog] +class AddItemRoute extends _i10.PageRouteInfo { + AddItemRoute({ + _i11.Key? key, + required String list, + List<_i10.PageRouteInfo>? children, + }) : super( + AddItemRoute.name, + args: AddItemRouteArgs(key: key, list: list), + initialChildren: children, + ); + + static const String name = 'AddItemRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i1.AddItemDialog(key: args.key, list: args.list); + }, + ); +} + +class AddItemRouteArgs { + const AddItemRouteArgs({this.key, required this.list}); + + final _i11.Key? key; + + final String list; + + @override + String toString() { + return 'AddItemRouteArgs{key: $key, list: $list}'; + } +} + +/// generated route for +/// [_i2.AddListDialog] +class AddListRoute extends _i10.PageRouteInfo { + const AddListRoute({List<_i10.PageRouteInfo>? children}) + : super(AddListRoute.name, initialChildren: children); + + static const String name = 'AddListRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i2.AddListDialog(); + }, + ); +} + +/// generated route for +/// [_i3.ListsDetailsPage] +class ListsDetailsRoute extends _i10.PageRouteInfo { + ListsDetailsRoute({ + _i11.Key? key, + required String list, + List<_i10.PageRouteInfo>? children, + }) : super( + ListsDetailsRoute.name, + args: ListsDetailsRouteArgs(key: key, list: list), + initialChildren: children, + ); + + static const String name = 'ListsDetailsRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i3.ListsDetailsPage(key: args.key, list: args.list); + }, + ); +} + +class ListsDetailsRouteArgs { + const ListsDetailsRouteArgs({this.key, required this.list}); + + final _i11.Key? key; + + final String list; + + @override + String toString() { + return 'ListsDetailsRouteArgs{key: $key, list: $list}'; + } +} + +/// generated route for +/// [_i4.ListsPage] +class ListsRoute extends _i10.PageRouteInfo { + const ListsRoute({List<_i10.PageRouteInfo>? children}) + : super(ListsRoute.name, initialChildren: children); + + static const String name = 'ListsRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i4.ListsPage(); + }, + ); +} + +/// generated route for +/// [_i5.LoggedInContents] +class LoggedInRoot extends _i10.PageRouteInfo { + const LoggedInRoot({List<_i10.PageRouteInfo>? children}) + : super(LoggedInRoot.name, initialChildren: children); + + static const String name = 'LoggedInRoot'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i5.LoggedInContents(); + }, + ); +} + +/// generated route for +/// [_i6.LoginPage] +class LoginRoute extends _i10.PageRouteInfo { + const LoginRoute({List<_i10.PageRouteInfo>? children}) + : super(LoginRoute.name, initialChildren: children); + + static const String name = 'LoginRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i6.LoginPage(); + }, + ); +} + +/// generated route for +/// [_i7.SignupPage] +class SignupRoute extends _i10.PageRouteInfo { + const SignupRoute({List<_i10.PageRouteInfo>? children}) + : super(SignupRoute.name, initialChildren: children); + + static const String name = 'SignupRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i7.SignupPage(); + }, + ); +} + +/// generated route for +/// [_i8.SqlConsolePage] +class SqlConsoleRoute extends _i10.PageRouteInfo { + const SqlConsoleRoute({List<_i10.PageRouteInfo>? children}) + : super(SqlConsoleRoute.name, initialChildren: children); + + static const String name = 'SqlConsoleRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i8.SqlConsolePage(); + }, + ); +} + +/// generated route for +/// [_i9.TakePhotoPage] +class TakePhotoRoute extends _i10.PageRouteInfo { + TakePhotoRoute({ + _i11.Key? key, + required String todoId, + required _i12.CameraDescription camera, + List<_i10.PageRouteInfo>? children, + }) : super( + TakePhotoRoute.name, + args: TakePhotoRouteArgs(key: key, todoId: todoId, camera: camera), + initialChildren: children, + ); + + static const String name = 'TakePhotoRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i9.TakePhotoPage( + key: args.key, + todoId: args.todoId, + camera: args.camera, + ); + }, + ); +} + +class TakePhotoRouteArgs { + const TakePhotoRouteArgs({ + this.key, + required this.todoId, + required this.camera, + }); + + final _i11.Key? key; + + final String todoId; + + final _i12.CameraDescription camera; + + @override + String toString() { + return 'TakePhotoRouteArgs{key: $key, todoId: $todoId, camera: $camera}'; + } +} diff --git a/demos/supabase-todolist-drift/lib/attachments/queue.dart b/demos/supabase-todolist-drift/lib/powersync/attachments/queue.dart similarity index 82% rename from demos/supabase-todolist-drift/lib/attachments/queue.dart rename to demos/supabase-todolist-drift/lib/powersync/attachments/queue.dart index 63d903ac..63088735 100644 --- a/demos/supabase-todolist-drift/lib/attachments/queue.dart +++ b/demos/supabase-todolist-drift/lib/powersync/attachments/queue.dart @@ -1,14 +1,24 @@ import 'dart:async'; -import 'package:powersync/powersync.dart'; import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; -import 'package:supabase_todolist_drift/app_config.dart'; -import 'package:supabase_todolist_drift/attachments/remote_storage_adapter.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:supabase_todolist_drift/models/schema.dart'; +import '../../app_config.dart'; +import '../powersync.dart'; +import '../schema.dart'; +import 'remote_storage_adapter.dart'; + +part 'queue.g.dart'; + +@Riverpod(keepAlive: true) +Future attachmentQueue(Ref ref) async { + final db = await ref.read(powerSyncInstanceProvider.future); + final queue = PhotoAttachmentQueue(db, remoteStorage); + await queue.init(); + return queue; +} -/// Global reference to the queue -late final PhotoAttachmentQueue attachmentQueue; final remoteStorage = SupabaseStorageAdapter(); /// Function to handle errors when downloading attachments @@ -83,8 +93,3 @@ class PhotoAttachmentQueue extends AbstractAttachmentQueue { }); } } - -initializeAttachmentQueue(PowerSyncDatabase db) async { - attachmentQueue = PhotoAttachmentQueue(db, remoteStorage); - await attachmentQueue.init(); -} diff --git a/demos/supabase-todolist-drift/lib/powersync/attachments/queue.g.dart b/demos/supabase-todolist-drift/lib/powersync/attachments/queue.g.dart new file mode 100644 index 00000000..a9402d66 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/attachments/queue.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'queue.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$attachmentQueueHash() => r'353be28d71ad41994abf783776a99881e0b51383'; + +/// See also [attachmentQueue]. +@ProviderFor(attachmentQueue) +final attachmentQueueProvider = FutureProvider.internal( + attachmentQueue, + name: r'attachmentQueueProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$attachmentQueueHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AttachmentQueueRef = FutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/attachments/remote_storage_adapter.dart b/demos/supabase-todolist-drift/lib/powersync/attachments/remote_storage_adapter.dart similarity index 96% rename from demos/supabase-todolist-drift/lib/attachments/remote_storage_adapter.dart rename to demos/supabase-todolist-drift/lib/powersync/attachments/remote_storage_adapter.dart index d8383e69..336c9d10 100644 --- a/demos/supabase-todolist-drift/lib/attachments/remote_storage_adapter.dart +++ b/demos/supabase-todolist-drift/lib/powersync/attachments/remote_storage_adapter.dart @@ -1,10 +1,11 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; -import 'package:supabase_todolist_drift/app_config.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:image/image.dart' as img; +import '../../app_config.dart'; + class SupabaseStorageAdapter implements AbstractRemoteStorageAdapter { @override Future uploadFile(String filename, File file, diff --git a/demos/supabase-todolist-drift/lib/powersync.dart b/demos/supabase-todolist-drift/lib/powersync/connector.dart similarity index 62% rename from demos/supabase-todolist-drift/lib/powersync.dart rename to demos/supabase-todolist-drift/lib/powersync/connector.dart index 71a16c8d..e6bccc92 100644 --- a/demos/supabase-todolist-drift/lib/powersync.dart +++ b/demos/supabase-todolist-drift/lib/powersync/connector.dart @@ -1,18 +1,10 @@ -// This file performs setup of the PowerSync database -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:powersync/powersync.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/migrations/fts_setup.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'app_config.dart'; -import 'models/schema.dart'; -import 'supabase.dart'; +import '../app_config.dart'; -final log = Logger('powersync-supabase'); +final _log = Logger('powersync-supabase'); /// Postgres Response codes that we cannot recover from by retrying. final List fatalResponseCodes = [ @@ -121,7 +113,7 @@ class SupabaseConnector extends PowerSyncBackendConnector { /// Note that these errors typically indicate a bug in the application. /// If protecting against data loss is important, save the failing records /// elsewhere instead of discarding, and/or notify the user. - log.severe('Data upload error - discarding $lastOp', e); + _log.severe('Data upload error - discarding $lastOp', e); await transaction.complete(); } else { // Error may be retryable - e.g. network error or temporary server error. @@ -131,72 +123,3 @@ class SupabaseConnector extends PowerSyncBackendConnector { } } } - -/// Global reference to the database -late final PowerSyncDatabase db; -late final AppDatabase appDb; - -bool isLoggedIn() { - return Supabase.instance.client.auth.currentSession?.accessToken != null; -} - -/// id of the user currently logged in -String? getUserId() { - return Supabase.instance.client.auth.currentSession?.user.id; -} - -Future getDatabasePath() async { - const dbFilename = 'powersync-demo.db'; - // getApplicationSupportDirectory is not supported on Web - if (kIsWeb) { - return dbFilename; - } - final dir = await getApplicationSupportDirectory(); - return join(dir.path, dbFilename); -} - -Future openDatabase() async { - // Open the local database - db = PowerSyncDatabase( - schema: schema, path: await getDatabasePath(), logger: attachedLogger); - await db.initialize(); - // Initialize the Drift database - appDb = AppDatabase(db); - - await loadSupabase(); - - SupabaseConnector? currentConnector; - - if (isLoggedIn()) { - // If the user is already logged in, connect immediately. - // Otherwise, connect once logged in. - currentConnector = SupabaseConnector(); - db.connect(connector: currentConnector); - } - - Supabase.instance.client.auth.onAuthStateChange.listen((data) async { - final AuthChangeEvent event = data.event; - if (event == AuthChangeEvent.signedIn) { - // Connect to PowerSync when the user is signed in - currentConnector = SupabaseConnector(); - db.connect(connector: currentConnector!); - } else if (event == AuthChangeEvent.signedOut) { - // Implicit sign out - disconnect, but don't delete data - currentConnector = null; - await db.disconnect(); - } else if (event == AuthChangeEvent.tokenRefreshed) { - // Supabase token refreshed - trigger token refresh for PowerSync. - currentConnector?.prefetchCredentials(); - } - }); - - // Demo using SQLite Full-Text Search with PowerSync. - // See https://docs.powersync.com/usage-examples/full-text-search for more details - await configureFts(db); -} - -/// Explicit sign out - clear database and log out. -Future logout() async { - await Supabase.instance.client.auth.signOut(); - await db.disconnectAndClear(); -} diff --git a/demos/supabase-todolist-drift/lib/powersync/database.dart b/demos/supabase-todolist-drift/lib/powersync/database.dart new file mode 100644 index 00000000..a135ffd7 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/database.dart @@ -0,0 +1,104 @@ +import 'package:drift/drift.dart'; +import 'package:drift_sqlite_async/drift_sqlite_async.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:powersync/powersync.dart' show uuid; + +import 'fts5.dart'; +import 'powersync.dart'; + +part 'database.g.dart'; + +class TodoItems extends Table { + @override + String get tableName => 'todos'; + + TextColumn get id => text().clientDefault(() => uuid.v4())(); + TextColumn get listId => text().named('list_id').references(ListItems, #id)(); + TextColumn get photoId => text().nullable().named('photo_id')(); + DateTimeColumn get createdAt => dateTime().nullable().named('created_at')(); + DateTimeColumn get completedAt => + dateTime().nullable().named('completed_at')(); + BoolColumn get completed => boolean().nullable()(); + TextColumn get description => text()(); + TextColumn get createdBy => text().nullable().named('created_by')(); + TextColumn get completedBy => text().nullable().named('completed_by')(); +} + +class ListItems extends Table { + @override + String get tableName => 'lists'; + + TextColumn get id => text().clientDefault(() => uuid.v4())(); + DateTimeColumn get createdAt => + dateTime().named('created_at').clientDefault(() => DateTime.now())(); + TextColumn get name => text()(); + TextColumn get ownerId => text().nullable().named('owner_id')(); +} + +final class ListItemWithStats { + final ListItem self; + final int completedCount; + final int pendingCount; + + const ListItemWithStats( + this.self, + this.completedCount, + this.pendingCount, + ); +} + +@DriftDatabase( + tables: [TodoItems, ListItems], + include: {'queries.drift'}, +) +class AppDatabase extends _$AppDatabase { + AppDatabase(super.e); + + @override + int get schemaVersion => 2; + + @override + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (m) async { + // We don't have to call createAll(), PowerSync instantiates the schema + // for us. We can use the opportunity to create fts5 indexes though. + await createFts5Tables( + db: this, + tableName: 'lists', + columns: ['name'], + ); + await createFts5Tables( + db: this, + tableName: 'todos', + columns: ['description', 'list_id'], + ); + }, + onUpgrade: (m, from, to) async { + if (from == 1) { + await createFts5Tables( + db: this, + tableName: 'todos', + columns: ['description', 'list_id'], + ); + } + }, + ); + } + + Future addTodoPhoto(String todoId, String photoId) async { + await (update(todoItems)..where((t) => t.id.equals(todoId))) + .write(TodoItemsCompanion(photoId: Value(photoId))); + } + + Future findList(String id) { + return (select(listItems)..where((t) => t.id.equals(id))).getSingle(); + } +} + +final driftDatabase = Provider((ref) { + return AppDatabase(DatabaseConnection.delayed(Future(() async { + final database = await ref.read(powerSyncInstanceProvider.future); + return SqliteAsyncDriftConnection(database); + }))); +}); diff --git a/demos/supabase-todolist-drift/lib/database.g.dart b/demos/supabase-todolist-drift/lib/powersync/database.g.dart similarity index 78% rename from demos/supabase-todolist-drift/lib/database.g.dart rename to demos/supabase-todolist-drift/lib/powersync/database.g.dart index 438ea554..c709dc46 100644 --- a/demos/supabase-todolist-drift/lib/database.g.dart +++ b/demos/supabase-todolist-drift/lib/powersync/database.g.dart @@ -814,7 +814,7 @@ final class $$ListItemsTableReferences $$TodoItemsTableProcessedTableManager get todoItemsRefs { final manager = $$TodoItemsTableTableManager($_db, $_db.todoItems) - .filter((f) => f.listId.id($_item.id)); + .filter((f) => f.listId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_todoItemsRefsTable($_db)); return ProcessedTableManager( @@ -823,64 +823,111 @@ final class $$ListItemsTableReferences } class $$ListItemsTableFilterComposer - extends FilterComposer<_$AppDatabase, $ListItemsTable> { - $$ListItemsTableFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get ownerId => $state.composableBuilder( - column: $state.table.ownerId, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ComposableFilter todoItemsRefs( - ComposableFilter Function($$TodoItemsTableFilterComposer f) f) { - final $$TodoItemsTableFilterComposer composer = $state.composerBuilder( + extends Composer<_$AppDatabase, $ListItemsTable> { + $$ListItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnFilters get ownerId => $composableBuilder( + column: $table.ownerId, builder: (column) => ColumnFilters(column)); + + Expression todoItemsRefs( + Expression Function($$TodoItemsTableFilterComposer f) f) { + final $$TodoItemsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $state.db.todoItems, + referencedTable: $db.todoItems, getReferencedColumn: (t) => t.listId, - builder: (joinBuilder, parentComposers) => - $$TodoItemsTableFilterComposer(ComposerState( - $state.db, $state.db.todoItems, joinBuilder, parentComposers))); + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$TodoItemsTableFilterComposer( + $db: $db, + $table: $db.todoItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); return f(composer); } } class $$ListItemsTableOrderingComposer - extends OrderingComposer<_$AppDatabase, $ListItemsTable> { - $$ListItemsTableOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get ownerId => $state.composableBuilder( - column: $state.table.ownerId, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + extends Composer<_$AppDatabase, $ListItemsTable> { + $$ListItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get ownerId => $composableBuilder( + column: $table.ownerId, builder: (column) => ColumnOrderings(column)); +} + +class $$ListItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $ListItemsTable> { + $$ListItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get ownerId => + $composableBuilder(column: $table.ownerId, builder: (column) => column); + + Expression todoItemsRefs( + Expression Function($$TodoItemsTableAnnotationComposer a) f) { + final $$TodoItemsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.todoItems, + getReferencedColumn: (t) => t.listId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$TodoItemsTableAnnotationComposer( + $db: $db, + $table: $db.todoItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } } class $$ListItemsTableTableManager extends RootTableManager< @@ -889,6 +936,7 @@ class $$ListItemsTableTableManager extends RootTableManager< ListItem, $$ListItemsTableFilterComposer, $$ListItemsTableOrderingComposer, + $$ListItemsTableAnnotationComposer, $$ListItemsTableCreateCompanionBuilder, $$ListItemsTableUpdateCompanionBuilder, (ListItem, $$ListItemsTableReferences), @@ -898,10 +946,12 @@ class $$ListItemsTableTableManager extends RootTableManager< : super(TableManagerState( db: db, table: table, - filteringComposer: - $$ListItemsTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $$ListItemsTableOrderingComposer(ComposerState(db, table)), + createFilteringComposer: () => + $$ListItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ListItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ListItemsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value createdAt = const Value.absent(), @@ -968,6 +1018,7 @@ typedef $$ListItemsTableProcessedTableManager = ProcessedTableManager< ListItem, $$ListItemsTableFilterComposer, $$ListItemsTableOrderingComposer, + $$ListItemsTableAnnotationComposer, $$ListItemsTableCreateCompanionBuilder, $$ListItemsTableUpdateCompanionBuilder, (ListItem, $$ListItemsTableReferences), @@ -1005,10 +1056,11 @@ final class $$TodoItemsTableReferences static $ListItemsTable _listIdTable(_$AppDatabase db) => db.listItems .createAlias($_aliasNameGenerator(db.todoItems.listId, db.listItems.id)); - $$ListItemsTableProcessedTableManager? get listId { - if ($_item.listId == null) return null; + $$ListItemsTableProcessedTableManager get listId { + final $_column = $_itemColumn('list_id')!; + final manager = $$ListItemsTableTableManager($_db, $_db.listItems) - .filter((f) => f.id($_item.listId!)); + .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_listIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -1017,113 +1069,163 @@ final class $$TodoItemsTableReferences } class $$TodoItemsTableFilterComposer - extends FilterComposer<_$AppDatabase, $TodoItemsTable> { - $$TodoItemsTableFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get photoId => $state.composableBuilder( - column: $state.table.photoId, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get completedAt => $state.composableBuilder( - column: $state.table.completedAt, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get completed => $state.composableBuilder( - column: $state.table.completed, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get description => $state.composableBuilder( - column: $state.table.description, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get createdBy => $state.composableBuilder( - column: $state.table.createdBy, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get completedBy => $state.composableBuilder( - column: $state.table.completedBy, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get photoId => $composableBuilder( + column: $table.photoId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get completedAt => $composableBuilder( + column: $table.completedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get completed => $composableBuilder( + column: $table.completed, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdBy => $composableBuilder( + column: $table.createdBy, builder: (column) => ColumnFilters(column)); + + ColumnFilters get completedBy => $composableBuilder( + column: $table.completedBy, builder: (column) => ColumnFilters(column)); $$ListItemsTableFilterComposer get listId { - final $$ListItemsTableFilterComposer composer = $state.composerBuilder( + final $$ListItemsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.listId, - referencedTable: $state.db.listItems, + referencedTable: $db.listItems, getReferencedColumn: (t) => t.id, - builder: (joinBuilder, parentComposers) => - $$ListItemsTableFilterComposer(ComposerState( - $state.db, $state.db.listItems, joinBuilder, parentComposers))); + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ListItemsTableFilterComposer( + $db: $db, + $table: $db.listItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); return composer; } } class $$TodoItemsTableOrderingComposer - extends OrderingComposer<_$AppDatabase, $TodoItemsTable> { - $$TodoItemsTableOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get photoId => $state.composableBuilder( - column: $state.table.photoId, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get completedAt => $state.composableBuilder( - column: $state.table.completedAt, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get completed => $state.composableBuilder( - column: $state.table.completed, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get description => $state.composableBuilder( - column: $state.table.description, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get createdBy => $state.composableBuilder( - column: $state.table.createdBy, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get completedBy => $state.composableBuilder( - column: $state.table.completedBy, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get photoId => $composableBuilder( + column: $table.photoId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get completedAt => $composableBuilder( + column: $table.completedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get completed => $composableBuilder( + column: $table.completed, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdBy => $composableBuilder( + column: $table.createdBy, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get completedBy => $composableBuilder( + column: $table.completedBy, builder: (column) => ColumnOrderings(column)); $$ListItemsTableOrderingComposer get listId { - final $$ListItemsTableOrderingComposer composer = $state.composerBuilder( + final $$ListItemsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.listId, - referencedTable: $state.db.listItems, + referencedTable: $db.listItems, getReferencedColumn: (t) => t.id, - builder: (joinBuilder, parentComposers) => - $$ListItemsTableOrderingComposer(ComposerState( - $state.db, $state.db.listItems, joinBuilder, parentComposers))); + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ListItemsTableOrderingComposer( + $db: $db, + $table: $db.listItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$TodoItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get photoId => + $composableBuilder(column: $table.photoId, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get completedAt => $composableBuilder( + column: $table.completedAt, builder: (column) => column); + + GeneratedColumn get completed => + $composableBuilder(column: $table.completed, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); + + GeneratedColumn get createdBy => + $composableBuilder(column: $table.createdBy, builder: (column) => column); + + GeneratedColumn get completedBy => $composableBuilder( + column: $table.completedBy, builder: (column) => column); + + $$ListItemsTableAnnotationComposer get listId { + final $$ListItemsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.listId, + referencedTable: $db.listItems, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ListItemsTableAnnotationComposer( + $db: $db, + $table: $db.listItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); return composer; } } @@ -1134,6 +1236,7 @@ class $$TodoItemsTableTableManager extends RootTableManager< TodoItem, $$TodoItemsTableFilterComposer, $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, $$TodoItemsTableCreateCompanionBuilder, $$TodoItemsTableUpdateCompanionBuilder, (TodoItem, $$TodoItemsTableReferences), @@ -1143,10 +1246,12 @@ class $$TodoItemsTableTableManager extends RootTableManager< : super(TableManagerState( db: db, table: table, - filteringComposer: - $$TodoItemsTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $$TodoItemsTableOrderingComposer(ComposerState(db, table)), + createFilteringComposer: () => + $$TodoItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TodoItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TodoItemsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value listId = const Value.absent(), @@ -1216,6 +1321,7 @@ class $$TodoItemsTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, + dynamic, dynamic>>(state) { if (listId) { state = state.withJoin( @@ -1244,6 +1350,7 @@ typedef $$TodoItemsTableProcessedTableManager = ProcessedTableManager< TodoItem, $$TodoItemsTableFilterComposer, $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, $$TodoItemsTableCreateCompanionBuilder, $$TodoItemsTableUpdateCompanionBuilder, (TodoItem, $$TodoItemsTableReferences), diff --git a/demos/supabase-todolist-drift/lib/powersync/fts5.dart b/demos/supabase-todolist-drift/lib/powersync/fts5.dart new file mode 100644 index 00000000..85409035 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/fts5.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; + +import 'schema.dart'; + +Future createFts5Tables({ + required DatabaseConnectionUser db, + required String tableName, + required List columns, + String tokenizationMethod = 'unicode61', +}) async { + String internalName = + schema.tables.firstWhere((table) => table.name == tableName).internalName; + String stringColumns = columns.join(', '); + + await db.customStatement(''' + CREATE VIRTUAL TABLE IF NOT EXISTS fts_$tableName + USING fts5(id UNINDEXED, $stringColumns, tokenize='$tokenizationMethod'); + '''); + // Copy over records already in table + await db.customStatement(''' + INSERT INTO fts_$tableName(rowid, id, $stringColumns) + SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM $internalName; + '''); + // Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table + await db.customStatement(''' + CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_$tableName AFTER INSERT ON $internalName + BEGIN + INSERT INTO fts_$tableName(rowid, id, $stringColumns) + VALUES ( + NEW.rowid, + NEW.id, + ${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)} + ); + END; + '''); + await db.customStatement(''' + CREATE TRIGGER IF NOT EXISTS fts_update_trigger_$tableName AFTER UPDATE ON $internalName BEGIN + UPDATE fts_$tableName + SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)} + WHERE rowid = NEW.rowid; + END; + '''); + await db.customStatement(''' + CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_$tableName AFTER DELETE ON $internalName BEGIN + DELETE FROM fts_$tableName WHERE rowid = OLD.rowid; + END; + '''); +} + +typedef ExtractGenerator = String Function(String, String); + +enum ExtractType { + columnOnly, + columnInOperation, +} + +typedef ExtractGeneratorMap = Map; + +String _createExtract(String jsonColumnName, String columnName) => + 'json_extract($jsonColumnName, \'\$.$columnName\')'; + +ExtractGeneratorMap extractGeneratorsMap = { + ExtractType.columnOnly: ( + String jsonColumnName, + String columnName, + ) => + _createExtract(jsonColumnName, columnName), + ExtractType.columnInOperation: ( + String jsonColumnName, + String columnName, + ) => + '$columnName = ${_createExtract(jsonColumnName, columnName)}', +}; + +String generateJsonExtracts( + ExtractType type, String jsonColumnName, List columns) { + ExtractGenerator? generator = extractGeneratorsMap[type]; + if (generator == null) { + throw StateError('Unexpected null generator for key: $type'); + } + + if (columns.length == 1) { + return generator(jsonColumnName, columns.first); + } + + return columns.map((column) => generator(jsonColumnName, column)).join(', '); +} diff --git a/demos/supabase-todolist-drift/lib/fts_helpers.dart b/demos/supabase-todolist-drift/lib/powersync/fts_helpers.dart similarity index 66% rename from demos/supabase-todolist-drift/lib/fts_helpers.dart rename to demos/supabase-todolist-drift/lib/powersync/fts_helpers.dart index 5e5ad5c3..f9e898a0 100644 --- a/demos/supabase-todolist-drift/lib/fts_helpers.dart +++ b/demos/supabase-todolist-drift/lib/powersync/fts_helpers.dart @@ -1,4 +1,9 @@ -import 'package:supabase_todolist_drift/powersync.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'powersync.dart'; + +part 'fts_helpers.g.dart'; String _createSearchTermWithOptions(String searchTerm) { // adding * to the end of the search term will match any word that starts with the search term @@ -9,8 +14,10 @@ String _createSearchTermWithOptions(String searchTerm) { } /// Search the FTS table for the given searchTerm -Future search(String searchTerm, String tableName) async { +@riverpod +Future search(Ref ref, String searchTerm, String tableName) async { String searchTermWithOptions = _createSearchTermWithOptions(searchTerm); + final db = await ref.read(powerSyncInstanceProvider.future); return await db.getAll( 'SELECT * FROM fts_$tableName WHERE fts_$tableName MATCH ? ORDER BY rank', [searchTermWithOptions]); diff --git a/demos/supabase-todolist-drift/lib/powersync/fts_helpers.g.dart b/demos/supabase-todolist-drift/lib/powersync/fts_helpers.g.dart new file mode 100644 index 00000000..8c0efb6f --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/fts_helpers.g.dart @@ -0,0 +1,188 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'fts_helpers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$searchHash() => r'44beab2ea36342be88731c46c2988e76058e7fe2'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Search the FTS table for the given searchTerm +/// +/// Copied from [search]. +@ProviderFor(search) +const searchProvider = SearchFamily(); + +/// Search the FTS table for the given searchTerm +/// +/// Copied from [search]. +class SearchFamily extends Family> { + /// Search the FTS table for the given searchTerm + /// + /// Copied from [search]. + const SearchFamily(); + + /// Search the FTS table for the given searchTerm + /// + /// Copied from [search]. + SearchProvider call( + String searchTerm, + String tableName, + ) { + return SearchProvider( + searchTerm, + tableName, + ); + } + + @override + SearchProvider getProviderOverride( + covariant SearchProvider provider, + ) { + return call( + provider.searchTerm, + provider.tableName, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'searchProvider'; +} + +/// Search the FTS table for the given searchTerm +/// +/// Copied from [search]. +class SearchProvider extends AutoDisposeFutureProvider { + /// Search the FTS table for the given searchTerm + /// + /// Copied from [search]. + SearchProvider( + String searchTerm, + String tableName, + ) : this._internal( + (ref) => search( + ref as SearchRef, + searchTerm, + tableName, + ), + from: searchProvider, + name: r'searchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchHash, + dependencies: SearchFamily._dependencies, + allTransitiveDependencies: SearchFamily._allTransitiveDependencies, + searchTerm: searchTerm, + tableName: tableName, + ); + + SearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchTerm, + required this.tableName, + }) : super.internal(); + + final String searchTerm; + final String tableName; + + @override + Override overrideWith( + FutureOr Function(SearchRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SearchProvider._internal( + (ref) => create(ref as SearchRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchTerm: searchTerm, + tableName: tableName, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SearchProvider && + other.searchTerm == searchTerm && + other.tableName == tableName; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchTerm.hashCode); + hash = _SystemHash.combine(hash, tableName.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SearchRef on AutoDisposeFutureProviderRef { + /// The parameter `searchTerm` of this provider. + String get searchTerm; + + /// The parameter `tableName` of this provider. + String get tableName; +} + +class _SearchProviderElement extends AutoDisposeFutureProviderElement + with SearchRef { + _SearchProviderElement(super.provider); + + @override + String get searchTerm => (origin as SearchProvider).searchTerm; + @override + String get tableName => (origin as SearchProvider).tableName; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/powersync/powersync.dart b/demos/supabase-todolist-drift/lib/powersync/powersync.dart new file mode 100644 index 00000000..cfd73336 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/powersync.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:powersync/powersync.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../supabase.dart'; +import 'connector.dart'; +import 'schema.dart'; + +part 'powersync.g.dart'; + +@Riverpod(keepAlive: true) +Future powerSyncInstance(Ref ref) async { + final db = PowerSyncDatabase( + schema: schema, + path: await _getDatabasePath(), + logger: attachedLogger, + ); + await db.initialize(); + + SupabaseConnector? currentConnector; + if (ref.read(sessionProvider).value != null) { + currentConnector = SupabaseConnector(); + db.connect(connector: currentConnector); + } + + final instance = Supabase.instance.client.auth; + final sub = instance.onAuthStateChange.listen((data) async { + final event = data.event; + if (event == AuthChangeEvent.signedIn) { + currentConnector = SupabaseConnector(); + db.connect(connector: currentConnector!); + } else if (event == AuthChangeEvent.signedOut) { + currentConnector = null; + await db.disconnect(); + } else if (event == AuthChangeEvent.tokenRefreshed) { + currentConnector?.prefetchCredentials(); + } + }); + ref.onDispose(sub.cancel); + ref.onDispose(db.close); + + return db; +} + +final _syncStatusInternal = StreamProvider((ref) { + return Stream.fromFuture( + ref.watch(powerSyncInstanceProvider.future), + ).asyncExpand((db) => db.statusStream).startWith(const SyncStatus()); +}); + +final syncStatus = Provider((ref) { + return ref.watch(_syncStatusInternal).value ?? const SyncStatus(); +}); + +@riverpod +bool didCompleteSync(Ref ref, [BucketPriority? priority]) { + final status = ref.watch(syncStatus); + if (priority != null) { + return status.statusForPriority(priority).hasSynced ?? false; + } else { + return status.hasSynced ?? false; + } +} + +Future _getDatabasePath() async { + const dbFilename = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (kIsWeb) { + return dbFilename; + } + final dir = await getApplicationSupportDirectory(); + return join(dir.path, dbFilename); +} diff --git a/demos/supabase-todolist-drift/lib/powersync/powersync.g.dart b/demos/supabase-todolist-drift/lib/powersync/powersync.g.dart new file mode 100644 index 00000000..8a0f6344 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/powersync/powersync.g.dart @@ -0,0 +1,177 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'powersync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$powerSyncInstanceHash() => r'd4ccd204e3e5b32f7e6111601de19179cbdd9f41'; + +/// See also [powerSyncInstance]. +@ProviderFor(powerSyncInstance) +final powerSyncInstanceProvider = FutureProvider.internal( + powerSyncInstance, + name: r'powerSyncInstanceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$powerSyncInstanceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef PowerSyncInstanceRef = FutureProviderRef; +String _$didCompleteSyncHash() => r'532f9cd620c43578b58452907e2165eba6745c21'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [didCompleteSync]. +@ProviderFor(didCompleteSync) +const didCompleteSyncProvider = DidCompleteSyncFamily(); + +/// See also [didCompleteSync]. +class DidCompleteSyncFamily extends Family { + /// See also [didCompleteSync]. + const DidCompleteSyncFamily(); + + /// See also [didCompleteSync]. + DidCompleteSyncProvider call([ + BucketPriority? priority, + ]) { + return DidCompleteSyncProvider( + priority, + ); + } + + @override + DidCompleteSyncProvider getProviderOverride( + covariant DidCompleteSyncProvider provider, + ) { + return call( + provider.priority, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'didCompleteSyncProvider'; +} + +/// See also [didCompleteSync]. +class DidCompleteSyncProvider extends AutoDisposeProvider { + /// See also [didCompleteSync]. + DidCompleteSyncProvider([ + BucketPriority? priority, + ]) : this._internal( + (ref) => didCompleteSync( + ref as DidCompleteSyncRef, + priority, + ), + from: didCompleteSyncProvider, + name: r'didCompleteSyncProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$didCompleteSyncHash, + dependencies: DidCompleteSyncFamily._dependencies, + allTransitiveDependencies: + DidCompleteSyncFamily._allTransitiveDependencies, + priority: priority, + ); + + DidCompleteSyncProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.priority, + }) : super.internal(); + + final BucketPriority? priority; + + @override + Override overrideWith( + bool Function(DidCompleteSyncRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: DidCompleteSyncProvider._internal( + (ref) => create(ref as DidCompleteSyncRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + priority: priority, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _DidCompleteSyncProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is DidCompleteSyncProvider && other.priority == priority; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, priority.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin DidCompleteSyncRef on AutoDisposeProviderRef { + /// The parameter `priority` of this provider. + BucketPriority? get priority; +} + +class _DidCompleteSyncProviderElement extends AutoDisposeProviderElement + with DidCompleteSyncRef { + _DidCompleteSyncProviderElement(super.provider); + + @override + BucketPriority? get priority => (origin as DidCompleteSyncProvider).priority; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/queries.drift b/demos/supabase-todolist-drift/lib/powersync/queries.drift similarity index 100% rename from demos/supabase-todolist-drift/lib/queries.drift rename to demos/supabase-todolist-drift/lib/powersync/queries.drift diff --git a/demos/supabase-todolist-drift/lib/models/schema.dart b/demos/supabase-todolist-drift/lib/powersync/schema.dart similarity index 100% rename from demos/supabase-todolist-drift/lib/models/schema.dart rename to demos/supabase-todolist-drift/lib/powersync/schema.dart diff --git a/demos/supabase-todolist-drift/lib/screens/add_item_dialog.dart b/demos/supabase-todolist-drift/lib/screens/add_item_dialog.dart new file mode 100644 index 00000000..2e3c24af --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/add_item_dialog.dart @@ -0,0 +1,52 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../stores/items.dart'; + +@RoutePage(name: 'AddItemRoute') +final class AddItemDialog extends HookConsumerWidget { + final String list; + + const AddItemDialog({super.key, required this.list}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + + Future add() async { + await ref + .read(itemsNotifierProvider(list).notifier) + .addItem(controller.text); + if (context.mounted) { + context.pop(); + } + } + + return AlertDialog( + title: const Text('Add a new todo item'), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'Type your new todo'), + onSubmitted: (value) { + add(); + }, + autofocus: true, + ), + actions: [ + OutlinedButton( + child: const Text('Cancel'), + onPressed: () { + controller.clear(); + Navigator.of(context).pop(); + }, + ), + ElevatedButton( + onPressed: add, + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/add_list_dialog.dart b/demos/supabase-todolist-drift/lib/screens/add_list_dialog.dart new file mode 100644 index 00000000..b0e35647 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/add_list_dialog.dart @@ -0,0 +1,52 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../stores/lists.dart'; + +@RoutePage(name: 'AddListRoute') +final class AddListDialog extends HookConsumerWidget { + const AddListDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textController = useTextEditingController(); + + Future add() async { + await ref + .read(listsNotifierProvider.notifier) + .createNewList(textController.text); + if (context.mounted) { + context.pop(); + } + } + + return AlertDialog( + title: const Text('Add a new list'), + content: TextField( + controller: textController, + decoration: const InputDecoration(hintText: 'List name'), + onSubmitted: (value) async { + await add(); + }, + autofocus: true, + ), + actions: [ + OutlinedButton( + child: const Text('Cancel'), + onPressed: () { + textController.clear(); + context.pop(); + }, + ), + ElevatedButton( + child: const Text('Create'), + onPressed: () async { + await add(); + }, + ), + ], + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/list_details.dart b/demos/supabase-todolist-drift/lib/screens/list_details.dart new file mode 100644 index 00000000..fbde5109 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/list_details.dart @@ -0,0 +1,116 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../app_config.dart'; +import '../components/page_layout.dart'; +import '../components/photo_widget.dart'; +import '../navigation.dart'; +import '../powersync/database.dart'; +import '../stores/items.dart'; + +@RoutePage() +final class ListsDetailsPage extends ConsumerWidget { + final String list; + + const ListsDetailsPage({super.key, required this.list}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PageLayout( + title: const Text('Todo List'), + showDrawer: false, + content: _ItemsInListWidget(list: list), + floatingActionButton: FloatingActionButton( + onPressed: () { + context.pushRoute(AddItemRoute(list: list)); + }, + tooltip: 'Add new item', + child: const Icon(Icons.add), + ), + ); + } +} + +final class _ItemsInListWidget extends ConsumerWidget { + final String list; + + const _ItemsInListWidget({required this.list}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(itemsNotifierProvider(list)); + + return items.maybeWhen( + data: (items) => ListView( + padding: const EdgeInsets.symmetric(vertical: 8.0), + children: items.map((todo) { + return _TodoItemWidget( + todo: todo, + ); + }).toList(), + ), + orElse: () => const CircularProgressIndicator(), + ); + } +} + +final class _TodoItemWidget extends ConsumerWidget { + _TodoItemWidget({ + required this.todo, + }) : super(key: ObjectKey(todo.id)); + + final TodoItem todo; + + TextStyle? _getTextStyle(bool checked) { + if (!checked) return null; + + return const TextStyle( + color: Colors.black54, + decoration: TextDecoration.lineThrough, + ); + } + + Future deleteTodo(WidgetRef ref) async { + await ref + .read(itemsNotifierProvider(todo.listId).notifier) + .deleteItem(todo); + } + + Future toggleTodo(WidgetRef ref) async { + await ref + .read(itemsNotifierProvider(todo.listId).notifier) + .toggleTodo(todo); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + onTap: () => toggleTodo(ref), + leading: Checkbox( + value: todo.completed, + onChanged: (_) => toggleTodo(ref), + ), + title: Row( + children: [ + Expanded( + child: Text(todo.description, + style: _getTextStyle(todo.completed == true))), + IconButton( + iconSize: 30, + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + alignment: Alignment.centerRight, + onPressed: () async => await deleteTodo(ref), + tooltip: 'Delete Item', + ), + AppConfig.supabaseStorageBucket.isEmpty + ? Container() + : PhotoWidget(todo: todo), + ], + ), + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/lists.dart b/demos/supabase-todolist-drift/lib/screens/lists.dart new file mode 100644 index 00000000..ebaa0857 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/lists.dart @@ -0,0 +1,108 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:powersync/powersync.dart' hide Column; + +import '../components/page_layout.dart'; +import '../navigation.dart'; +import '../powersync/database.dart'; +import '../powersync/powersync.dart'; +import '../stores/lists.dart'; + +@RoutePage() +final class ListsPage extends ConsumerWidget { + const ListsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PageLayout( + title: const Text('Todo Lists'), + content: const _ListsWidget(), + floatingActionButton: FloatingActionButton( + onPressed: () { + context.pushRoute(const AddListRoute()); + }, + tooltip: 'Create List', + child: const Icon(Icons.add), + ), + ); + } +} + +final class _ListsWidget extends ConsumerWidget { + const _ListsWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final lists = ref.watch(listsNotifierProvider); + final didSync = ref.watch(didCompleteSyncProvider(BucketPriority(1))); + + if (!didSync) { + return const Text('Busy with sync...'); + } + + return lists.map( + data: (data) { + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8.0), + children: data.value.map((list) { + return ListItemWidget(list: list); + }).toList(), + ); + }, + error: (_) => const Text('Error loading lists'), + loading: (_) => const CircularProgressIndicator(), + ); + } +} + +class ListItemWidget extends ConsumerWidget { + ListItemWidget({ + required this.list, + }) : super(key: ObjectKey(list)); + + final ListItemWithStats list; + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future delete() async { + await ref.read(listsNotifierProvider.notifier).deleteList(list.self.id); + } + + void viewList() { + context.pushRoute(ListsDetailsRoute(list: list.self.id)); + } + + final subtext = + '${list.pendingCount} pending, ${list.completedCount} completed'; + + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: viewList, + leading: const Icon(Icons.list), + title: Text(list.self.name), + subtitle: Text(subtext)), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + iconSize: 30, + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + tooltip: 'Delete List', + alignment: Alignment.centerRight, + onPressed: delete, + ), + const SizedBox(width: 8), + ], + ), + ], + ), + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/login.dart b/demos/supabase-todolist-drift/lib/screens/login.dart new file mode 100644 index 00000000..15eea101 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/login.dart @@ -0,0 +1,84 @@ +import 'package:auto_route/annotations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../components/app_bar.dart'; +import '../navigation.dart'; +import '../supabase.dart'; + +@RoutePage() +final class LoginPage extends HookConsumerWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usernameController = useTextEditingController(); + final passwordController = useTextEditingController(); + final (:error, :isBusy) = ref.watch(authNotifierProvider); + + final loginAction = isBusy + ? null + : () { + ref + .read(authNotifierProvider.notifier) + .login(usernameController.text, passwordController.text); + }; + + return Scaffold( + appBar: appBar, + body: SingleChildScrollView( + child: Container( + margin: const EdgeInsets.all(30), + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 15), + child: Text('Supabase Signup'), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + controller: usernameController, + decoration: const InputDecoration(labelText: "Email"), + enabled: !isBusy, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + obscureText: true, + controller: passwordController, + decoration: InputDecoration( + labelText: "Password", errorText: error), + enabled: !isBusy, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 15), + child: TextButton( + onPressed: loginAction, + child: const Text('Login'), + ), + ), + TextButton( + onPressed: isBusy + ? null + : () { + ref.read(appRouter).replace(const SignupRoute()); + }, + child: const Text('Sign Up'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/search.dart b/demos/supabase-todolist-drift/lib/screens/search.dart new file mode 100644 index 00000000..dce33806 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/search.dart @@ -0,0 +1,120 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../navigation.dart'; +import '../powersync/database.dart'; +import '../powersync/fts_helpers.dart' as fts_helpers; + +part 'search.g.dart'; + +final log = Logger('powersync-supabase'); + +class FtsSearchDelegate extends SearchDelegate { + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + onPressed: () { + query = ''; + }, + icon: const Icon(Icons.clear), + ), + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + onPressed: () { + close(context, null); + }, + icon: const Icon(Icons.arrow_back), + ); + } + + @override + Widget buildResults(BuildContext context) { + return Consumer(builder: (context, ref, _) { + final results = ref.watch(_searchProvider(query)); + + return results.maybeWhen( + data: (rows) { + return ListView.builder( + itemBuilder: (context, index) { + return ListTile( + title: Text(rows[index]['name']), + onTap: () { + close(context, null); + }, + ); + }, + itemCount: rows.length, + ); + }, + orElse: () => const Center( + child: CircularProgressIndicator(), + ), + ); + }); + } + + @override + Widget buildSuggestions(BuildContext context) { + return Consumer( + builder: (context, ref, _) { + final results = ref.watch(_searchProvider(query)); + final appDb = ref.watch(driftDatabase); + + return results.maybeWhen( + data: (rows) { + return ListView.builder( + itemBuilder: (context, index) { + return ListTile( + title: Text(rows[index]['name'] ?? ''), + onTap: () async { + ListItem list = await appDb.findList(rows[index]['id']); + if (context.mounted) { + context.pushRoute(ListsDetailsRoute(list: list.id)); + } + }, + ); + }, + itemCount: rows.length, + ); + }, + orElse: () => const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ); + } +} + +@riverpod +Future _search(Ref ref, String query) async { + if (query.isEmpty) return []; + final listsSearchResults = + await ref.watch(fts_helpers.searchProvider(query, 'lists').future); + final todoItemsSearchResults = + await ref.watch(fts_helpers.searchProvider(query, 'todos').future); + + List formattedListResults = listsSearchResults + .map((result) => {"id": result['id'], "name": result['name']}) + .toList(); + List formattedTodoItemsResults = todoItemsSearchResults + .map((result) => { + // Use list_id so the navigation goes to the list page + "id": result['list_id'], + "name": result['description'], + }) + .toList(); + List formattedResults = [ + ...formattedListResults, + ...formattedTodoItemsResults + ]; + return formattedResults; +} diff --git a/demos/supabase-todolist-drift/lib/screens/search.g.dart b/demos/supabase-todolist-drift/lib/screens/search.g.dart new file mode 100644 index 00000000..48426f71 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/search.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$searchHash() => r'22f755afc645f10c862d9aece9f392958c10d086'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [_search]. +@ProviderFor(_search) +const _searchProvider = _SearchFamily(); + +/// See also [_search]. +class _SearchFamily extends Family> { + /// See also [_search]. + const _SearchFamily(); + + /// See also [_search]. + _SearchProvider call( + String query, + ) { + return _SearchProvider( + query, + ); + } + + @override + _SearchProvider getProviderOverride( + covariant _SearchProvider provider, + ) { + return call( + provider.query, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'_searchProvider'; +} + +/// See also [_search]. +class _SearchProvider extends AutoDisposeFutureProvider { + /// See also [_search]. + _SearchProvider( + String query, + ) : this._internal( + (ref) => _search( + ref as _SearchRef, + query, + ), + from: _searchProvider, + name: r'_searchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchHash, + dependencies: _SearchFamily._dependencies, + allTransitiveDependencies: _SearchFamily._allTransitiveDependencies, + query: query, + ); + + _SearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.query, + }) : super.internal(); + + final String query; + + @override + Override overrideWith( + FutureOr Function(_SearchRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: _SearchProvider._internal( + (ref) => create(ref as _SearchRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + query: query, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is _SearchProvider && other.query == query; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, query.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin _SearchRef on AutoDisposeFutureProviderRef { + /// The parameter `query` of this provider. + String get query; +} + +class _SearchProviderElement extends AutoDisposeFutureProviderElement + with _SearchRef { + _SearchProviderElement(super.provider); + + @override + String get query => (origin as _SearchProvider).query; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/screens/signup.dart b/demos/supabase-todolist-drift/lib/screens/signup.dart new file mode 100644 index 00000000..6b22271e --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/signup.dart @@ -0,0 +1,84 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../components/app_bar.dart'; +import '../navigation.dart'; +import '../supabase.dart'; + +@RoutePage() +final class SignupPage extends HookConsumerWidget { + const SignupPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usernameController = useTextEditingController(); + final passwordController = useTextEditingController(); + final (:error, :isBusy) = ref.watch(authNotifierProvider); + + final signupAction = isBusy + ? null + : () { + ref + .read(authNotifierProvider.notifier) + .signup(usernameController.text, passwordController.text); + }; + + return Scaffold( + appBar: appBar, + body: SingleChildScrollView( + child: Container( + margin: const EdgeInsets.all(30), + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 15), + child: Text('Supabase Login'), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + controller: usernameController, + decoration: const InputDecoration(labelText: 'Email'), + enabled: !isBusy, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: TextFormField( + obscureText: true, + controller: passwordController, + decoration: InputDecoration( + labelText: 'Password', errorText: error), + enabled: !isBusy, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 15), + child: TextButton( + onPressed: signupAction, + child: const Text('Sign up'), + ), + ), + TextButton( + onPressed: isBusy + ? null + : () { + ref.read(appRouter).replace(const LoginRoute()); + }, + child: const Text('Already have an account?'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/sql_console.dart b/demos/supabase-todolist-drift/lib/screens/sql_console.dart new file mode 100644 index 00000000..0608dd0d --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/sql_console.dart @@ -0,0 +1,99 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:powersync/sqlite3_common.dart' as sqlite; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../components/page_layout.dart'; +import '../powersync/powersync.dart'; +import '../powersync/schema.dart'; + +part 'sql_console.g.dart'; + +@riverpod +Stream _watch(Ref ref, String sql) async* { + final db = await ref.read(powerSyncInstanceProvider.future); + yield* db.watch(sql); +} + +@RoutePage() +final class SqlConsolePage extends HookConsumerWidget { + const SqlConsolePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final query = useState('SELECT * FROM $todosTable'); + final controller = useTextEditingController(text: query.value); + final rows = ref.watch(_watchProvider(query.value)); + + return PageLayout( + showDrawer: false, + content: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: controller, + onEditingComplete: () { + query.value = controller.text; + }, + decoration: InputDecoration( + isDense: false, + border: const OutlineInputBorder(), + labelText: 'Query', + errorText: rows.error?.toString(), + ), + ), + ), + if (rows case AsyncData(:final value)) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: ResultSetTable(data: value), + ), + )) + ], + ), + ); + } +} + +/// Stateless DataTable rendering results from a SQLite query +final class ResultSetTable extends StatelessWidget { + const ResultSetTable({super.key, this.data}); + + final sqlite.ResultSet? data; + + @override + Widget build(BuildContext context) { + if (data == null) { + return const Text('Loading...'); + } else if (data!.isEmpty) { + return const Text('Empty'); + } + return DataTable( + columns: [ + for (var column in data!.columnNames) + DataColumn( + label: Expanded( + child: Text( + column, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ], + rows: [ + for (var row in data!.rows) + DataRow( + cells: [ + for (var cell in row) DataCell(Text((cell ?? '').toString())), + ], + ), + ], + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/screens/sql_console.g.dart b/demos/supabase-todolist-drift/lib/screens/sql_console.g.dart new file mode 100644 index 00000000..96a2949b --- /dev/null +++ b/demos/supabase-todolist-drift/lib/screens/sql_console.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sql_console.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$watchHash() => r'd184cf5e1c494c80f42ad490e989911be7fce98a'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [_watch]. +@ProviderFor(_watch) +const _watchProvider = _WatchFamily(); + +/// See also [_watch]. +class _WatchFamily extends Family> { + /// See also [_watch]. + const _WatchFamily(); + + /// See also [_watch]. + _WatchProvider call( + String sql, + ) { + return _WatchProvider( + sql, + ); + } + + @override + _WatchProvider getProviderOverride( + covariant _WatchProvider provider, + ) { + return call( + provider.sql, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'_watchProvider'; +} + +/// See also [_watch]. +class _WatchProvider extends AutoDisposeStreamProvider { + /// See also [_watch]. + _WatchProvider( + String sql, + ) : this._internal( + (ref) => _watch( + ref as _WatchRef, + sql, + ), + from: _watchProvider, + name: r'_watchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$watchHash, + dependencies: _WatchFamily._dependencies, + allTransitiveDependencies: _WatchFamily._allTransitiveDependencies, + sql: sql, + ); + + _WatchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.sql, + }) : super.internal(); + + final String sql; + + @override + Override overrideWith( + Stream Function(_WatchRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: _WatchProvider._internal( + (ref) => create(ref as _WatchRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + sql: sql, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _WatchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is _WatchProvider && other.sql == sql; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, sql.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin _WatchRef on AutoDisposeStreamProviderRef { + /// The parameter `sql` of this provider. + String get sql; +} + +class _WatchProviderElement + extends AutoDisposeStreamProviderElement with _WatchRef { + _WatchProviderElement(super.provider); + + @override + String get sql => (origin as _WatchProvider).sql; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/attachments/photo_capture_widget.dart b/demos/supabase-todolist-drift/lib/screens/take_photo.dart similarity index 71% rename from demos/supabase-todolist-drift/lib/attachments/photo_capture_widget.dart rename to demos/supabase-todolist-drift/lib/screens/take_photo.dart index aa742c42..3234d579 100644 --- a/demos/supabase-todolist-drift/lib/attachments/photo_capture_widget.dart +++ b/demos/supabase-todolist-drift/lib/screens/take_photo.dart @@ -1,25 +1,31 @@ import 'dart:async'; +import 'package:auto_route/auto_route.dart'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:powersync/powersync.dart' as powersync; -import 'package:supabase_todolist_drift/attachments/queue.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; -class TakePhotoWidget extends StatefulWidget { +import '../powersync/attachments/queue.dart'; +import '../powersync/database.dart'; + +final _log = Logger('TakePhotoWidget'); + +@RoutePage() +class TakePhotoPage extends ConsumerStatefulWidget { final String todoId; final CameraDescription camera; - const TakePhotoWidget( - {super.key, required this.todoId, required this.camera}); + const TakePhotoPage({super.key, required this.todoId, required this.camera}); @override - State createState() { + ConsumerState createState() { return _TakePhotoWidgetState(); } } -class _TakePhotoWidgetState extends State { +class _TakePhotoWidgetState extends ConsumerState { late CameraController _cameraController; late Future _initializeControllerFuture; @@ -50,16 +56,17 @@ class _TakePhotoWidgetState extends State { 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 + final queue = await ref.read(attachmentQueueProvider.future); + String storageDirectory = await queue.getStorageDirectory(); + await queue.localStorage .copyFile(photo.path, '$storageDirectory/$photoId.jpg'); int photoSize = await photo.length(); - await appDb.addTodoPhoto(widget.todoId, photoId); - await attachmentQueue.saveFile(photoId, photoSize); + await ref.read(driftDatabase).addTodoPhoto(widget.todoId, photoId); + await queue.saveFile(photoId, photoSize); } catch (e) { - log.info('Error taking photo: $e'); + _log.info('Error taking photo: $e'); } // After taking the photo, navigate back to the previous screen diff --git a/demos/supabase-todolist-drift/lib/stores/items.dart b/demos/supabase-todolist-drift/lib/stores/items.dart new file mode 100644 index 00000000..888e1758 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/stores/items.dart @@ -0,0 +1,62 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../powersync/attachments/queue.dart'; +import '../powersync/database.dart'; +import '../supabase.dart'; + +part 'items.g.dart'; + +@riverpod +final class ItemsNotifier extends _$ItemsNotifier { + @override + Stream> build(String list) { + final database = ref.watch(driftDatabase); + final query = database.select(database.todoItems) + ..where((row) => row.listId.equals(list)) + ..orderBy([(t) => OrderingTerm(expression: t.createdAt)]); + return query.watch(); + } + + Future toggleTodo(TodoItem todo) async { + final db = ref.read(driftDatabase); + final userId = ref.read(userIdProvider); + + final stmt = db.update(db.todoItems)..where((t) => t.id.equals(todo.id)); + + if (todo.completed != true) { + await stmt.write( + TodoItemsCompanion( + completed: const Value(true), + completedAt: Value(DateTime.now()), + completedBy: Value(userId)), + ); + } else { + await stmt.write(const TodoItemsCompanion(completed: Value(false))); + } + } + + Future deleteItem(TodoItem item) async { + final db = ref.read(driftDatabase); + if (item.photoId case final photo?) { + final queue = await ref.read(attachmentQueueProvider.future); + queue.deleteFile(photo); + } + + await (db.delete(db.todoItems)..where((t) => t.id.equals(item.id))).go(); + } + + Future addItem(String description) async { + final db = ref.read(driftDatabase); + final userId = ref.read(userIdProvider); + + await db.into(db.todoItems).insertReturning( + TodoItemsCompanion.insert( + listId: list, + description: description, + completed: const Value(false), + createdBy: Value(userId), + ), + ); + } +} diff --git a/demos/supabase-todolist-drift/lib/stores/items.g.dart b/demos/supabase-todolist-drift/lib/stores/items.g.dart new file mode 100644 index 00000000..8a9c7e43 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/stores/items.g.dart @@ -0,0 +1,176 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'items.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$itemsNotifierHash() => r'0cda92119ac0ce0a22bdaf05d74d17e6b1dc0f4f'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ItemsNotifier + extends BuildlessAutoDisposeStreamNotifier> { + late final String list; + + Stream> build( + String list, + ); +} + +/// See also [ItemsNotifier]. +@ProviderFor(ItemsNotifier) +const itemsNotifierProvider = ItemsNotifierFamily(); + +/// See also [ItemsNotifier]. +class ItemsNotifierFamily extends Family>> { + /// See also [ItemsNotifier]. + const ItemsNotifierFamily(); + + /// See also [ItemsNotifier]. + ItemsNotifierProvider call( + String list, + ) { + return ItemsNotifierProvider( + list, + ); + } + + @override + ItemsNotifierProvider getProviderOverride( + covariant ItemsNotifierProvider provider, + ) { + return call( + provider.list, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'itemsNotifierProvider'; +} + +/// See also [ItemsNotifier]. +class ItemsNotifierProvider extends AutoDisposeStreamNotifierProviderImpl< + ItemsNotifier, List> { + /// See also [ItemsNotifier]. + ItemsNotifierProvider( + String list, + ) : this._internal( + () => ItemsNotifier()..list = list, + from: itemsNotifierProvider, + name: r'itemsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$itemsNotifierHash, + dependencies: ItemsNotifierFamily._dependencies, + allTransitiveDependencies: + ItemsNotifierFamily._allTransitiveDependencies, + list: list, + ); + + ItemsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.list, + }) : super.internal(); + + final String list; + + @override + Stream> runNotifierBuild( + covariant ItemsNotifier notifier, + ) { + return notifier.build( + list, + ); + } + + @override + Override overrideWith(ItemsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: ItemsNotifierProvider._internal( + () => create()..list = list, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + list: list, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement> + createElement() { + return _ItemsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ItemsNotifierProvider && other.list == list; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, list.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ItemsNotifierRef on AutoDisposeStreamNotifierProviderRef> { + /// The parameter `list` of this provider. + String get list; +} + +class _ItemsNotifierProviderElement + extends AutoDisposeStreamNotifierProviderElement> with ItemsNotifierRef { + _ItemsNotifierProviderElement(super.provider); + + @override + String get list => (origin as ItemsNotifierProvider).list; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/stores/lists.dart b/demos/supabase-todolist-drift/lib/stores/lists.dart new file mode 100644 index 00000000..cf47f0bd --- /dev/null +++ b/demos/supabase-todolist-drift/lib/stores/lists.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../powersync/database.dart'; +import '../supabase.dart'; + +part 'lists.g.dart'; + +@riverpod +final class ListsNotifier extends _$ListsNotifier { + @override + Stream> build() { + final database = ref.watch(driftDatabase); + return database.listsWithStats().watch(); + } + + Future createNewList(String name) async { + final database = ref.read(driftDatabase); + await database.listItems.insertOne(ListItemsCompanion.insert( + name: name, + ownerId: Value(ref.read(userIdProvider)), + )); + } + + Future deleteList(String id) async { + // We only need to delete the list here, the foreign key constraint on the + // server will delete related todos (which will delete them locally after + // the next sync). + final database = ref.read(driftDatabase); + final stmt = database.listItems.delete()..where((row) => row.id.equals(id)); + await stmt.go(); + } +} diff --git a/demos/supabase-todolist-drift/lib/stores/lists.g.dart b/demos/supabase-todolist-drift/lib/stores/lists.g.dart new file mode 100644 index 00000000..ac722cf4 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/stores/lists.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'lists.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$listsNotifierHash() => r'6cefeae1ff39373c3b827895be5b1d6911cfe023'; + +/// See also [ListsNotifier]. +@ProviderFor(ListsNotifier) +final listsNotifierProvider = AutoDisposeStreamNotifierProvider>.internal( + ListsNotifier.new, + name: r'listsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$listsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ListsNotifier = AutoDisposeStreamNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/supabase.dart b/demos/supabase-todolist-drift/lib/supabase.dart index e4e4d05b..db28c6e0 100644 --- a/demos/supabase-todolist-drift/lib/supabase.dart +++ b/demos/supabase-todolist-drift/lib/supabase.dart @@ -1,6 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:stream_transform/stream_transform.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'app_config.dart'; +import 'powersync/powersync.dart'; + +part 'supabase.g.dart'; loadSupabase() async { await Supabase.initialize( @@ -8,3 +15,65 @@ loadSupabase() async { anonKey: AppConfig.supabaseAnonKey, ); } + +@riverpod +Stream session(Ref ref) { + final instance = Supabase.instance.client.auth; + + return instance.onAuthStateChange + .map((_) => instance.currentSession) + .startWith(instance.currentSession); +} + +@riverpod +bool isLoggedIn(Ref ref) { + return ref.watch(sessionProvider.select((session) => session.value != null)); +} + +@riverpod +String? userId(Ref ref) { + return ref.watch(sessionProvider.select((session) => session.value?.user.id)); +} + +typedef AuthState = ({String? error, bool isBusy}); + +@riverpod +final class AuthNotifier extends _$AuthNotifier { + static final _logger = Logger('AuthNotifier'); + + @override + AuthState build() { + return (error: null, isBusy: false); + } + + Future _doWork(Future Function() inner) async { + try { + state = (error: null, isBusy: true); + await inner(); + state = (error: null, isBusy: false); + } catch (e, s) { + _logger.warning('auth error', e, s); + state = (error: e.toString(), isBusy: false); + } + } + + Future login(String username, String password) { + return _doWork(() async { + await Supabase.instance.client.auth + .signInWithPassword(email: username, password: password); + }); + } + + Future signup(String username, String password) async { + return _doWork(() async { + await Supabase.instance.client.auth + .signUp(email: username, password: password); + }); + } + + Future signOut() async { + await Supabase.instance.client.auth.signOut(); + await (await ref.read(powerSyncInstanceProvider.future)) + .disconnectAndClear(); + } +} diff --git a/demos/supabase-todolist-drift/lib/supabase.g.dart b/demos/supabase-todolist-drift/lib/supabase.g.dart new file mode 100644 index 00000000..d72036f6 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/supabase.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'supabase.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sessionHash() => r'1ecba22c88e6f2c7349d9da812430647fe008045'; + +/// See also [session]. +@ProviderFor(session) +final sessionProvider = AutoDisposeStreamProvider.internal( + session, + name: r'sessionProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$sessionHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SessionRef = AutoDisposeStreamProviderRef; +String _$isLoggedInHash() => r'1d50e28b5449cd3d195c0736f5f9d92b97e69cc8'; + +/// See also [isLoggedIn]. +@ProviderFor(isLoggedIn) +final isLoggedInProvider = AutoDisposeProvider.internal( + isLoggedIn, + name: r'isLoggedInProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isLoggedInHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IsLoggedInRef = AutoDisposeProviderRef; +String _$userIdHash() => r'0ca9244c1352c59ea306e9e23278b952eb348681'; + +/// See also [userId]. +@ProviderFor(userId) +final userIdProvider = AutoDisposeProvider.internal( + userId, + name: r'userIdProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$userIdHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserIdRef = AutoDisposeProviderRef; +String _$authNotifierHash() => r'ed547c8adf5eb1a61014332a83dcd266e47b25b3'; + +/// See also [AuthNotifier]. +@ProviderFor(AuthNotifier) +final authNotifierProvider = + AutoDisposeNotifierProvider.internal( + AuthNotifier.new, + name: r'authNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$authNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AuthNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/demos/supabase-todolist-drift/lib/utils/provider_observer.dart b/demos/supabase-todolist-drift/lib/utils/provider_observer.dart new file mode 100644 index 00000000..c47c70e9 --- /dev/null +++ b/demos/supabase-todolist-drift/lib/utils/provider_observer.dart @@ -0,0 +1,22 @@ +import 'package:logging/logging.dart'; +import 'package:riverpod/riverpod.dart'; + +final class LoggingProviderObserver extends ProviderObserver { + static final _log = Logger('provider'); + + const LoggingProviderObserver(); + + @override + void didUpdateProvider(ProviderBase provider, Object? previousValue, + Object? newValue, ProviderContainer container) { + if (newValue case AsyncError(:final error, :final stackTrace)) { + _log.warning('$provider emitted async error', error, stackTrace); + } + } + + @override + void providerDidFail(ProviderBase provider, Object error, + StackTrace stackTrace, ProviderContainer container) { + _log.warning('$provider threw exception', error, stackTrace); + } +} diff --git a/demos/supabase-todolist-drift/lib/widgets/fts_search_delegate.dart b/demos/supabase-todolist-drift/lib/widgets/fts_search_delegate.dart deleted file mode 100644 index 521ef0e6..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/fts_search_delegate.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/fts_helpers.dart' as fts_helpers; -import 'package:supabase_todolist_drift/powersync.dart'; - -import 'todo_list_page.dart'; - -final log = Logger('powersync-supabase'); - -class FtsSearchDelegate extends SearchDelegate { - @override - List? buildActions(BuildContext context) { - return [ - IconButton( - onPressed: () { - query = ''; - }, - icon: const Icon(Icons.clear), - ), - ]; - } - - @override - Widget? buildLeading(BuildContext context) { - return IconButton( - onPressed: () { - close(context, null); - }, - icon: const Icon(Icons.arrow_back), - ); - } - - @override - Widget buildResults(BuildContext context) { - return FutureBuilder( - future: _search(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return ListView.builder( - itemBuilder: (context, index) { - return ListTile( - title: Text(snapshot.data?[index].name), - onTap: () { - close(context, null); - }, - ); - }, - itemCount: snapshot.data?.length, - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } - - @override - Widget buildSuggestions(BuildContext context) { - NavigatorState navigator = Navigator.of(context); - - return FutureBuilder( - future: _search(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return ListView.builder( - itemBuilder: (context, index) { - return ListTile( - title: Text(snapshot.data?[index]['name'] ?? ''), - onTap: () async { - ListItem list = - await appDb.findList(snapshot.data![index]['id']); - navigator.push(MaterialPageRoute( - builder: (context) => TodoListPage(list: list), - )); - }, - ); - }, - itemCount: snapshot.data?.length, - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } - - Future _search() async { - if (query.isEmpty) return []; - List listsSearchResults = await fts_helpers.search(query, 'lists'); - List todoItemsSearchResults = await fts_helpers.search(query, 'todos'); - List formattedListResults = listsSearchResults - .map((result) => {"id": result['id'], "name": result['name']}) - .toList(); - List formattedTodoItemsResults = todoItemsSearchResults - .map((result) => { - // Use list_id so the navigation goes to the list page - "id": result['list_id'], - "name": result['description'], - }) - .toList(); - List formattedResults = [ - ...formattedListResults, - ...formattedTodoItemsResults - ]; - return formattedResults; - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/list_item.dart b/demos/supabase-todolist-drift/lib/widgets/list_item.dart deleted file mode 100644 index 19c5d798..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/list_item.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -import 'todo_list_page.dart'; - -class ListItemWidget extends StatelessWidget { - ListItemWidget({ - required this.list, - }) : super(key: ObjectKey(list)); - - final ListItemWithStats list; - - Future delete() async { - // Server will take care of deleting related todos - await appDb.deleteList(list.self); - } - - @override - Widget build(BuildContext context) { - viewList() { - var navigator = Navigator.of(context); - - navigator.push(MaterialPageRoute( - builder: (context) => TodoListPage(list: list.self))); - } - - final subtext = - '${list.pendingCount} pending, ${list.completedCount} completed'; - - return Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: viewList, - leading: const Icon(Icons.list), - title: Text(list.self.name), - subtitle: Text(subtext)), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - iconSize: 30, - icon: const Icon( - Icons.delete, - color: Colors.red, - ), - tooltip: 'Delete List', - alignment: Alignment.centerRight, - onPressed: delete, - ), - const SizedBox(width: 8), - ], - ), - ], - ), - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/list_item_dialog.dart b/demos/supabase-todolist-drift/lib/widgets/list_item_dialog.dart deleted file mode 100644 index 50d0009c..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/list_item_dialog.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -class ListItemDialog extends StatefulWidget { - const ListItemDialog({super.key}); - - @override - State createState() { - return _ListItemDialogState(); - } -} - -class _ListItemDialogState extends State { - final TextEditingController _textFieldController = TextEditingController(); - - _ListItemDialogState(); - - @override - void dispose() { - super.dispose(); - _textFieldController.dispose(); - } - - Future add() async { - await appDb.createList(_textFieldController.text); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add a new list'), - content: TextField( - controller: _textFieldController, - decoration: const InputDecoration(hintText: 'List name'), - onSubmitted: (value) async { - Navigator.of(context).pop(); - await add(); - }, - autofocus: true, - ), - actions: [ - OutlinedButton( - child: const Text('Cancel'), - onPressed: () { - _textFieldController.clear(); - Navigator.of(context).pop(); - }, - ), - ElevatedButton( - child: const Text('Create'), - onPressed: () async { - Navigator.of(context).pop(); - await add(); - }, - ), - ], - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/lists_page.dart b/demos/supabase-todolist-drift/lib/widgets/lists_page.dart deleted file mode 100644 index 52e3beed..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/lists_page.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -import 'list_item.dart'; -import 'list_item_dialog.dart'; -import '../main.dart'; - -void _showAddDialog(BuildContext context) async { - return showDialog( - context: context, - barrierDismissible: false, // user must tap button! - builder: (BuildContext context) { - return const ListItemDialog(); - }, - ); -} - -class ListsPage extends StatelessWidget { - const ListsPage({super.key}); - - @override - Widget build(BuildContext context) { - const content = ListsWidget(); - - final button = FloatingActionButton( - onPressed: () { - _showAddDialog(context); - }, - tooltip: 'Create List', - child: const Icon(Icons.add), - ); - - final page = MyHomePage( - title: 'Todo Lists', - content: content, - floatingActionButton: button, - ); - return page; - } -} - -class ListsWidget extends StatefulWidget { - const ListsWidget({super.key}); - - @override - State createState() { - return _ListsWidgetState(); - } -} - -class _ListsWidgetState extends State { - List _data = []; - bool hasSynced = false; - StreamSubscription? _subscription; - StreamSubscription? _syncStatusSubscription; - - _ListsWidgetState(); - - @override - void initState() { - super.initState(); - final stream = appDb.watchListsWithStats(); - _subscription = stream.listen((data) { - if (!context.mounted) { - return; - } - setState(() { - _data = data; - }); - }); - _syncStatusSubscription = db.statusStream.listen((status) { - if (!context.mounted) { - return; - } - setState(() { - hasSynced = status.hasSynced ?? false; - }); - }); - } - - @override - void dispose() { - super.dispose(); - _subscription?.cancel(); - _syncStatusSubscription?.cancel(); - } - - @override - Widget build(BuildContext context) { - return !hasSynced - ? const Text("Busy with sync...") - : ListView( - padding: const EdgeInsets.symmetric(vertical: 8.0), - children: _data.map((list) { - return ListItemWidget(list: list); - }).toList(), - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/login_page.dart b/demos/supabase-todolist-drift/lib/widgets/login_page.dart deleted file mode 100644 index f54f09da..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/login_page.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import '../main.dart'; - -class LoginPage extends StatefulWidget { - const LoginPage({super.key}); - - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - late TextEditingController _passwordController; - late TextEditingController _usernameController; - String? _error; - late bool _busy; - - @override - void initState() { - super.initState(); - - _busy = false; - _passwordController = TextEditingController(text: ''); - _usernameController = TextEditingController(text: ''); - } - - void _login(BuildContext context) async { - setState(() { - _busy = true; - _error = null; - }); - try { - await Supabase.instance.client.auth.signInWithPassword( - email: _usernameController.text, password: _passwordController.text); - - if (context.mounted) { - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (context) => listsPage, - )); - } - } on AuthException catch (e) { - setState(() { - _error = e.message; - }); - } catch (e) { - setState(() { - _error = e.toString(); - }); - } finally { - setState(() { - _busy = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("PowerSync Flutter Demo"), - ), - body: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(30), - child: Center( - child: SizedBox( - width: 300, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('Supabase Login'), - const SizedBox(height: 35), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration(labelText: "Email"), - enabled: !_busy, - onFieldSubmitted: _busy - ? null - : (String value) { - _login(context); - }, - ), - const SizedBox(height: 20), - TextFormField( - obscureText: true, - controller: _passwordController, - decoration: InputDecoration( - labelText: "Password", errorText: _error), - enabled: !_busy, - onFieldSubmitted: _busy - ? null - : (String value) { - _login(context); - }, - ), - const SizedBox(height: 25), - TextButton( - onPressed: _busy - ? null - : () { - _login(context); - }, - child: const Text('Login'), - ), - TextButton( - onPressed: _busy - ? null - : () { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => signupPage, - )); - }, - child: const Text('Sign Up'), - ), - ], - ), - ), - ), - ), - ), - )); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/query_widget.dart b/demos/supabase-todolist-drift/lib/widgets/query_widget.dart deleted file mode 100644 index a3ea9654..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/query_widget.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3_common.dart' as sqlite; - -import 'resultset_table.dart'; -import '../powersync.dart'; - -class QueryWidget extends StatefulWidget { - final String defaultQuery; - - const QueryWidget({super.key, required this.defaultQuery}); - - @override - State createState() { - return QueryWidgetState(); - } -} - -class QueryWidgetState extends State { - sqlite.ResultSet? _data; - late TextEditingController _controller; - late String _query; - String? _error; - StreamSubscription? _subscription; - - QueryWidgetState(); - - @override - void initState() { - super.initState(); - _error = null; - _controller = TextEditingController(text: widget.defaultQuery); - _query = _controller.text; - _refresh(); - } - - @override - void dispose() { - super.dispose(); - _controller.dispose(); - _subscription?.cancel(); - } - - _refresh() async { - _subscription?.cancel(); - final stream = db.watch(_query); - _subscription = stream.listen((data) { - if (!context.mounted) { - return; - } - setState(() { - _data = data; - _error = null; - }); - }, onError: (e) { - setState(() { - if (e is sqlite.SqliteException) { - _error = "${e.message}!"; - } else { - _error = e.toString(); - } - }); - }); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: TextField( - controller: _controller, - onEditingComplete: () { - setState(() { - _query = _controller.text; - _refresh(); - }); - }, - decoration: InputDecoration( - isDense: false, - border: const OutlineInputBorder(), - labelText: 'Query', - errorText: _error), - ), - ), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: ResultSetTable(data: _data), - ), - )) - ], - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/resultset_table.dart b/demos/supabase-todolist-drift/lib/widgets/resultset_table.dart deleted file mode 100644 index f348e4ff..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/resultset_table.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3_common.dart' as sqlite; - -/// Stateless DataTable rendering results from a SQLite query -class ResultSetTable extends StatelessWidget { - const ResultSetTable({super.key, this.data}); - - final sqlite.ResultSet? data; - - @override - Widget build(BuildContext context) { - if (data == null) { - return const Text('Loading...'); - } else if (data!.isEmpty) { - return const Text('Empty'); - } - return DataTable( - columns: [ - for (var column in data!.columnNames) - DataColumn( - label: Expanded( - child: Text( - column, - style: const TextStyle(fontStyle: FontStyle.italic), - ), - ), - ), - ], - rows: [ - for (var row in data!.rows) - DataRow( - cells: [ - for (var cell in row) DataCell(Text((cell ?? '').toString())), - ], - ), - ], - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/signup_page.dart b/demos/supabase-todolist-drift/lib/widgets/signup_page.dart deleted file mode 100644 index 2f9150b4..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/signup_page.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import '../main.dart'; - -class SignupPage extends StatefulWidget { - const SignupPage({super.key}); - - @override - State createState() => _SignupPageState(); -} - -class _SignupPageState extends State { - late TextEditingController _passwordController; - late TextEditingController _usernameController; - String? _error; - late bool _busy; - - @override - void initState() { - super.initState(); - - _busy = false; - _passwordController = TextEditingController(text: ''); - _usernameController = TextEditingController(text: ''); - } - - void _signup(BuildContext context) async { - setState(() { - _busy = true; - _error = null; - }); - try { - final response = await Supabase.instance.client.auth.signUp( - email: _usernameController.text, password: _passwordController.text); - - if (context.mounted) { - if (response.session != null) { - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (context) => homePage, - )); - } else { - Navigator.of(context).pop(); - } - } - } on AuthException catch (e) { - setState(() { - _error = e.message; - }); - } catch (e) { - setState(() { - _error = e.toString(); - }); - } finally { - setState(() { - _busy = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("PowerSync Flutter Demo"), - ), - body: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(30), - child: Center( - child: SizedBox( - width: 300, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('Sign Up'), - const SizedBox(height: 35), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration(labelText: "Email"), - enabled: !_busy, - onFieldSubmitted: _busy - ? null - : (String value) { - _signup(context); - }, - ), - const SizedBox(height: 20), - TextFormField( - obscureText: true, - controller: _passwordController, - decoration: InputDecoration( - labelText: "Password", errorText: _error), - enabled: !_busy, - onFieldSubmitted: _busy - ? null - : (String value) { - _signup(context); - }, - ), - const SizedBox(height: 25), - TextButton( - onPressed: _busy - ? null - : () { - _signup(context); - }, - child: const Text('Sign Up'), - ), - ], - ), - ), - ), - ), - ), - )); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/todo_item_dialog.dart b/demos/supabase-todolist-drift/lib/widgets/todo_item_dialog.dart deleted file mode 100644 index 269fcf65..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/todo_item_dialog.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -class TodoItemDialog extends StatefulWidget { - final ListItem list; - - const TodoItemDialog({super.key, required this.list}); - - @override - State createState() { - return _TodoItemDialogState(); - } -} - -class _TodoItemDialogState extends State { - final TextEditingController _textFieldController = TextEditingController(); - - _TodoItemDialogState(); - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - _textFieldController.dispose(); - } - - Future add() async { - Navigator.of(context).pop(); - - await appDb.addTodo(widget.list, _textFieldController.text); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add a new todo item'), - content: TextField( - controller: _textFieldController, - decoration: const InputDecoration(hintText: 'Type your new todo'), - onSubmitted: (value) { - add(); - }, - autofocus: true, - ), - actions: [ - OutlinedButton( - child: const Text('Cancel'), - onPressed: () { - _textFieldController.clear(); - Navigator.of(context).pop(); - }, - ), - ElevatedButton( - onPressed: add, - child: const Text('Add'), - ), - ], - ); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/todo_item_widget.dart b/demos/supabase-todolist-drift/lib/widgets/todo_item_widget.dart deleted file mode 100644 index 374e9e6f..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/todo_item_widget.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/app_config.dart'; -import 'package:supabase_todolist_drift/attachments/photo_widget.dart'; -import 'package:supabase_todolist_drift/attachments/queue.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -class TodoItemWidget extends StatelessWidget { - TodoItemWidget({ - required this.todo, - }) : super(key: ObjectKey(todo.id)); - - final TodoItem todo; - - TextStyle? _getTextStyle(bool checked) { - if (!checked) return null; - - return const TextStyle( - color: Colors.black54, - decoration: TextDecoration.lineThrough, - ); - } - - Future deleteTodo(TodoItem todo) async { - if (todo.photoId != null) { - attachmentQueue.deleteFile(todo.photoId!); - } - await appDb.deleteTodo(todo); - } - - @override - Widget build(BuildContext context) { - return ListTile( - onTap: () => appDb.toggleTodo(todo), - leading: Checkbox( - value: todo.completed, - onChanged: (_) { - appDb.toggleTodo(todo); - }, - ), - title: Row( - children: [ - Expanded( - child: Text(todo.description, - style: _getTextStyle(todo.completed == true))), - IconButton( - iconSize: 30, - icon: const Icon( - Icons.delete, - color: Colors.red, - ), - alignment: Alignment.centerRight, - onPressed: () async => await deleteTodo(todo), - tooltip: 'Delete Item', - ), - AppConfig.supabaseStorageBucket.isEmpty - ? Container() - : PhotoWidget(todo: todo), - ], - )); - } -} diff --git a/demos/supabase-todolist-drift/lib/widgets/todo_list_page.dart b/demos/supabase-todolist-drift/lib/widgets/todo_list_page.dart deleted file mode 100644 index f9687bd7..00000000 --- a/demos/supabase-todolist-drift/lib/widgets/todo_list_page.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:supabase_todolist_drift/database.dart'; -import 'package:supabase_todolist_drift/powersync.dart'; - -import 'status_app_bar.dart'; -import 'todo_item_dialog.dart'; -import 'todo_item_widget.dart'; - -void _showAddDialog(BuildContext context, ListItem list) async { - return showDialog( - context: context, - barrierDismissible: false, // user must tap button! - builder: (BuildContext context) { - return TodoItemDialog(list: list); - }, - ); -} - -class TodoListPage extends StatelessWidget { - final ListItem list; - - const TodoListPage({super.key, required this.list}); - - @override - Widget build(BuildContext context) { - final button = FloatingActionButton( - onPressed: () { - _showAddDialog(context, list); - }, - tooltip: 'Add Item', - child: const Icon(Icons.add), - ); - - return Scaffold( - appBar: StatusAppBar(title: list.name), - floatingActionButton: button, - body: TodoListWidget(list: list)); - } -} - -class TodoListWidget extends StatefulWidget { - final ListItem list; - - const TodoListWidget({super.key, required this.list}); - - @override - State createState() { - return TodoListWidgetState(); - } -} - -class TodoListWidgetState extends State { - List _data = []; - StreamSubscription? _subscription; - - TodoListWidgetState(); - - @override - void initState() { - super.initState(); - final stream = appDb.watchTodoItems(widget.list); - _subscription = stream.listen((data) { - if (!context.mounted) { - return; - } - setState(() { - _data = data; - }); - }); - } - - @override - void dispose() { - super.dispose(); - _subscription?.cancel(); - } - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8.0), - children: _data.map((todo) { - return TodoItemWidget(todo: todo); - }).toList(), - ); - } -} diff --git a/demos/supabase-todolist-drift/macos/Podfile.lock b/demos/supabase-todolist-drift/macos/Podfile.lock index c48748f4..6c7274b0 100644 --- a/demos/supabase-todolist-drift/macos/Podfile.lock +++ b/demos/supabase-todolist-drift/macos/Podfile.lock @@ -19,6 +19,8 @@ PODS: - sqlite3/common - sqlite3/fts5 (3.49.1): - sqlite3/common + - sqlite3/math (3.49.1): + - sqlite3/common - sqlite3/perf-threadsafe (3.49.1): - sqlite3/common - sqlite3/rtree (3.49.1): @@ -29,6 +31,7 @@ PODS: - sqlite3 (~> 3.49.1) - sqlite3/dbstatvtab - sqlite3/fts5 + - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree - url_launcher_macos (0.0.1): @@ -72,7 +75,7 @@ SPEC CHECKSUMS: powersync_flutter_libs: 011c1704766d154faf2373bb9c973d26910d322b shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 - sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 + sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 diff --git a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 906d1c2b..0b9a896d 100644 --- a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/demos/supabase-todolist-drift/macos/Runner/AppDelegate.swift b/demos/supabase-todolist-drift/macos/Runner/AppDelegate.swift index d53ef643..b3c17614 100644 --- a/demos/supabase-todolist-drift/macos/Runner/AppDelegate.swift +++ b/demos/supabase-todolist-drift/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/demos/supabase-todolist-drift/macos/Runner/DebugProfile.entitlements b/demos/supabase-todolist-drift/macos/Runner/DebugProfile.entitlements index dddb8a30..3ba6c126 100644 --- a/demos/supabase-todolist-drift/macos/Runner/DebugProfile.entitlements +++ b/demos/supabase-todolist-drift/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server diff --git a/demos/supabase-todolist-drift/macos/Runner/Release.entitlements b/demos/supabase-todolist-drift/macos/Runner/Release.entitlements index 852fa1a4..ee95ab7e 100644 --- a/demos/supabase-todolist-drift/macos/Runner/Release.entitlements +++ b/demos/supabase-todolist-drift/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/demos/supabase-todolist-drift/pubspec.lock b/demos/supabase-todolist-drift/pubspec.lock index 056dab07..38cea59b 100644 --- a/demos/supabase-todolist-drift/pubspec.lock +++ b/demos/supabase-todolist-drift/pubspec.lock @@ -17,14 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.3.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4 + url: "https://pub.dev" + source: hosted + version: "0.13.0" app_links: dependency: transitive description: name: app_links - sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" + sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.0" app_links_linux: dependency: transitive description: @@ -53,18 +61,18 @@ packages: dependency: transitive description: name: archive - sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" + sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: @@ -73,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + auto_route: + dependency: "direct main" + description: + name: auto_route + sha256: "89bc5d17d8c575399891194b8cd02b39f52a8512c730052f17ebe443cdcb9109" + url: "https://pub.dev" + source: hosted + version: "10.0.1" + auto_route_generator: + dependency: "direct dev" + description: + name: auto_route_generator + sha256: "8e622d26dc6be4bf496d47969e3e9ba555c3abcf2290da6abfa43cbd4f57fa52" + url: "https://pub.dev" + source: hosted + version: "10.0.1" boolean_selector: dependency: transitive description: @@ -141,10 +165,10 @@ packages: dependency: transitive description: name: built_value - sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.3" + version: "8.9.5" camera: dependency: "direct main" description: @@ -157,18 +181,18 @@ packages: dependency: transitive description: name: camera_android - sha256: "007c57cdcace4751014071e3d42f2eb8a64a519254abed35b714223d81d66234" + sha256: "997f19dbdb1bb0e40bdb87265c7e550abea657fe3c4ba3720f81e97b6d4b64dd" url: "https://pub.dev" source: hosted - version: "0.10.10" + version: "0.10.10+1" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: eff7ed630b1ac3994737c790368fe006388ad9f271d7148e432263721e45dc75 + sha256: ba48b65a3a97004276ede882e6b838d9667642ff462c95a8bb57ca8a82b6bd25 url: "https://pub.dev" source: hosted - version: "0.9.18+7" + version: "0.9.18+11" camera_platform_interface: dependency: transitive description: @@ -265,6 +289,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.3.0" dart_style: dependency: transitive description: @@ -277,17 +317,18 @@ packages: dependency: "direct main" description: name: drift - sha256: "97d5832657d49f26e7a8e07de397ddc63790b039372878d5117af816d0fdb5cb" + sha256: "76f23535e19a9f2be92f954e74d8802e96f526e5195d7408c1a20f6659043941" url: "https://pub.dev" source: hosted - version: "2.25.1" + version: "2.24.0" drift_dev: dependency: "direct dev" description: - path: "/Users/simon/src/drift/drift_dev" - relative: false - source: path - version: "2.25.1" + name: drift_dev + sha256: d1d90b0d55b22de412b77186f3bf3179a4b7e2acc4c8fb3a7aaf28a01abc194b + url: "https://pub.dev" + source: hosted + version: "2.24.0" drift_sqlite_async: dependency: "direct main" description: @@ -349,6 +390,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d + url: "https://pub.dev" + source: hosted + version: "0.21.2" flutter_lints: dependency: "direct dev" description: @@ -361,10 +410,18 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" url: "https://pub.dev" source: hosted - version: "2.0.24" + version: "2.0.27" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter @@ -375,6 +432,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + url: "https://pub.dev" + source: hosted + version: "3.0.0" frontend_server_client: dependency: transitive description: @@ -387,10 +452,10 @@ packages: dependency: transitive description: name: functions_client - sha256: "61597ed93be197b1be6387855e4b760e6aac2355fcfc4df6d20d2b4579982158" + sha256: a49876ebae32a50eb62483c5c5ac80ed0d8da34f98ccc23986b03a8d28cee07c url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" glob: dependency: transitive description: @@ -423,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hooks_riverpod: + dependency: "direct main" + description: + name: hooks_riverpod + sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966" + url: "https://pub.dev" + source: hosted + version: "2.6.1" http: dependency: transitive description: @@ -451,10 +524,10 @@ packages: dependency: "direct main" description: name: image - sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" + sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" io: dependency: transitive description: @@ -571,10 +644,10 @@ packages: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" path: dependency: "direct main" description: @@ -595,10 +668,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.16" path_provider_foundation: dependency: transitive description: @@ -685,36 +758,36 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.11.2" + version: "1.12.2" powersync_attachments_helper: dependency: "direct main" description: path: "../../packages/powersync_attachments_helper" relative: true source: path - version: "0.6.18" + version: "0.6.18+4" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.1.2" + version: "1.2.2" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.4" + version: "0.4.7" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" pubspec_parse: dependency: transitive description: @@ -727,10 +800,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "1bfcb7455fdcf15953bf18ac2817634ea5b8f7f350c7e8c9873141a3ee2c3e9c" + sha256: e3089dac2121917cc0c72d42ab056fea0abbaf3c2229048fc50e64bafc731adf url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" recase: dependency: transitive description: @@ -747,6 +820,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + riverpod: + dependency: "direct main" + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" rxdart: dependency: transitive description: @@ -767,10 +872,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: ea86be7b7114f9e94fddfbb52649e59a03d6627ccd2387ebddcd6624719e9f16 + sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.8" shared_preferences_foundation: dependency: transitive description: @@ -799,10 +904,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -860,26 +965,26 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" + sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.7.5" sqlite3_flutter_libs: dependency: transitive description: name: sqlite3_flutter_libs - sha256: "57fafacd815c981735406215966ff7caaa8eab984b094f52e692accefcbd9233" + sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" url: "https://pub.dev" source: hosted - version: "0.5.30" + version: "0.5.32" sqlite3_web: dependency: transitive description: name: sqlite3_web - sha256: "870f287c2375117af1f769893c5ea0941882ee820444af5c3dcceec3b217aab1" + sha256: "967e076442f7e1233bd7241ca61f3efe4c7fc168dac0f38411bdb3bdf471eb3c" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" sqlite_async: dependency: "direct main" description: @@ -904,14 +1009,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" storage_client: dependency: transitive description: name: storage_client - sha256: d80d34f0aa60e5199646bc301f5750767ee37310c2ecfe8d4bbdd29351e09ab0 + sha256: "9f9ed283943313b23a1b27139bb18986e9b152a6d34530232c702c468d98e91a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.1" stream_channel: dependency: transitive description: @@ -921,7 +1034,7 @@ packages: source: hosted version: "2.1.4" stream_transform: - dependency: transitive + dependency: "direct main" description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 @@ -940,18 +1053,18 @@ packages: dependency: transitive description: name: supabase - sha256: "270f63cd87a16578fee87e40cbf61062e8cdbce68d5e723e665f4651d70ddd8c" + sha256: c3ebddba69ddcf16d8b78e8c44c4538b0193d1cf944fde3b72eb5b279892a370 url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.6.3" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: ca8dfe3d4b109e7338cdf7778f3ec2c660a0178006876bfac343eb39b0f3d1e3 + sha256: "3b5b5b492e342f63f301605d0c66f6528add285b5744f53c9fd9abd5ffdbce5b" url: "https://pub.dev" source: hosted - version: "2.8.3" + version: "2.8.4" term_glyph: dependency: transitive description: @@ -1004,10 +1117,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.15" url_launcher_ios: dependency: transitive description: @@ -1092,10 +1205,10 @@ packages: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: diff --git a/demos/supabase-todolist-drift/pubspec.yaml b/demos/supabase-todolist-drift/pubspec.yaml index 8b95168b..454dff6c 100644 --- a/demos/supabase-todolist-drift/pubspec.yaml +++ b/demos/supabase-todolist-drift/pubspec.yaml @@ -21,6 +21,13 @@ dependencies: sqlite_async: ^0.11.0 drift: ^2.20.2 drift_sqlite_async: ^0.2.0 + riverpod_annotation: ^2.6.1 + riverpod: ^2.6.1 + flutter_hooks: ^0.21.2 + hooks_riverpod: ^2.6.1 + flutter_riverpod: ^2.6.1 + auto_route: ^10.0.1 + stream_transform: ^2.1.1 dev_dependencies: flutter_test: @@ -29,6 +36,8 @@ dev_dependencies: flutter_lints: ^3.0.1 drift_dev: ^2.20.3 build_runner: ^2.4.8 + riverpod_generator: ^2.6.5 + auto_route_generator: ^10.0.1 flutter: uses-material-design: true