data_management

Collection of service with advanced style and controlling system.

INTEGRATION

LOCAL (CACHED)

import 'dart:convert';

import 'package:data_management/core.dart';
import 'package:in_app_database/in_app_database.dart';
import 'package:shared_preferences/shared_preferences.dart';

class LocalDatabaseDelegate extends InAppDatabaseDelegate {
  SharedPreferences? _db;

  SharedPreferences get db => _db!;

  @override
  Future<bool> delete(String dbName, String path) {
    return db.remove(path);
  }

  @override
  Future<bool> drop(String dbName) {
    return db.clear();
  }

  @override
  Future<bool> init(String dbName) async {
    if (_db == null) {
      _db = await SharedPreferences.getInstance();
    }
    return true;
  }

  @override
  Future<InAppWriteLimitation?> limitation(
    String dbName,
    PathDetails details,
  ) async {
    return null;
  }

  @override
  Future<Iterable<String>> paths(String dbName) async {
    return db.getKeys();
  }

  @override
  Future<Object?> read(String dbName, String path) async {
    return db.get(path);
  }

  @override
  Future<bool> write(String dbName, String path, Object? data) async {
    if (data is! Map || data.isEmpty) return false;
    if (data is! List || data.isEmpty) return false;
    return db.setString(path, jsonEncode(data));
  }
}

class LocalWriteBatch extends DataWriteBatch {
  late InAppWriteBatch batch;
  final InAppDatabase db;

  LocalWriteBatch(this.db);

  @override
  void init() {
    batch = db.batch();
  }

  @override
  Future<void> commit() async {
    await batch.commit();
  }

  @override
  void delete(String path) {
    batch.delete(db.doc(path));
  }

  @override
  void set(String path, Object data, [bool merge = true]) {
    batch.set(db.doc(path), data, InAppSetOptions(merge: merge));
  }

  @override
  void update(String path, Map<String, dynamic> data) {
    batch.update(db.doc(path), data);
  }
}

class LocalDataDelegate extends DataDelegate {
  InAppDatabase db = InAppDatabase.instance;

  @override
  DataWriteBatch batch() => LocalWriteBatch(db);

  @override
  Future<int?> count(String path) {
    return db.collection(path).count().get().then((snapshot) {
      return snapshot.count;
    });
  }

  @override
  Future<void> create(
    String path,
    Map<String, dynamic> data, [
    bool merge = true,
  ]) {
    return db.doc(path).set(data, InAppSetOptions(merge: merge));
  }

  @override
  Future<void> delete(String path) {
    return db.doc(path).delete();
  }

  @override
  Future<DataGetsSnapshot> get(String path) {
    return db.collection(path).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data).whereType(),
        docChanges: snapshot.docChanges.map((e) => e.doc.data).whereType(),
      );
    });
  }

  @override
  Future<DataGetSnapshot> getById(String path) {
    return db.doc(path).get().then((snapshot) {
      return DataGetSnapshot(
        snapshot: snapshot,
        doc: snapshot.data,
      );
    });
  }

  @override
  Future<DataGetsSnapshot> getByQuery(
    String path, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    return LocalQueryHelper.query(
      db.collection(path),
      queries: queries,
      selections: selections,
      sorts: sorts,
      options: options,
    ).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data).whereType(),
        docChanges: snapshot.docChanges.map((e) => e.doc.data).whereType(),
      );
    });
  }

  @override
  Stream<DataGetsSnapshot> listen(String path) {
    return db.collection(path).snapshots().map((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data).whereType(),
        docChanges: snapshot.docChanges.map((e) => e.doc.data).whereType(),
      );
    });
  }

  @override
  Stream<DataGetSnapshot> listenById(String path) {
    return db.doc(path).snapshots().map((snapshot) {
      return DataGetSnapshot(
        snapshot: snapshot,
        doc: snapshot.data,
      );
    });
  }

  @override
  Stream<DataGetsSnapshot> listenByQuery(
    String path, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    return LocalQueryHelper.query(
      db.collection(path),
      queries: queries,
      selections: selections,
      sorts: sorts,
      options: options,
    ).snapshots().map((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data).whereType(),
        docChanges: snapshot.docChanges.map((e) => e.doc.data).whereType(),
      );
    });
  }

  @override
  Future<DataGetsSnapshot> search(String path, Checker checker) {
    return LocalQueryHelper.search(
      db.collection(path),
      checker,
    ).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data).whereType(),
        docChanges: snapshot.docChanges.map((e) => e.doc.data).whereType(),
      );
    });
  }

  @override
  Future<void> update(String path, Map<String, dynamic> data) {
    return db.doc(path).update(data);
  }

  @override
  Object? updatingFieldValue(Object? value) {
    if (value is! DataFieldValue) return value;
    switch (value.type) {
      case DataFieldValues.arrayUnion:
        return InAppFieldValue.arrayUnion(value.value as List);
      case DataFieldValues.arrayRemove:
        return InAppFieldValue.arrayRemove(value.value as List);
      case DataFieldValues.delete:
        return InAppFieldValue.delete();
      case DataFieldValues.serverTimestamp:
        return InAppFieldValue.timestamp();
      case DataFieldValues.increment:
        return InAppFieldValue.increment(value.value as num);
      case DataFieldValues.none:
        return value;
    }
  }
}

class LocalQueryHelper {
  const LocalQueryHelper._();

  static InAppQueryReference search(
    InAppQueryReference ref,
    Checker checker,
  ) {
    final field = checker.field;
    final value = checker.value;
    final type = checker.type;

    if (value is String) {
      if (type.isContains) {
        ref = ref.orderBy(field).startAt([value]).endAt(['$value\uf8ff']);
      } else {
        ref = ref.where(field, isEqualTo: value);
      }
    }

    return ref;
  }

  static InAppQueryReference query(
    InAppQueryReference ref, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    var isFetchingMode = true;
    final fetchingSizeInit = options.initialSize ?? 0;
    final fetchingSize = options.fetchingSize ?? fetchingSizeInit;
    final isValidLimit = fetchingSize > 0;

    if (queries.isNotEmpty) {
      for (final i in queries) {
        ref = ref.where(
          i.field,
          arrayContains: i.arrayContains,
          arrayNotContains: i.arrayNotContains,
          arrayContainsAny: i.arrayContainsAny,
          arrayNotContainsAny: i.arrayNotContainsAny,
          isEqualTo: i.isEqualTo,
          isNotEqualTo: i.isNotEqualTo,
          isGreaterThan: i.isGreaterThan,
          isGreaterThanOrEqualTo: i.isGreaterThanOrEqualTo,
          isLessThan: i.isLessThan,
          isLessThanOrEqualTo: i.isLessThanOrEqualTo,
          isNull: i.isNull,
          whereIn: i.whereIn,
          whereNotIn: i.whereNotIn,
        );
      }
    }

    if (sorts.isNotEmpty) {
      for (final i in sorts) {
        ref = ref.orderBy(i.field, descending: i.descending);
      }
    }

    if (selections.isNotEmpty) {
      for (final i in selections) {
        final type = i.type;
        final value = i.value;
        final values = i.values;
        final isValidValues = values != null && values.isNotEmpty;
        final isValidSnapshot = value is InAppDocumentSnapshot;
        isFetchingMode = (isValidValues || isValidSnapshot) && !type.isNone;
        if (isValidValues) {
          if (type.isEndAt) {
            ref = ref.endAt(values);
          } else if (type.isEndBefore) {
            ref = ref.endBefore(values);
          } else if (type.isStartAfter) {
            ref = ref.startAfter(values);
          } else if (type.isStartAt) {
            ref = ref.startAt(values);
          }
        } else if (isValidSnapshot) {
          if (type.isEndAtDocument) {
            ref = ref.endAtDocument(value);
          } else if (type.isEndBeforeDocument) {
            ref = ref.endBeforeDocument(value);
          } else if (type.isStartAfterDocument) {
            ref = ref.startAfterDocument(value);
          } else if (type.isStartAtDocument) {
            ref = ref.startAtDocument(value);
          }
        }
      }
    }

    if (isValidLimit) {
      if (options.fetchFromLast) {
        if (isFetchingMode) {
          ref = ref.limitToLast(fetchingSize);
        } else {
          ref = ref.limitToLast(fetchingSizeInit);
        }
      } else {
        if (isFetchingMode) {
          ref = ref.limit(fetchingSize);
        } else {
          ref = ref.limit(fetchingSizeInit);
        }
      }
    }
    return ref;
  }
}

REMOTE (SERVER SIDE)

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:data_management/core.dart';

class FirestoreWriteBatch extends DataWriteBatch {
  late WriteBatch batch;
  final FirebaseFirestore db;

  FirestoreWriteBatch(this.db);

  @override
  void init() {
    batch = db.batch();
  }

  @override
  Future<void> commit() async {
    await batch.commit();
  }

  @override
  void delete(String path) {
    batch.delete(db.doc(path));
  }

  @override
  void set(String path, Object data, [bool merge = true]) {
    batch.set(db.doc(path), data, SetOptions(merge: merge));
  }

  @override
  void update(String path, Map<String, dynamic> data) {
    batch.update(db.doc(path), data);
  }
}

class FirestoreDataDelegate extends DataDelegate {
  FirebaseFirestore db = FirebaseFirestore.instance;

  @override
  DataWriteBatch batch() => FirestoreWriteBatch(db);

  @override
  Future<int?> count(String path) {
    return db.collection(path).count().get().then((snapshot) {
      return snapshot.count;
    });
  }

  @override
  Future<void> create(
    String path,
    Map<String, dynamic> data, [
    bool merge = true,
  ]) {
    return db.doc(path).set(data, SetOptions(merge: merge));
  }

  @override
  Future<void> delete(String path) {
    return db.doc(path).delete();
  }

  @override
  Future<DataGetsSnapshot> get(String path) {
    return db.collection(path).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data()),
        docChanges: snapshot.docChanges.map((e) => e.doc.data()).whereType(),
      );
    });
  }

  @override
  Future<DataGetSnapshot> getById(String path) {
    return db.doc(path).get().then((snapshot) {
      return DataGetSnapshot(
        snapshot: snapshot,
        doc: snapshot.data(),
      );
    });
  }

  @override
  Future<DataGetsSnapshot> getByQuery(
    String path, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    return FirestoreQueryHelper.query(
      db.collection(path),
      queries: queries,
      selections: selections,
      sorts: sorts,
      options: options,
    ).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data()),
        docChanges: snapshot.docChanges.map((e) => e.doc.data()).whereType(),
      );
    });
  }

  @override
  Stream<DataGetsSnapshot> listen(String path) {
    return db.collection(path).snapshots().map((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data()),
        docChanges: snapshot.docChanges.map((e) => e.doc.data()).whereType(),
      );
    });
  }

  @override
  Stream<DataGetSnapshot> listenById(String path) {
    return db.doc(path).snapshots().map((snapshot) {
      return DataGetSnapshot(
        snapshot: snapshot,
        doc: snapshot.data(),
      );
    });
  }

  @override
  Stream<DataGetsSnapshot> listenByQuery(
    String path, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    return FirestoreQueryHelper.query(
      db.collection(path),
      queries: queries,
      selections: selections,
      sorts: sorts,
      options: options,
    ).snapshots().map((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data()),
        docChanges: snapshot.docChanges.map((e) => e.doc.data()).whereType(),
      );
    });
  }

  @override
  Future<DataGetsSnapshot> search(String path, Checker checker) {
    return FirestoreQueryHelper.search(
      db.collection(path),
      checker,
    ).get().then((snapshot) {
      return DataGetsSnapshot(
        snapshot: snapshot,
        docs: snapshot.docs.map((e) => e.data()),
        docChanges: snapshot.docChanges.map((e) => e.doc.data()).whereType(),
      );
    });
  }

  @override
  Future<void> update(String path, Map<String, dynamic> data) {
    return db.doc(path).update(data);
  }

  @override
  Object? updatingFieldValue(Object? value) {
    if (value is! DataFieldValue) return value;
    switch (value.type) {
      case DataFieldValues.arrayUnion:
        return FieldValue.arrayUnion(value.value as List);
      case DataFieldValues.arrayRemove:
        return FieldValue.arrayRemove(value.value as List);
      case DataFieldValues.delete:
        return FieldValue.delete();
      case DataFieldValues.serverTimestamp:
        return FieldValue.serverTimestamp();
      case DataFieldValues.increment:
        return FieldValue.increment(value.value as num);
      case DataFieldValues.none:
        return value;
    }
  }
}

class FirestoreQueryHelper {
  const FirestoreQueryHelper._();

  static Query<T> search<T extends Object?>(
    Query<T> ref,
    Checker checker,
  ) {
    final field = checker.field;
    final value = checker.value;
    final type = checker.type;

    if (value is String) {
      if (type.isContains) {
        ref = ref.orderBy(field).startAt([value]).endAt(['$value\uf8ff']);
      } else {
        ref = ref.where(field, isEqualTo: value);
      }
    }

    return ref;
  }

  static Query<T> query<T extends Object?>(
    Query<T> ref, {
    Iterable<DataQuery> queries = const [],
    Iterable<DataSelection> selections = const [],
    Iterable<DataSorting> sorts = const [],
    DataPagingOptions options = const DataPagingOptions(),
  }) {
    var isFetchingMode = true;
    final fetchingSizeInit = options.initialSize ?? 0;
    final fetchingSize = options.fetchingSize ?? fetchingSizeInit;
    final isValidLimit = fetchingSize > 0;

    if (queries.isNotEmpty) {
      for (final i in queries) {
        final field = i.field;
        ref = ref.where(
          field,
          arrayContains: i.arrayContains,
          arrayContainsAny: i.arrayContainsAny,
          isEqualTo: i.isEqualTo,
          isNotEqualTo: i.isNotEqualTo,
          isGreaterThan: i.isGreaterThan,
          isGreaterThanOrEqualTo: i.isGreaterThanOrEqualTo,
          isLessThan: i.isLessThan,
          isLessThanOrEqualTo: i.isLessThanOrEqualTo,
          isNull: i.isNull,
          whereIn: i.whereIn,
          whereNotIn: i.whereNotIn,
        );
      }
    }

    if (sorts.isNotEmpty) {
      for (final i in sorts) {
        ref = ref.orderBy(i.field, descending: i.descending);
      }
    }

    if (selections.isNotEmpty) {
      for (final i in selections) {
        final type = i.type;
        final value = i.value;
        final values = i.values;
        final isValidValues = values != null && values.isNotEmpty;
        final isValidSnapshot = value is DocumentSnapshot;
        isFetchingMode = (isValidValues || isValidSnapshot) && !type.isNone;
        if (isValidValues) {
          if (type.isEndAt) {
            ref = ref.endAt(values);
          } else if (type.isEndBefore) {
            ref = ref.endBefore(values);
          } else if (type.isStartAfter) {
            ref = ref.startAfter(values);
          } else if (type.isStartAt) {
            ref = ref.startAt(values);
          }
        } else if (isValidSnapshot) {
          if (type.isEndAtDocument) {
            ref = ref.endAtDocument(value);
          } else if (type.isEndBeforeDocument) {
            ref = ref.endBeforeDocument(value);
          } else if (type.isStartAfterDocument) {
            ref = ref.startAfterDocument(value);
          } else if (type.isStartAtDocument) {
            ref = ref.startAtDocument(value);
          }
        }
      }
    }

    if (isValidLimit) {
      if (options.fetchFromLast) {
        if (isFetchingMode) {
          ref = ref.limitToLast(fetchingSize);
        } else {
          ref = ref.limitToLast(fetchingSizeInit);
        }
      } else {
        if (isFetchingMode) {
          ref = ref.limit(fetchingSize);
        } else {
          ref = ref.limit(fetchingSizeInit);
        }
      }
    }
    return ref;
  }
}

USE CASE

INITIALIZATION EACH MODEL

import 'package:data_management/core.dart';
import 'package:flutter_entity/entity.dart';

import 'local.dart';
import 'remote.dart';

class Photo extends Entity {
  final String? path;
  final String? url;

  const Photo({super.id, super.timeMills, this.path, this.url});

  factory Photo.from(Object? source) {
    if (source is! Map) return Photo();
    return Photo(
      id: source["id"],
      timeMills: source["timeMills"],
      path: source["path"],
      url: source["url"],
    );
  }

  @override
  bool isInsertable(String key, value) => value != null;

  @override
  Map<String, dynamic> get source {
    return super.source..addAll({"path": path, "url": url});
  }
}

class User extends Entity {
  final String? path;
  final String? name;

  const User({super.id, super.timeMills, this.path, this.name});

  factory User.from(Object? source) {
    if (source is! Map) return User();
    return User(
      id: source["id"],
      timeMills: source["timeMills"],
      path: source["path"],
      name: source["name"],
    );
  }

  @override
  bool isInsertable(String key, value) => value != null;

  @override
  Map<String, dynamic> get source {
    return super.source..addAll({"path": path, "url": name});
  }
}

class Feed extends Entity {
  final String? title;
  final User? publisher;
  final Photo? photo;

  const Feed({
    super.id,
    super.timeMills,
    this.title,
    this.publisher,
    this.photo,
  });

  factory Feed.from(Object? source) {
    if (source is! Map) return Feed();
    return Feed(
      id: source["id"],
      timeMills: source["timeMills"],
      title: source["title"],
      publisher: User.from(source["publisher"]),
      photo: Photo.from(source["photo"]),
    );
  }

  @override
  bool isInsertable(String key, value) => value != null;

  @override
  Map<String, dynamic> get source {
    return super.source
      ..addAll(
        {
          "title": title,
          "@publisher": publisher?.path,
          "@photo": photo?.path,
        },
      );
  }
}

class RemoteFeedDataSource extends RemoteDataSource<Feed> {
  RemoteFeedDataSource()
      : super(
          path: "users",
          delegate: FirestoreDataDelegate(),
          limitations: DataLimitations(whereIn: 10),
        );

  @override
  Feed build(source) => Feed.from(source);
}

class LocalFeedDataSource extends LocalDataSource<Feed> {
  LocalFeedDataSource()
      : super(
          path: "users",
          delegate: LocalDataDelegate(),
          limitations: DataLimitations(whereIn: 10),
        );

  @override
  Feed build(source) => Feed.from(source);
}

class FeedRepository extends RemoteDataRepository<Feed> {
  static FeedRepository? _i;

  static FeedRepository get i => _i ??= FeedRepository._();

  FeedRepository._()
      : super(source: RemoteFeedDataSource(), backup: LocalFeedDataSource());
}

FINAL

import 'package:flutter/material.dart';
import 'package:flutter_entity/flutter_entity.dart';
import 'package:in_app_database/in_app_database.dart';

import 'local.dart';
import 'model.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await InAppDatabase.init(delegate: LocalDatabaseDelegate());
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final crud = FeedRepository.i;

  String feedPath = "test_feeds";
  String userPath = "test_users";
  String feedId = "feed_123";
  String userId = "user_123";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Custom Ref CRUD Demo")),
      bottomNavigationBar: Row(
        children: [
          ElevatedButton(onPressed: _createFeed, child: const Text("Create")),
          const SizedBox(width: 12),
          ElevatedButton(onPressed: _updateFeed, child: const Text("Update")),
          const SizedBox(width: 12),
          ElevatedButton(
            onPressed: _deleteFeed,
            style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
            child: const Text("Delete"),
          ),
        ],
      ),
      body: FutureBuilder<Response<Feed>>(
        future: crud.getById(feedId, resolveRefs: true),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (!snapshot.hasData) {
            return const Center(child: Text("No feed found"));
          }

          final feed = snapshot.data!.data ?? Feed();
          final publisher = feed.publisher ?? User();
          final photo = feed.photo ?? Photo();

          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  feed.title ?? "No title",
                  style: const TextStyle(
                    fontSize: 20,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 8),
                Row(
                  children: [
                    CircleAvatar(
                      backgroundImage:
                          photo.url != null ? NetworkImage(photo.url!) : null,
                      radius: 24,
                      child:
                          photo.url == null ? const Icon(Icons.person) : null,
                    ),
                    const SizedBox(width: 12),
                    Text(publisher.name ?? "Unknown publisher"),
                  ],
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  /// Example Create
  Future<void> _createFeed() async {
    await crud.createById(feedId, createRefs: true, {
      "title": "My First Feed",
      "@publisher": {
        "path": "$userPath/$userId",
        "create": {
          "name": "John Doe",
          "joinedAt": DateTime.now().millisecondsSinceEpoch,
        },
      },
      "@photo": {
        "path": "$userPath/$userId/avatars/avatar_456",
        "create": {"url": "https://picsum.photos/200"},
      },
    });
  }

  /// Example Update
  Future<void> _updateFeed() async {
    await crud.updateById(feedId, updateRefs: true, {
      "title":
          "Feed Updated at ${DateTime.now().hour}:${DateTime.now().minute}",
      "@photo": {
        "path": "$userPath/$userId/avatars/avatar_456",
        "update": {
          "url": "https://picsum.photos/500",
          "title": "Updated title",
        },
      },
      "@publisher": {
        "path": "$userPath/$userId",
        "update": {
          "name": "Update Omie",
          "updatedAt": DateTime.now().millisecondsSinceEpoch,
        },
      },
    });
  }

  Future<void> _deleteFeed() async {
    await crud.deleteById(
      feedId,
      deleteRefs: true,
    );
  }
}