Skip to content

Commit

Permalink
2.0: Hot reload support
Browse files Browse the repository at this point in the history
  • Loading branch information
tp committed Nov 5, 2024
1 parent 110bdaa commit b86bcc3
Show file tree
Hide file tree
Showing 9 changed files with 460 additions and 103 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## 2.0.0

* 🔥 Hot-reload: The connector interface has been adapted to allow for hot reload (in addition to hot restart)
* This means that instead of a configuration object instead, one now need to implement a class, such that updates to the implementation are accessible on hot reload (which was not possible with the configuration object, which was treated as "state" and thus not refreshed)
* While some patterns on top of the configuration objects (e.g. passing factories) could've allowed for hot-reload, requiring an implementation of the connector class ensures that it works all the time. This requires a one-time migration of all existing code though.
* Update method names
* Most notably `insert` is now `write` to transmit the ambivalence between "insert" and "update"
* `get` is now `read` to be symmetric to write (but instead of entities it takes keys, of course)
Expand Down
2 changes: 2 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:indexed_entity_store_example/src/examples/async_value_group_and_detail.dart';
import 'package:indexed_entity_store_example/src/examples/hot_reload.dart';
import 'package:indexed_entity_store_example/src/examples/simple_synchronous.dart';
import 'package:path_provider/path_provider.dart';

Expand Down Expand Up @@ -39,6 +40,7 @@ class _ExampleSelectorState extends State<ExampleSelector> {
Widget? _example;

static Map<String, Widget> examples = {
'Hot-reload example': const HotReloadExample(),
'Simple synchronous data repository': const SimpleSynchronousExample(),
'AsyncValue-based product list & detail view':
const AsyncValueGroupDetailExample(),
Expand Down
274 changes: 274 additions & 0 deletions example/lib/src/examples/hot_reload.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import 'dart:convert';

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:indexed_entity_store/indexed_entity_store.dart';
import 'package:indexed_entity_store_example/src/stores/database_helper.dart';

enum HotReloadCase {
intial,

/// Switching to this case will cause an error, as there is a persistent query on the age
///
/// Since this error is not something that happens at normal runtime, but just during development when
/// using index names which are no longer value, it's exposed through the `QueryResult.value` (which
/// will `throw` when access), such that we don't have to wrap the `value` into a success/error wrapper
/// all the time (because under ordinary circumstances we'll always have a successful value).
withoutAgeIndex,

/// When this is active, a new name index is created, and only if active is a query for this index used
///
/// Thus there will be no error, but instead the new query starts working right away, showcasing how the
/// code gracefully handles updates while coding.
withNameIndex,
}

const _currentCase = HotReloadCase.withNameIndex;

/// Showcases how the store can be updated during development, supporting hot-reload
class HotReloadExample extends StatefulWidget {
const HotReloadExample({
super.key,
});

@override
State<HotReloadExample> createState() => _HotReloadExampleState();
}

class _HotReloadExampleState extends State<HotReloadExample> {
final database = getNewDatabase(); // todo merge again

late final PersonStore store = database.entityStore(PersonConnector());

late final QueryResult<List<Person>> everyone = store.query();

late final QueryResult<List<Person>> adults =
store.query(where: (cols) => cols['age'].greaterThanOrEqual(18));

QueryResult<List<Person>>? roberts;

@override
initState() {
super.initState();

// ignore: invalid_use_of_protected_member
// WidgetsBinding.instance.registerSignalServiceExtension(
// name: FoundationServiceExtensions.reassemble.name,
// callback: () async {
// print('reassemble');
// },
// );

store.writeMany([
Person(id: 1, name: 'Sam', age: 5),
Person(id: 2, name: 'Bob', age: 3),
Person(id: 3, name: 'Robert', age: 20),
Person(id: 4, name: 'Max', age: 21),
]);
}

@override
void reassemble() {
super.reassemble();

debugPrint('Reassemble');
database.handleHotReload();
// WidgetsBinding.instance.addPostFrameCallback((_) {
// });

if (_currentCase == HotReloadCase.withNameIndex) {
roberts = store.query(
where: (cols) =>
cols['name'].equals('Robert') | cols['name'].equals('Bob'));
} else {
roberts?.dispose();
roberts = null;
}

// static final _hotReload = ChangeNotifier();
// static Listenable get hotReload => _hotReload;

// WidgetsFlutterBinding.ensureInitialized().addObserver(observer);
// WidgetsFlutterBinding.ensureInitialized().buildOwner.;
}

@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Change the `_currentCase` value in the code and see how the app adapts to the new configuration after hot-reload',
),
Expanded(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_GroupWidget(name: 'Everyone', persons: everyone),
_GroupWidget(name: 'Adults', persons: adults),
if (roberts != null)
_GroupWidget(name: 'Roberts', persons: roberts!),
],
),
),
),
],
);
}

@override
void dispose() {
// In practice, whoever create the database, store, and repository would have to dispose it

everyone.dispose();
adults.dispose();
roberts?.dispose();

super.dispose();
}
}

class _GroupWidget extends StatelessWidget {
const _GroupWidget({
// ignore: unused_element
super.key,
required this.name,
required this.persons,
});

final String name;

final QueryResult<List<Person>> persons;

@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: persons,
builder: (context, persons, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 20),
Text(
' $name',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
for (final person in persons)
CupertinoListTile(
title: Text('${person.name} (${person.age})'),
),
],
);
},
);
}
}

// class SimpleSynchronousRepository {
// final TodoStore _store;

// SimpleSynchronousRepository({
// required TodoStore store,
// }) : _store = store;

// void init() {
// _store.write(Todo(id: 1, text: 'Brew Coffe', done: false));
// _store.write(Todo(id: 2, text: 'Get milk', done: false));
// _store.write(Todo(id: 3, text: 'Read newspaper', done: false));
// }

// QueryResult<List<Todo>> getOpenTodos() {
// return _store.query(where: (cols) => cols['done'].equals(false));
// }

// void updateTodo(Todo todo) {
// _store.write(todo);
// }
// }

typedef PersonStore = IndexedEntityStore<Person, int>;

// Instantiate the store on top of the DB?
// with (super.database)
// would that make query writing better by e.g. using `store.ageCol.equals` ?

class PersonConnector
implements
IndexedEntityConnector<
Person,
// key type
int,
// DB value type
String> {
@override
final entityKey = 'person';

@override
void getIndices(IndexCollector<Person> index) {
if (_currentCase != HotReloadCase.withoutAgeIndex) {
index((p) => p.age, as: 'age');
}

if (_currentCase == HotReloadCase.withNameIndex) {
index((p) => p.name, as: 'name');
}
}

@override
int getPrimaryKey(Person e) => e.id;

@override
String serialize(Person e) => jsonEncode(e.toJSON());

@override
Person deserialize(String s) => Person.fromJSON(
jsonDecode(s) as Map<String, dynamic>,
);
}

class Person {
Person({
required this.id,
required this.name,
required this.age,
});

final int id;

final String name;

final int age;

// These would very likely be created by [json_serializable](https://pub.dev/packages/json_serializable)
// or [freezed](https://pub.dev/packages/freezed) already for your models
Map<String, dynamic> toJSON() {
return {
'id': id,
'name': name,
'age': age,
};
}

static Person fromJSON(Map<String, dynamic> json) {
return Person(
id: json['id'],
name: json['name'],
age: json['age'],
);
}
}

class Foo extends WidgetsBindingObserver {}

class HotReloadTracker {
static final _instance = HotReloadTracker._internal();
factory HotReloadTracker() => _instance;

HotReloadTracker._internal() {
if (kDebugMode) {
print("HotReloadTracker initialized - possible hot reload");
}
}
}
33 changes: 22 additions & 11 deletions example/lib/src/stores/product_connector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,33 @@ import 'package:indexed_entity_store_example/src/stores/entities/product.dart';

typedef ProductDetailStore = IndexedEntityStore<ProductDetail, int>;

final productDetailConnector = IndexedEntityConnector<ProductDetail,
int /* key type */, String /* DB type */ >(
entityKey: 'product',
getPrimaryKey: (t) => t.id,
getIndices: (index) {},
serialize: (t) => jsonEncode(t.toJSON()),
deserialize: (s) => ProductDetail.fromJSON(
jsonDecode(s) as Map<String, dynamic>,
),
);
class ProductDetailConnector
implements
IndexedEntityConnector<ProductDetail, int /* key type */,
String /* DB type */ > {
@override
final entityKey = 'product';

@override
void getIndices(IndexCollector<ProductDetail> index) {}

@override
int getPrimaryKey(ProductDetail e) => e.id;

@override
String serialize(ProductDetail e) => jsonEncode(e.toJSON());

@override
ProductDetail deserialize(String s) => ProductDetail.fromJSON(
jsonDecode(s) as Map<String, dynamic>,
);
}

/// Creates a new ProductDetail store, backed by a new, temporary database
///
/// In practice a single database would likely be reused with many stores,
/// and more importantly the same instance would be used instead of a new one created
/// each time as done here for the showcase.
ProductDetailStore getProductDetailStore() {
return getNewDatabase().entityStore(productDetailConnector);
return getNewDatabase().entityStore(ProductDetailConnector());
}
37 changes: 25 additions & 12 deletions example/lib/src/stores/todo_connector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,37 @@ import 'package:indexed_entity_store_example/src/stores/entities/todo.dart';

typedef TodoStore = IndexedEntityStore<Todo, int>;

final todoConnector =
IndexedEntityConnector<Todo, int /* key type */, String /* DB type */ >(
entityKey: 'todo',
getPrimaryKey: (t) => t.id,
getIndices: (index) {
class TodoConnector extends IndexedEntityConnector<Todo, int /* key type */,
String /* DB type */ > {
@override
final entityKey = 'todo';

@override
getIndices(index) {
index((t) => t.done, as: 'done');
},
serialize: (t) => jsonEncode(t.toJSON()),
deserialize: (s) => Todo.fromJSON(
jsonDecode(s) as Map<String, dynamic>,
),
);
}

@override
getPrimaryKey(e) {
return e.id;
}

@override
serialize(e) {
return jsonEncode(e.toJSON());
}

@override
deserialize(s) {
return Todo.fromJSON(jsonDecode(s) as Map<String, dynamic>);
}
}

/// Creates a new Todo store, backed by a new, temporary database
///
/// In practice a single database would likely be reused with many stores,
/// and more importantly the same instance would be used instead of a new one created
/// each time as done here for the showcase.
TodoStore getTodoStore() {
return getNewDatabase().entityStore(todoConnector);
return getNewDatabase().entityStore(TodoConnector());
}
Loading

0 comments on commit b86bcc3

Please sign in to comment.