diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3d844fc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +packages/**/example/android/** linguist-vendored +packages/**/example/ios/** linguist-vendored +packages/**/example/macos/** linguist-vendored +packages/**/example/linux/** linguist-vendored +packages/**/example/windows/** linguist-vendored +packages/**/example/web/** linguist-vendored + +**/android/** linguist-vendored +**/ios/** linguist-vendored +**/macos/** linguist-vendored +**/linux/** linguist-vendored +**/windows/** linguist-vendored +**/web/** linguist-vendored diff --git a/.gitignore b/.gitignore index 02d7df3..bd65a03 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ pubspec.lock build/ *.iml + +DerivedData/ \ No newline at end of file diff --git a/packages/diff_algorithims/LICENSE b/packages/diff_algorithims/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/diff_algorithims/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/diff_algorithims/lib/hybrid_diff.dart b/packages/diff_algorithims/lib/hybrid_diff.dart new file mode 100644 index 0000000..22b2493 --- /dev/null +++ b/packages/diff_algorithims/lib/hybrid_diff.dart @@ -0,0 +1,298 @@ +import 'dart:math'; +import 'dart:typed_data'; + +enum ChangeOp { insert, delete, modify, equal } + +class DiffNode { + final ChangeOp op; + final String key; + final Object? oldValue; + final Object? newValue; + final List? children; + final TextSplice? splice; + + DiffNode.insert(this.key, this.newValue) + : op = ChangeOp.insert, + oldValue = null, + children = null, + splice = null; + + DiffNode.delete(this.key, this.oldValue) + : op = ChangeOp.delete, + newValue = null, + children = null, + splice = null; + + DiffNode.equal(this.key, this.newValue) + : op = ChangeOp.equal, + oldValue = newValue, + children = null, + splice = null; + + DiffNode.modify( + this.key, + this.oldValue, + this.newValue, { + this.children, + this.splice, + }) : op = ChangeOp.modify; + + @override + String toString() => '$op: $key'; +} + +class TextSplice { + final int index; + final int deleteCount; + final String insertText; + + TextSplice(this.index, this.deleteCount, this.insertText); + + Map toJson() => { + 'i': index, + 'd': deleteCount, + 't': insertText, + }; +} + +class HybridDiffer { + static List diff( + List> oldList, + List> newList, { + required String idField, + }) { + final oldIds = oldList.map((e) => e[idField]).toList(); + final newIds = newList.map((e) => e[idField]).toList(); + + final structuralOps = _myersDiff(oldIds, newIds); + final results = []; + + for (final op in structuralOps) { + if (op.type == _MyersOpType.delete) { + final item = oldList[op.oldIndex!]; + results.add(DiffNode.delete(item[idField].toString(), item)); + } else if (op.type == _MyersOpType.insert) { + final item = newList[op.newIndex!]; + results.add(DiffNode.insert(item[idField].toString(), item)); + } else { + // Equal ID: Check Content + final oldItem = oldList[op.oldIndex!]; + final newItem = newList[op.newIndex!]; + final key = newItem[idField].toString(); + + if (_areDeepEqual(oldItem, newItem)) { + results.add(DiffNode.equal(key, newItem)); + } else { + final fieldChanges = _generateObjectDiff(oldItem, newItem); + results.add( + DiffNode.modify(key, oldItem, newItem, children: fieldChanges), + ); + } + } + } + return results; + } + + static List _generateObjectDiff( + Map oldObj, + Map newObj, + ) { + final diffs = []; + final allKeys = {...oldObj.keys, ...newObj.keys}.toList()..sort(); + + for (final key in allKeys) { + final oldVal = oldObj[key]; + final newVal = newObj[key]; + + if (!oldObj.containsKey(key)) { + diffs.add(DiffNode.insert(key, newVal)); + } else if (!newObj.containsKey(key)) { + diffs.add(DiffNode.delete(key, oldVal)); + } else if (oldVal is Map && + newVal is Map) { + final nested = _generateObjectDiff(oldVal, newVal); + if (nested.isNotEmpty) { + diffs.add(DiffNode.modify(key, oldVal, newVal, children: nested)); + } + } else if (oldVal is String && newVal is String) { + if (oldVal != newVal) { + final splice = _calculateStringSplice(oldVal, newVal); + diffs.add(DiffNode.modify(key, oldVal, newVal, splice: splice)); + } + } else { + if (!_areDeepEqual(oldVal, newVal)) { + diffs.add(DiffNode.modify(key, oldVal, newVal)); + } + } + } + return diffs; + } + + static bool _areDeepEqual(Object? a, Object? b) { + if (a == b) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_areDeepEqual(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !_areDeepEqual(a[key], b[key])) return false; + } + return true; + } + return false; + } + + static TextSplice? _calculateStringSplice(String oldText, String newText) { + if (oldText == newText) return null; + int start = 0; + final minLen = min(oldText.length, newText.length); + while (start < minLen && + oldText.codeUnitAt(start) == newText.codeUnitAt(start)) { + start++; + } + + if (start > 0 && + start < oldText.length && + _isSurrogate(oldText.codeUnitAt(start - 1))) { + start--; + } + + int oldEnd = oldText.length; + int newEnd = newText.length; + while (oldEnd > start && + newEnd > start && + oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) { + oldEnd--; + newEnd--; + } + + return TextSplice(start, oldEnd - start, newText.substring(start, newEnd)); + } + + static bool _isSurrogate(int code) => (code >= 0xD800 && code <= 0xDFFF); + + // --- MYERS ALGORITHM --- + + static List<_MyersOp> _myersDiff(List oldIds, List newIds) { + final n = oldIds.length; + final m = newIds.length; + final max = n + m; + final v = Int32List(2 * max + 1)..fillRange(0, 2 * max + 1, -1); + final trace = []; + + v[max] = 0; + + for (var d = 0; d <= max; d++) { + trace.add(Int32List.fromList(v)); + for (var k = -d; k <= d; k += 2) { + final indexK = max + k; + int x; + if (d == 0) { + x = 0; + } else if (k == -d || (k != d && v[indexK - 1] < v[indexK + 1])) { + x = v[indexK + 1]; + } else { + x = v[indexK - 1] + 1; + } + int y = x - k; + while (x < n && y < m && oldIds[x] == newIds[y]) { + x++; + y++; + } + v[indexK] = x; + if (x >= n && y >= m) { + return _buildMyersScript(oldIds, newIds, trace, d, max); + } + } + } + return []; + } + + static List<_MyersOp> _buildMyersScript( + List oldIds, + List newIds, + List trace, + int d, + int maxOffset, + ) { + var script = <_MyersOp>[]; + var x = oldIds.length; + var y = newIds.length; + + for (var i = d; i > 0; i--) { + final k = x - y; + final kIndex = maxOffset + k; + final prevV = trace[i]; + final kMinus1 = kIndex - 1; + final kPlus1 = kIndex - 1 + 2; // kIndex + 1 + + int prevKIndex; + final bool pickInsert; + + if (k == -i) { + pickInsert = true; + } else if (k == i) { + pickInsert = false; + } else if (prevV[kMinus1] == -1) { + pickInsert = true; + } else if (prevV[kPlus1] == -1) { + pickInsert = false; + } else { + pickInsert = prevV[kMinus1] < prevV[kPlus1]; + } + + if (pickInsert) { + prevKIndex = kPlus1; + } else { + prevKIndex = kMinus1; + } + + final prevX = prevV[prevKIndex]; + final isInsert = prevKIndex == kPlus1; + final startX = isInsert ? prevX : prevX + 1; + + while (x > startX) { + // Snake loop logic + script.add( + _MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1), + ); + x--; + y--; + } + + if (isInsert) { + // Moved down (Insert) + script.add(_MyersOp(_MyersOpType.insert, newIndex: y - 1)); + y--; + } else { + // Moved right (Delete) + script.add(_MyersOp(_MyersOpType.delete, oldIndex: x - 1)); + x--; + } + } + + // Flush remaining snakes + while (x > 0 && y > 0) { + script.add( + _MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1), + ); + x--; + y--; + } + return script.reversed.toList(); + } +} + +enum _MyersOpType { insert, delete, equal } + +class _MyersOp { + final _MyersOpType type; + final int? oldIndex; + final int? newIndex; + _MyersOp(this.type, {this.oldIndex, this.newIndex}); +} diff --git a/packages/diff_algorithims/lib/myers_diff.dart b/packages/diff_algorithims/lib/myers_diff.dart new file mode 100644 index 0000000..138dd23 --- /dev/null +++ b/packages/diff_algorithims/lib/myers_diff.dart @@ -0,0 +1,279 @@ +import 'dart:typed_data'; +import 'package:collection/collection.dart'; + +enum DiffType { insert, delete, equal } + +class DiffOperation { + final DiffType type; + final T data; + final int + index; // Index in newList (for insert) or oldList (for delete/equal) + + DiffOperation(this.type, this.data, this.index); + + @override + String toString() => + '$type: ${data.toString().substring(0, 20)}... (@$index)'; +} + +/// A generic-free typedef for the custom hasher to maximize performance +typedef JsonHasher = int Function(Map item); + +class FastJsonDiffer { + static const _deepEquality = DeepCollectionEquality(); + + // POOLING: Reusable buffers to prevent GC thrashing during high-freq updates + Int32List _vBuffer = Int32List(1024); + + // Storing history for the traceback + // We reuse the list container, but we must allocate new Int32Lists for snapshots + final List _traceBuffer = []; + + /// Main Diff Method. + /// + /// [keyGenerator] (Optional): Provide this for maximum speed. + /// instead of comparing every field, return a hash of specific fields + /// (e.g., `(i) => Object.hash(i['id'], i['version'])`). + List>> diff( + List> oldList, + List> newList, { + JsonHasher? keyGenerator, + }) { + // 1. Identity Check (Optimization) + if (identical(oldList, newList)) return []; + if (oldList.isEmpty && newList.isEmpty) return []; + + // 2. Generate Proxies (Hashes) + // If no keyGenerator is provided, we default to expensive Deep Equality + final hasher = keyGenerator ?? (item) => _deepEquality.hash(item); + + final oldHashes = _generateHashes(oldList, hasher); + final newHashes = _generateHashes(newList, hasher); + + // 3. Run Myers Algorithm on Ints + final rawOps = _diffInts(oldHashes, newHashes); + + // 4. Rehydrate (Map indices back to actual Objects) + return rawOps.map((op) { + switch (op.type) { + case DiffType.insert: + return DiffOperation(DiffType.insert, newList[op.index], op.index); + case DiffType.delete: + return DiffOperation(DiffType.delete, oldList[op.index], op.index); + case DiffType.equal: + return DiffOperation(DiffType.equal, oldList[op.index], op.index); + } + }).toList(); + } + + Int32List _generateHashes( + List> list, + JsonHasher hasher, + ) { + final int len = list.length; + final buffer = Int32List(len); + for (var i = 0; i < len; i++) { + buffer[i] = hasher(list[i]); + } + return buffer; + } + + /// The Core Myers Logic specialized for Int32List. + /// This runs purely on stack primitives and typed arrays. + List> _diffInts(Int32List oldList, Int32List newList) { + final n = oldList.length; + final m = newList.length; + final max = n + m; + final requiredSize = 2 * max + 1; + + // Grow buffer if necessary + if (_vBuffer.length < requiredSize) { + _vBuffer = Int32List(requiredSize); + } + + _vBuffer.fillRange(0, requiredSize, -1); + _traceBuffer.clear(); + + _vBuffer[max] = 0; + + for (var d = 0; d <= max; d++) { + // Snapshot current V state for traceback. + _traceBuffer.add(Int32List.fromList(_vBuffer.sublist(0, requiredSize))); + + for (var k = -d; k <= d; k += 2) { + final kIndex = max + k; + int x; + + // Choose move: Down (Insertion) or Right (Deletion) + if (d == 0) { + x = 0; + } else if (k == -d || + (k != d && _vBuffer[kIndex - 1] < _vBuffer[kIndex + 1])) { + x = _vBuffer[kIndex + 1]; + } else { + x = _vBuffer[kIndex - 1] + 1; + } + + int y = x - k; + + // Snake: Move diagonal as long as hashes match + while (x < n && y < m && oldList[x] == newList[y]) { + x++; + y++; + } + + _vBuffer[kIndex] = x; + + // Check for completion + if (x >= n && y >= m) { + return _buildScript(oldList, newList, d, max); + } + } + } + return []; + } + + List> _buildScript( + Int32List oldList, + Int32List newList, + int d, + int maxOffset, + ) { + var script = >[]; + var x = oldList.length; + var y = newList.length; + + for (var i = d; i > 0; i--) { + final k = x - y; + final kIndex = maxOffset + k; + final prevV = _traceBuffer[i]; // FIXED: Use trace[i] + + final kMinus1 = kIndex - 1; + final kPlus1 = kIndex + 1; + + int prevKIndex; + final bool pickInsert; + + // Robust selection logic + if (k == -i) { + pickInsert = true; + } else if (k == i) { + pickInsert = false; + } else if (prevV[kMinus1] == -1) { + pickInsert = true; + } else if (prevV[kPlus1] == -1) { + pickInsert = false; + } else { + pickInsert = prevV[kMinus1] < prevV[kPlus1]; + } + + if (pickInsert) { + prevKIndex = kPlus1; + } else { + prevKIndex = kMinus1; + } + + final prevX = prevV[prevKIndex]; + // Assert specific invariants + assert( + prevX >= 0, + "Myers invariant violated: prevX should effectively never be -1 (sentinel) when accessed from valid path.", + ); + assert( + x >= prevX, + "Myers invariant violated: x ($x) cannot be less than prevX ($prevX)", + ); + + final isInsert = prevKIndex == kPlus1; + final startX = isInsert ? prevX : prevX + 1; + + // Add Snakes (Equal) + while (x > startX) { + assert(x > 0, "Backtracking snake cannot go below 0 for x"); + script.add(DiffOperation(DiffType.equal, 0, x - 1)); + x--; + y--; + } + + // Add Edit + if (isInsert) { + assert(y > 0, "Insert operation must have y index > 0 (y=$y)"); + script.add(DiffOperation(DiffType.insert, 0, y - 1)); + y--; + } else { + assert(x > 0, "Delete operation must have x index > 0 (x=$x)"); + script.add(DiffOperation(DiffType.delete, 0, x - 1)); + x--; + } + } + + // Flush remaining snakes + while (x > 0 && y > 0) { + script.add(DiffOperation(DiffType.equal, 0, x - 1)); + x--; + y--; + } + + assert( + x == 0 && y == 0, + "Backtrack should end at 0,0. Ended at x=$x, y=$y. Traceback logic is flowed.", + ); + + return script.reversed.toList(); + } +} + +void main() { + final differ = FastJsonDiffer(); + + // Scenario: API Response or Game State + final oldData = [ + {'id': 1, 'val': 'A', 'meta': 'complex_obj'}, + {'id': 2, 'val': 'B', 'meta': 'complex_obj'}, + {'id': 3, 'val': 'C', 'meta': 'complex_obj'}, + ]; + + final newData = [ + {'id': 1, 'val': 'A', 'meta': 'complex_obj'}, + {'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z) + {'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted + ]; + + print('--- 1. Default (Deep Equality) ---'); + // Good for correctness, slower for large lists. + final opsDefault = differ.diff(oldData, newData); + + for (var op in opsDefault) { + if (op.type != DiffType.equal) print(op); + } + // Expected: + // Delete: {id: 2...} + // Delete: {id: 3, val: C...} (Because val changed, hash changed) + // Insert: {id: 3, val: Z...} + // Insert: {id: 4...} + + print('\n--- 2. Optimized (ID + Val Check) ---'); + // Great for high-frequency. We tell the differ EXACTLY what constitutes a change. + // Here we ignore the 'meta' field entirely. + final opsFast = differ.diff( + oldData, + newData, + keyGenerator: (map) => Object.hash(map['id'], map['val']), + ); + + for (var op in opsFast) { + if (op.type != DiffType.equal) print(op); + } + + print('\n--- 3. Ultra Fast (ID Only - Move Detection) ---'); + // If we only care if the ID exists (e.g. for list animations where content updates later) + final opsIdOnly = differ.diff( + oldData, + newData, + keyGenerator: (map) => map['id'] as int, // Direct int cast is fastest + ); + + for (var op in opsIdOnly) { + if (op.type != DiffType.equal) print(op); + } +} diff --git a/packages/diff_algorithims/lib/object_transform.dart b/packages/diff_algorithims/lib/object_transform.dart new file mode 100644 index 0000000..d8409f4 --- /dev/null +++ b/packages/diff_algorithims/lib/object_transform.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +// ============================================================================= +// --- CORE ALGORITHM (The Diff Engine) --- +// ============================================================================= + +/// Sentinel value for deleted keys +class Deleted { + const Deleted(); + Map toJson() => {'_op': 'd'}; + @override + String toString() => ''; +} + +const deleted = Deleted(); + +/// Represents a precise text edit (Splice) +class TextSplice { + final int index; + final int deleteCount; + final String insertText; + + TextSplice(this.index, this.deleteCount, this.insertText); + + /// Applies the splice to a string + String apply(String original) { + if (index > original.length) return original + insertText; + final start = original.substring(0, index); + final end = original.substring(index + deleteCount); + return '$start$insertText$end'; + } + + Map toJson() => { + '_op': 's', + 'i': index, + 'd': deleteCount, + 't': insertText, + }; +} + +class DiffEngine { + /// Generates a recursive diff between two JSON-like maps + static Map generateDiff( + Map oldObj, + Map newObj, + ) { + final diff = {}; + + // 1. Walk new keys + for (final key in newObj.keys) { + final oldVal = oldObj[key]; + final newVal = newObj[key]; + + // New Key Added + if (!oldObj.containsKey(key)) { + diff[key] = newVal; + continue; + } + + // Recursive Map Diff + if (oldVal is Map && newVal is Map) { + final nested = generateDiff(oldVal, newVal); + if (nested.isNotEmpty) diff[key] = nested; + continue; + } + + // Smart String Diff (Splice) + if (oldVal is String && newVal is String) { + final splice = _calculateStringSplice(oldVal, newVal); + if (splice != null) diff[key] = splice; + continue; + } + + // Standard Value Replacement (Primitives or Lists) + if (!_areValuesEqual(oldVal, newVal)) { + diff[key] = newVal; + } + } + + // 2. Walk old keys to find Deletions + for (final key in oldObj.keys) { + if (!newObj.containsKey(key)) diff[key] = deleted; + } + + return diff; + } + + /// Calculates the minimal edit (splice) with EMOJI SAFETY + static TextSplice? _calculateStringSplice(String oldText, String newText) { + if (oldText == newText) return null; + + // A. Find Common Prefix + int start = 0; + final minLen = (oldText.length < newText.length) + ? oldText.length + : newText.length; + + while (start < minLen && + oldText.codeUnitAt(start) == newText.codeUnitAt(start)) { + start++; + } + + // SAFETY: If we stopped inside a Surrogate Pair (High Surrogate), backtrack. + if (start > 0 && start < oldText.length && start < newText.length) { + final prevCode = oldText.codeUnitAt(start - 1); + if (prevCode >= 0xD800 && prevCode <= 0xDBFF) { + start--; + } + } + + // B. Find Common Suffix + int oldEnd = oldText.length; + int newEnd = newText.length; + + while (oldEnd > start && + newEnd > start && + oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) { + oldEnd--; + newEnd--; + } + + // SAFETY: If we stopped inside a Surrogate Pair (Low Surrogate), expand the change area. + if (oldEnd < oldText.length && newEnd < newText.length) { + final code = oldText.codeUnitAt(oldEnd); + if (code >= 0xDC00 && code <= 0xDFFF) { + oldEnd++; + newEnd++; + } + } + + final deleteCount = oldEnd - start; + final insertText = newText.substring(start, newEnd); + + return TextSplice(start, deleteCount, insertText); + } + + static bool _areValuesEqual(dynamic a, dynamic b) { + if (a == b) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] != b[i]) return false; + return true; + } + return false; + } +} + +/// Helper to serialize the diff for the UI/Network +String serializeDiff(Map diff) { + // We use a custom encoder to handle our TextSplice and Deleted objects + return const JsonEncoder.withIndent(' ').convert( + jsonDecode( + jsonEncode( + diff, + toEncodable: (obj) { + if (obj is TextSplice) return obj.toJson(); + if (obj is Deleted) return obj.toJson(); + return obj; + }, + ), + ), + ); +} \ No newline at end of file diff --git a/packages/diff_algorithims/pubspec.yaml b/packages/diff_algorithims/pubspec.yaml new file mode 100644 index 0000000..778a067 --- /dev/null +++ b/packages/diff_algorithims/pubspec.yaml @@ -0,0 +1,12 @@ +name: diff_algorithims +description: A set of diffing algorithims for Dart +version: 0.0.1 +homepage: https://github.com/example/pocketbase_sync +publish_to: 'none' +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.38.5 +dev_dependencies: + test: ^1.26.3 diff --git a/packages/diff_algorithims/test/hybrid_diff_test.dart b/packages/diff_algorithims/test/hybrid_diff_test.dart new file mode 100644 index 0000000..b636705 --- /dev/null +++ b/packages/diff_algorithims/test/hybrid_diff_test.dart @@ -0,0 +1,225 @@ +import 'package:diff_algorithims/hybrid_diff.dart'; +import 'package:test/test.dart'; + +void main() { + group('HybridDiffer Structural Tests (Myers)', () { + test('Identical lists return no changes', () { + final list = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + final changes = HybridDiffer.diff(list, list, idField: 'id'); + + // We expect equal nodes or no nodes depending on filtering. + // The implementation returns Equal nodes for matches. + expect(changes.every((c) => c.op == ChangeOp.equal), isTrue); + expect(changes.length, equals(2)); + }); + + test('Detects simple insertion', () { + final oldList = [ + {'id': 1, 'val': 'a'}, + ]; + final newList = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(2)); + expect(changes[0].op, equals(ChangeOp.equal)); + expect(changes[1].op, equals(ChangeOp.insert)); + expect(changes[1].key, equals('2')); + }); + + test('Detects simple deletion', () { + final oldList = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + final newList = [ + {'id': 1, 'val': 'a'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(2)); + expect(changes[0].op, equals(ChangeOp.equal)); + expect(changes[1].op, equals(ChangeOp.delete)); + expect(changes[1].key, equals('2')); + }); + + test('Detects moves (Delete + Insert)', () { + // Myers detects moves as a delete of the old position and insert at new + final oldList = [ + {'id': 1}, + {'id': 2}, + {'id': 3}, + ]; + final newList = [ + {'id': 1}, + {'id': 3}, + {'id': 2}, + ]; // 2 moved to end + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + // Expected: Equal(1), Equal(3), Insert(2), Delete(2) OR Equal(1), Delete(2), Equal(3), Insert(2) + // Myers usually prefers deletes first if weights are equal, but let's check operations. + final ops = changes.map((c) => c.op).toList(); + + expect(ops, contains(ChangeOp.insert)); + expect(ops, contains(ChangeOp.delete)); + + final insertNode = changes.firstWhere((c) => c.op == ChangeOp.insert); + final deleteNode = changes.firstWhere((c) => c.op == ChangeOp.delete); + + expect(insertNode.key, equals(deleteNode.key)); + expect(['2', '3'], contains(insertNode.key)); + }); + }); + + group('HybridDiffer Content Tests (Deep Diff)', () { + test('Detects modification in primitive fields', () { + final oldList = [ + {'id': 1, 'name': 'Alice'}, + ]; + final newList = [ + {'id': 1, 'name': 'Bob'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(1)); + final mod = changes.first; + + expect(mod.op, equals(ChangeOp.modify)); + expect(mod.children, isNotNull); + expect(mod.children!.length, equals(1)); + + final fieldChange = mod.children!.first; + expect(fieldChange.key, equals('name')); + expect(fieldChange.oldValue, equals('Alice')); + expect(fieldChange.newValue, equals('Bob')); + }); + + test('Detects nested map changes recursively', () { + final oldList = [ + { + 'id': 1, + 'meta': {'ver': 1, 'author': 'me'}, + }, + ]; + final newList = [ + { + 'id': 1, + 'meta': {'ver': 2, 'author': 'me'}, + }, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final rootMod = changes.first; + + // Look inside the 'meta' field + final metaMod = rootMod.children!.firstWhere((c) => c.key == 'meta'); + expect(metaMod.op, equals(ChangeOp.modify)); + + // Look inside 'ver' field + final verMod = metaMod.children!.firstWhere((c) => c.key == 'ver'); + expect(verMod.oldValue, equals(1)); + expect(verMod.newValue, equals(2)); + }); + + test('Handles null values correctly', () { + final oldList = [ + {'id': 1, 'val': null}, + ]; + final newList = [ + {'id': 1, 'val': 'not null'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.key, equals('val')); + expect(fieldChange.oldValue, isNull); + expect(fieldChange.newValue, equals('not null')); + }); + }); + + group('String Splice Optimization', () { + test('Generates TextSplice for string changes', () { + final oldList = [ + {'id': 1, 'text': 'Hello World'}, + ]; + final newList = [ + {'id': 1, 'text': 'Hello Flutter World'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.key, equals('text')); + expect(fieldChange.splice, isNotNull); + + // "Hello " (len 6) match. Insert "Flutter ". + expect(fieldChange.splice!.index, equals(6)); + expect(fieldChange.splice!.deleteCount, equals(0)); + expect(fieldChange.splice!.insertText, equals('Flutter ')); + }); + + test('Handles Unicode Surrogate Pairs (Emoji Safety)', () { + // ๐Ÿคš is \uD83E\uDD1A (2 code units) + final oldList = [ + {'id': 1, 'text': 'Hi ๐Ÿคš'}, + ]; + final newList = [ + {'id': 1, 'text': 'Hi ๐Ÿคš there'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.splice, isNotNull); + // Ensure we didn't split the emoji. The index should differ by proper length. + // 'Hi ' is 3. Emoji is 2. Total 5. + expect(fieldChange.splice!.index, greaterThanOrEqualTo(5)); + expect(fieldChange.splice!.insertText, contains('there')); + }); + }); + + group('Performance / Stress Test', () { + test('Handles large lists efficiently', () { + // Generate 5000 items + final oldList = List>.generate( + 5000, + (i) => {'id': i, 'val': 'item $i'}, + ); + final newList = List>.from(oldList); + + // Make 3 changes + newList.removeAt(100); // Delete + newList.insert(4000, {'id': 9999, 'val': 'new'}); // Insert + // Index 2500 shifted to 2499 + newList[2499] = {'id': 2500, 'val': 'CHANGED'}; // Modify + + final stopwatch = Stopwatch()..start(); + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + stopwatch.stop(); + + print('Diff 5000 items took: ${stopwatch.elapsedMilliseconds}ms'); + + final deletes = changes.where((c) => c.op == ChangeOp.delete); + final inserts = changes.where((c) => c.op == ChangeOp.insert); + final modifies = changes.where((c) => c.op == ChangeOp.modify); + + expect(deletes.length, equals(1)); + expect(inserts.length, equals(1)); + expect(modifies.length, equals(1)); + + // Ensure it's reasonably fast (adjust threshold for your machine) + expect(stopwatch.elapsedMilliseconds, lessThan(500)); + }); + }); +} diff --git a/packages/diff_algorithims/test/myers_diff_test.dart b/packages/diff_algorithims/test/myers_diff_test.dart new file mode 100644 index 0000000..e047302 --- /dev/null +++ b/packages/diff_algorithims/test/myers_diff_test.dart @@ -0,0 +1,234 @@ +import 'dart:math'; +import 'package:diff_algorithims/myers_diff.dart'; +import 'package:test/test.dart'; + +void main() { + final differ = FastJsonDiffer(); + + final oldData = [ + {'id': 1, 'val': 'A', 'meta': 'complex_obj'}, + {'id': 2, 'val': 'B', 'meta': 'complex_obj'}, + {'id': 3, 'val': 'C', 'meta': 'complex_obj'}, + ]; + + final newData = [ + {'id': 1, 'val': 'A', 'meta': 'complex_obj'}, + {'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z) + {'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted + ]; + + group('FastJsonDiffer Scenarios', () { + test('Default (Deep Equality) detects content changes', () { + // Good for correctness, slower for large lists. + final ops = differ.diff(oldData, newData); + + // Filtering out 'equal' ops to check changes + final changes = ops.where((op) => op.type != DiffType.equal).toList(); + + // Expected changes: + // 1. Delete id:2 + // 2. Delete id:3 (old version) + // 3. Insert id:3 (new version) + // 4. Insert id:4 + + expect(changes.length, equals(4)); + + expect( + changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2), + isTrue, + ); + expect( + changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4), + isTrue, + ); + + // ID 3 changed, so it should appear as both delete and insert + expect( + changes.any( + (op) => + op.type == DiffType.delete && + op.data['id'] == 3 && + op.data['val'] == 'C', + ), + isTrue, + ); + expect( + changes.any( + (op) => + op.type == DiffType.insert && + op.data['id'] == 3 && + op.data['val'] == 'Z', + ), + isTrue, + ); + }); + + test( + 'Optimized (ID + Val Check) behaves like Deep Equality for relevant fields', + () { + // Here we ignore the 'meta' field entirely, but 'val' changed so result is same as above. + final ops = differ.diff( + oldData, + newData, + keyGenerator: (map) => Object.hash(map['id'], map['val']), + ); + + final changes = ops.where((op) => op.type != DiffType.equal).toList(); + + expect(changes.length, equals(4)); + + expect( + changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2), + isTrue, + ); + expect( + changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4), + isTrue, + ); + + // ID 3 changed val + expect( + changes.any((op) => op.type == DiffType.delete && op.data['id'] == 3), + isTrue, + ); + expect( + changes.any((op) => op.type == DiffType.insert && op.data['id'] == 3), + isTrue, + ); + }, + ); + + test('Ultra Fast (ID Only) detects moves/existence only', () { + // If we only care if the ID exists (e.g. for list animations where content updates later) + final ops = differ.diff( + oldData, + newData, + keyGenerator: (map) => map['id'] as int, + ); + + final changes = ops.where((op) => op.type != DiffType.equal).toList(); + + // Expected changes: + // 1. Delete id:2 + // 2. Insert id:4 + // Node 3 should be EQUAL because ID matches + + expect(changes.length, equals(2)); + + expect( + changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2), + isTrue, + ); + expect( + changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4), + isTrue, + ); + + // Ensure id:3 is considered Equal + final id3Ops = ops.where((op) => op.data['id'] == 3).toList(); + expect(id3Ops.length, equals(1)); + expect(id3Ops.first.type, equals(DiffType.equal)); + }); + }); + + group('Fuzz Tests', () { + test('Randomized Diff/Patch Consistency (500 Iterations)', () { + final r = Random(42); + + for (var i = 0; i < 500; i++) { + // 1. Generate two random lists + // We make listB a mutation of listA to ensure some overlap + final listA = _generateRandomList(r, size: r.nextInt(50) + 10); + final listB = _mutateList(r, listA); + + // 2. Diff + // Using "Ultra Fast" (ID only) mode since it's cleaner for simple integer maps + // or just standard map equality if we use defaults. + // Let's use standard map equality for robustness. + final ops = differ.diff(listA, listB); + + // 3. Reconstruct List B from Ops + final reconstructed = >[]; + + for (final op in ops) { + switch (op.type) { + case DiffType.equal: + case DiffType.insert: + reconstructed.add(op.data); + break; + case DiffType.delete: + // Deletions are skipped in the new list construction + break; + } + } + + // 4. Verify + try { + expect(reconstructed, equals(listB)); + } catch (e) { + print('Fuzz Failure at iteration $i'); + print('List A (len ${listA.length}): $listA'); + print('List B (len ${listB.length}): $listB'); + print('Ops: $ops'); + rethrow; + } + } + }); + }); +} + +// --- Fuzz Utils --- + +List> _generateRandomList(Random r, {required int size}) { + return List.generate(size, (index) { + return { + 'id': r.nextInt(1000), // Random IDs, duplicates possible + 'val': r.nextInt(100), + 'content': _randomString(r), + }; + }); +} + +List> _mutateList( + Random r, + List> original, +) { + final clone = List>.from(original); + final mutations = r.nextInt(10) + 1; // 1 to 10 mutations + + for (var m = 0; m < mutations; m++) { + if (clone.isEmpty) { + clone.add(_generateSingleItem(r)); + continue; + } + + final action = r.nextInt(3); + if (action == 0) { + // Insert + final index = r.nextInt(clone.length + 1); + clone.insert(index, _generateSingleItem(r)); + } else if (action == 1) { + // Delete + final index = r.nextInt(clone.length); + clone.removeAt(index); + } else { + // Modify (Replace item) + final index = r.nextInt(clone.length); + clone[index] = _generateSingleItem(r); + } + } + return clone; +} + +Map _generateSingleItem(Random r) { + return { + 'id': r.nextInt(1000), + 'val': r.nextInt(100), + // 'content': _randomString(r), // Keep simple + }; +} + +String _randomString(Random r) { + const chars = 'abcdefghijklmnopqrstuvwxyz'; + return List.generate(5, (_) => chars[r.nextInt(chars.length)]).join(); +} diff --git a/packages/diff_algorithims/test/object_trasnform_test.dart b/packages/diff_algorithims/test/object_trasnform_test.dart new file mode 100644 index 0000000..b7cdf54 --- /dev/null +++ b/packages/diff_algorithims/test/object_trasnform_test.dart @@ -0,0 +1,316 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:diff_algorithims/object_transform.dart'; +import 'package:test/test.dart'; + +// ============================================================================= +// --- HELPER: PATCH APPLICATOR --- +// ============================================================================= +// Since the provided code only included the Generator and UI, we need +// this helper in the test suite to verify the Diff actually works "Round Trip". + +Map applyPatchHelper( + Map original, + Map diff, +) { + final clone = Map.from(original); + + for (final key in diff.keys) { + var diffVal = diff[key]; + final originalVal = clone[key]; + + // Handle Deserialized JSON objects from serializeDiff + if (diffVal is Map && diffVal.containsKey('_op')) { + if (diffVal['_op'] == 'd') { + diffVal = const Deleted(); + } else if (diffVal['_op'] == 's') { + diffVal = TextSplice(diffVal['i'], diffVal['d'], diffVal['t']); + } + } + + if (diffVal is Deleted) { + clone.remove(key); + } else if (diffVal is TextSplice && originalVal is String) { + clone[key] = diffVal.apply(originalVal); + } else if (diffVal is Map && + originalVal is Map) { + clone[key] = applyPatchHelper(originalVal, diffVal); + } else { + clone[key] = diffVal; + } + } + return clone; +} + +// ============================================================================= +// --- THE TEST SUITE --- +// ============================================================================= + +void main() { + group('1. TextSplice Logic', () { + test('Correctly splices into the middle of a string', () { + final splice = TextSplice(6, 0, "Dart "); // Insert at index 6 + final original = "Hello World"; + final result = splice.apply(original); + expect(result, "Hello Dart World"); + }); + + test('Correctly handles deletions', () { + final splice = TextSplice(0, 5, ""); // Delete first 5 chars + final original = "Hello World"; + final result = splice.apply(original); + expect(result, " World"); + }); + + test('Correctly handles replacements', () { + // "brown" starts at 10, length 5. Replace with "red". + final splice = TextSplice(10, 5, "red"); + final original = "The quick brown fox"; + final result = splice.apply(original); + expect(result, "The quick red fox"); + }); + + test('Handles index out of bounds gracefully (Append)', () { + final splice = TextSplice(100, 0, "!"); + final original = "Hi"; + final result = splice.apply(original); + expect(result, "Hi!"); + }); + }); + + group('2. DiffEngine Basic Logic', () { + test('Detects Added Keys', () { + final oldMap = {'a': 1}; + final newMap = {'a': 1, 'b': 2}; + final diff = DiffEngine.generateDiff(oldMap, newMap); + expect(diff, {'b': 2}); + }); + + test('Detects Deleted Keys', () { + final oldMap = {'a': 1, 'b': 2}; + final newMap = {'a': 1}; + final diff = DiffEngine.generateDiff(oldMap, newMap); + expect(diff['b'], isA()); + }); + + test('Detects Value Changes (Primitives)', () { + final oldMap = {'a': 1}; + final newMap = {'a': 99}; + final diff = DiffEngine.generateDiff(oldMap, newMap); + expect(diff, {'a': 99}); + }); + + test('Treats Lists as Atomic Replacements', () { + // The engine is designed to replace lists entirely, not diff indices + final oldMap = { + 'tags': ['a', 'b'], + }; + final newMap = { + 'tags': ['a', 'c'], + }; + final diff = DiffEngine.generateDiff(oldMap, newMap); + expect(diff['tags'], ['a', 'c']); + }); + + test('Recursively Diffs Nested Maps', () { + final oldMap = { + 'meta': {'v': 1, 'active': true}, + }; + final newMap = { + 'meta': {'v': 2, 'active': true}, + }; + final diff = DiffEngine.generateDiff(oldMap, newMap); + + expect(diff.containsKey('meta'), true); + expect(diff['meta'], {'v': 2}); // Should NOT contain 'active' + }); + }); + + group('3. Emoji & Unicode Safety (Crucial)', () { + test('Does NOT split a surrogate pair when deleting', () { + // ๐Ÿ‘‹ is \uD83D\uDC4B (2 code units) + final oldMap = {'msg': "Hi ๐Ÿ‘‹"}; + final newMap = {'msg': "Hi "}; + + final diff = DiffEngine.generateDiff(oldMap, newMap); + final splice = diff['msg'] as TextSplice; + + // It should delete 2 units (the whole emoji), not 1 + expect(splice.deleteCount, 2, reason: "Should delete full emoji"); + expect(splice.index, 3); + }); + + test( + 'Handles changing one emoji to another (Surrogate boundary check)', + () { + // ๐Ÿ‘‹ (\uD83D\uDC4B) -> ๐Ÿคš (\uD83E\uDD1A) + // Note: They share the high surrogate \uD83... in some encodings, + // or simply look similar. The algorithm must not get confused. + final oldMap = {'msg': "A ๐Ÿ‘‹ B"}; + final newMap = {'msg': "A ๐Ÿคš B"}; + + final diff = DiffEngine.generateDiff(oldMap, newMap); + final splice = diff['msg'] as TextSplice; + + // Should recognize the change at the emoji + expect(splice.insertText, "๐Ÿคš"); + // Applying it should result in valid string + final patched = applyPatchHelper(oldMap, diff); + expect(patched['msg'], "A ๐Ÿคš B"); + }, + ); + + test('Handles Complex ZWJ Emojis (Family)', () { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 11 chars long + final family = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; + final oldMap = {'icon': "Family: $family"}; + final newMap = {'icon': "Family: "}; // Deleted + + final diff = DiffEngine.generateDiff(oldMap, newMap); + final splice = diff['icon'] as TextSplice; + + // Should delete exactly the length of the emoji + space + // Length of "Family: " is 8. + expect(splice.index, 8); + expect(splice.deleteCount, family.length); + }); + }); + + group('4. Serialization', () { + test('Serializes TextSplice and Deleted correctly', () { + final diff = { + 'bio': TextSplice(5, 1, "a"), + 'oldField': const Deleted(), + 'simple': 123, + }; + + final jsonStr = serializeDiff(diff); + final decoded = jsonDecode(jsonStr); + + expect(decoded['bio']['_op'], 's'); + expect(decoded['bio']['i'], 5); + expect(decoded['oldField']['_op'], 'd'); + expect(decoded['simple'], 123); + }); + + test('Round Trip: Serialize -> Deserialize -> Apply', () { + final oldState = {'text': "Hello"}; + final diff = {'text': TextSplice(5, 0, " World")}; + + final jsonStr = serializeDiff(diff); + final decodedDiff = jsonDecode(jsonStr); // Raw JSON Maps + + // Our helper must handle the raw JSON maps with _op + final patched = applyPatchHelper(oldState, decodedDiff); + expect(patched['text'], "Hello World"); + }); + }); + + group('5. Fuzz Testing (Chaos Monkey)', () { + // This generates random maps, mutates them, calculates diff, + // and verifies that Old + Diff == New. + test('Randomized Property Test (1000 iterations)', () { + final r = Random(42); // Seed for reproducibility + + for (int i = 0; i < 1000; i++) { + final stateA = _generateRandomMap(r, depth: 3); + final stateB = _mutateMap(r, stateA); + + try { + // 1. Generate + final diff = DiffEngine.generateDiff(stateA, stateB); + + // 2. Simulate Network (Serialize/Deserialize) + final jsonStr = serializeDiff(diff); + final decodedDiff = jsonDecode(jsonStr); + + // 3. Patch + final reconstructedB = applyPatchHelper(stateA, decodedDiff); + + // 4. Verify + final isEqual = jsonEncode(stateB) == jsonEncode(reconstructedB); + if (!isEqual) { + fail(''' +Fuzz Failure at iteration $i +Original: $stateA +Target: $stateB +Diff: $decodedDiff +Result: $reconstructedB +'''); + } + } catch (e, stack) { + fail('Crash at iteration $i: $e\n$stack'); + } + } + }); + }); +} + +// ============================================================================= +// --- FUZZER UTILITIES --- +// ============================================================================= + +Map _generateRandomMap(Random r, {int depth = 2}) { + final map = {}; + final keyCount = r.nextInt(5); + + for (int i = 0; i < keyCount; i++) { + final key = 'k$i'; + if (depth > 0 && r.nextBool()) { + map[key] = _generateRandomMap(r, depth: depth - 1); + } else { + map[key] = _generateRandomValue(r); + } + } + return map; +} + +dynamic _generateRandomValue(Random r) { + final type = r.nextInt(4); + if (type == 0) return r.nextInt(100); + if (type == 1) return r.nextBool(); + if (type == 2) { + // Random String with occasional Emoji + if (r.nextBool()) return "Test ${r.nextInt(100)}"; + return "Hello ๐Ÿ‘‹ ${r.nextInt(10)}"; + } + if (type == 3) return [1, 2, 3]; // Simple list + return null; +} + +Map _mutateMap(Random r, Map original) { + // Deep copy via JSON + final clone = jsonDecode(jsonEncode(original)) as Map; + final keys = clone.keys.toList(); + + // 1. Add new key + if (r.nextBool() || keys.isEmpty) { + clone['new_${r.nextInt(100)}'] = 'added'; + return clone; // Return early to keep mutations simple per step + } + + // 2. Delete key + if (r.nextBool()) { + clone.remove(keys[r.nextInt(keys.length)]); + return clone; + } + + // 3. Modify existing + final key = keys[r.nextInt(keys.length)]; + final val = clone[key]; + + if (val is Map) { + clone[key] = _mutateMap(r, val); + } else if (val is String) { + // String mutation + if (val.isNotEmpty) { + clone[key] = val + " appended"; + } else { + clone[key] = "New"; + } + } else { + clone[key] = "Changed Primitive"; + } + + return clone; +} diff --git a/packages/pocketbase_auth/.gitignore b/packages/pocketbase_auth/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/packages/pocketbase_auth/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/pocketbase_auth/.metadata b/packages/pocketbase_auth/.metadata new file mode 100644 index 0000000..829937b --- /dev/null +++ b/packages/pocketbase_auth/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: android + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: ios + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: linux + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: macos + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: web + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: windows + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/pocketbase_auth/LICENSE b/packages/pocketbase_auth/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/pocketbase_auth/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/pocketbase_auth/README.md b/packages/pocketbase_auth/README.md new file mode 100644 index 0000000..2d6bf48 --- /dev/null +++ b/packages/pocketbase_auth/README.md @@ -0,0 +1,16 @@ +# thermal_halo + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/pocketbase_auth/analysis_options.yaml b/packages/pocketbase_auth/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/pocketbase_auth/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/pocketbase_auth/example/.gitignore b/packages/pocketbase_auth/example/.gitignore new file mode 100644 index 0000000..5689576 --- /dev/null +++ b/packages/pocketbase_auth/example/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +./pocketbase +pb_data/ diff --git a/packages/pocketbase_auth/example/.metadata b/packages/pocketbase_auth/example/.metadata new file mode 100644 index 0000000..829937b --- /dev/null +++ b/packages/pocketbase_auth/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: android + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: ios + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: linux + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: macos + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: web + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: windows + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/pocketbase_auth/example/README.md b/packages/pocketbase_auth/example/README.md new file mode 100644 index 0000000..2b3fce4 --- /dev/null +++ b/packages/pocketbase_auth/example/README.md @@ -0,0 +1,16 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/pocketbase_auth/example/analysis_options.yaml b/packages/pocketbase_auth/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/pocketbase_auth/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/pocketbase_auth/example/android/.gitignore b/packages/pocketbase_auth/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/packages/pocketbase_auth/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/packages/pocketbase_auth/example/android/app/build.gradle.kts b/packages/pocketbase_auth/example/android/app/build.gradle.kts new file mode 100644 index 0000000..c3d837e --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/packages/pocketbase_auth/example/android/app/src/debug/AndroidManifest.xml b/packages/pocketbase_auth/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/main/AndroidManifest.xml b/packages/pocketbase_auth/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..74a78b9 --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/pocketbase_auth/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..ac81bae --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/pocketbase_auth/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/drawable/launch_background.xml b/packages/pocketbase_auth/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/packages/pocketbase_auth/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/values-night/styles.xml b/packages/pocketbase_auth/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/main/res/values/styles.xml b/packages/pocketbase_auth/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/pocketbase_auth/example/android/app/src/profile/AndroidManifest.xml b/packages/pocketbase_auth/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/pocketbase_auth/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/pocketbase_auth/example/android/build.gradle.kts b/packages/pocketbase_auth/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/packages/pocketbase_auth/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/pocketbase_auth/example/android/gradle.properties b/packages/pocketbase_auth/example/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/packages/pocketbase_auth/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/packages/pocketbase_auth/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/pocketbase_auth/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/packages/pocketbase_auth/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/packages/pocketbase_auth/example/android/settings.gradle.kts b/packages/pocketbase_auth/example/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/packages/pocketbase_auth/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/packages/pocketbase_auth/example/ios/.gitignore b/packages/pocketbase_auth/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/pocketbase_auth/example/ios/Flutter/AppFrameworkInfo.plist b/packages/pocketbase_auth/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/packages/pocketbase_auth/example/ios/Flutter/Debug.xcconfig b/packages/pocketbase_auth/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/pocketbase_auth/example/ios/Flutter/Release.xcconfig b/packages/pocketbase_auth/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/pocketbase_auth/example/ios/Podfile b/packages/pocketbase_auth/example/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.pbxproj b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..26d8d55 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,619 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/pocketbase_auth/example/ios/Runner/AppDelegate.swift b/packages/pocketbase_auth/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/pocketbase_auth/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/pocketbase_auth/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/ios/Runner/Base.lproj/Main.storyboard b/packages/pocketbase_auth/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/ios/Runner/Info.plist b/packages/pocketbase_auth/example/ios/Runner/Info.plist new file mode 100644 index 0000000..5458fc4 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/packages/pocketbase_auth/example/ios/Runner/Runner-Bridging-Header.h b/packages/pocketbase_auth/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/pocketbase_auth/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/pocketbase_auth/example/ios/RunnerTests/RunnerTests.swift b/packages/pocketbase_auth/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/packages/pocketbase_auth/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/pocketbase_auth/example/lib/main.dart b/packages/pocketbase_auth/example/lib/main.dart new file mode 100644 index 0000000..7051740 --- /dev/null +++ b/packages/pocketbase_auth/example/lib/main.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pocketbase_auth/pocketbase_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +late final AuthService authService; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 1. Initialize PocketBase + // Replace with your actual PocketBase URL + final pb = PocketBase('http://127.0.0.1:8090'); + + // 2. Initialize Shared Preferences + final prefs = await SharedPreferences.getInstance(); + final persistence = SharedPrefsAuthPersistence(prefs); + + // 3. Initialize Persistent Store + // Load initial auth data + final initialAuth = await persistence.loadAuthData(); + final authStore = PersistentAuthStore( + persistence: persistence, + initial: initialAuth, + ); + + // 4. Configure PocketBase with the custom store and httpClient + final pbRetry = PocketBase( + 'http://127.0.0.1:8090', + authStore: authStore, + httpClientFactory: () => clientFactory(pb), + ); + + authService = AuthService(pbRetry); + + runApp(const MyApp()); +} + +/// Converts a Stream into a Listenable for GoRouter +class GoRouterRefreshStream extends ChangeNotifier { + late final StreamSubscription _subscription; + + GoRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen( + (dynamic _) => notifyListeners(), + ); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + final router = GoRouter( + initialLocation: '/', + refreshListenable: GoRouterRefreshStream(authService.authStateChanges), + routes: [ + GoRoute(path: '/', builder: (context, state) => const HomeScreen()), + GoRoute( + path: '/login', + builder: (context, state) => PocketBaseLoginScreen( + authService: authService, + onSignup: () => context.push('/register'), + onForgotPassword: () => context.push('/forgot-password'), + ), + ), + GoRoute( + path: '/register', + builder: (context, state) => PocketBaseRegisterScreen( + authService: authService, + onLogin: () => context.pop(), + ), + ), + GoRoute( + path: '/forgot-password', + builder: (context, state) => PocketBaseForgotPasswordScreen( + authService: authService, + onBackToLogin: () => context.pop(), + ), + ), + GoRoute( + path: '/update-profile', + builder: (context, state) => + PocketBaseUpdateProfileScreen(authService: authService), + ), + GoRoute( + path: '/change-email', + builder: (context, state) => + PocketBaseChangeEmailScreen(authService: authService), + ), + GoRoute( + path: '/verify-email', + builder: (context, state) => + PocketBaseVerifyEmailScreen(authService: authService), + ), + GoRoute( + path: '/delete-account', + builder: (context, state) => + PocketBaseDeleteAccountScreen(authService: authService), + ), + ], + redirect: (context, state) { + final isLoggedIn = authService.currentUser != null; + final isLoggingIn = state.matchedLocation == '/login'; + final isRegistering = state.matchedLocation == '/register'; + final isForgot = state.matchedLocation == '/forgot-password'; + + if (!isLoggedIn && !isLoggingIn && !isRegistering && !isForgot) { + return '/login'; + } + + if (isLoggedIn && (isLoggingIn || isRegistering || isForgot)) { + return '/'; + } + + return null; + }, + ); + + return MaterialApp.router( + debugShowCheckedModeBanner: false, + title: 'PocketBase Auth Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + routerConfig: router, + ); + } +} + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final user = authService.currentUser; + return Scaffold( + appBar: AppBar( + title: const Text('Home'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () { + authService.logout(); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Welcome, ${user?.getStringValue('email') ?? 'User'}!'), + const SizedBox(height: 20), + const Text('You are securely logged in.'), + const SizedBox(height: 40), + Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => context.push('/update-profile'), + icon: const Icon(Icons.person), + label: const Text('Update Profile'), + ), + ElevatedButton.icon( + onPressed: () => context.push('/change-email'), + icon: const Icon(Icons.email), + label: const Text('Change Email'), + ), + ElevatedButton.icon( + onPressed: () => context.push('/verify-email'), + icon: const Icon(Icons.verified), + label: const Text('Verify Email'), + ), + ElevatedButton.icon( + onPressed: () => context.push('/delete-account'), + icon: const Icon(Icons.delete_forever), + label: const Text('Delete Account'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.withValues(alpha: 0.1), + foregroundColor: Colors.red, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/pocketbase_auth/example/linux/.gitignore b/packages/pocketbase_auth/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/pocketbase_auth/example/linux/CMakeLists.txt b/packages/pocketbase_auth/example/linux/CMakeLists.txt new file mode 100644 index 0000000..7a9a314 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/pocketbase_auth/example/linux/flutter/CMakeLists.txt b/packages/pocketbase_auth/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.cc b/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..f6f23bf --- /dev/null +++ b/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.h b/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/pocketbase_auth/example/linux/flutter/generated_plugins.cmake b/packages/pocketbase_auth/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f16b4c3 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/pocketbase_auth/example/linux/runner/CMakeLists.txt b/packages/pocketbase_auth/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/packages/pocketbase_auth/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/packages/pocketbase_auth/example/linux/runner/main.cc b/packages/pocketbase_auth/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/pocketbase_auth/example/linux/runner/my_application.cc b/packages/pocketbase_auth/example/linux/runner/my_application.cc new file mode 100644 index 0000000..27b4f86 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/packages/pocketbase_auth/example/linux/runner/my_application.h b/packages/pocketbase_auth/example/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/packages/pocketbase_auth/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/pocketbase_auth/example/macos/.gitignore b/packages/pocketbase_auth/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/packages/pocketbase_auth/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/pocketbase_auth/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/pocketbase_auth/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/pocketbase_auth/example/macos/Flutter/Flutter-Release.xcconfig b/packages/pocketbase_auth/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/pocketbase_auth/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/pocketbase_auth/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..997e35d --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/packages/pocketbase_auth/example/macos/Podfile b/packages/pocketbase_auth/example/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/pocketbase_auth/example/macos/Podfile.lock b/packages/pocketbase_auth/example/macos/Podfile.lock new file mode 100644 index 0000000..843ee00 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.pbxproj b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..60a0c4a --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,806 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 0BB35798475357D38ACB23F9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8925DDC17E6A90AEBA7F0FB4 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 764DF974AACA67F17C50CFCD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D46E0054A74D98A3C9ED2B35 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 11F08D8EE19C1509D799AFB7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 552EE6A09497F757C29A708C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8925DDC17E6A90AEBA7F0FB4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9EB2DF7CE6BC1C404ED9AA6F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + B490CC38BFFF7B19A475E78C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + CB75AD6C9D60604F4AE7A5B5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D46E0054A74D98A3C9ED2B35 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DA3E0CBD16D63F8736251F44 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 764DF974AACA67F17C50CFCD /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0BB35798475357D38ACB23F9 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + E92E7E330AFE1AB23BEFE1E0 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8925DDC17E6A90AEBA7F0FB4 /* Pods_Runner.framework */, + D46E0054A74D98A3C9ED2B35 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E92E7E330AFE1AB23BEFE1E0 /* Pods */ = { + isa = PBXGroup; + children = ( + CB75AD6C9D60604F4AE7A5B5 /* Pods-Runner.debug.xcconfig */, + DA3E0CBD16D63F8736251F44 /* Pods-Runner.release.xcconfig */, + 552EE6A09497F757C29A708C /* Pods-Runner.profile.xcconfig */, + 9EB2DF7CE6BC1C404ED9AA6F /* Pods-RunnerTests.debug.xcconfig */, + B490CC38BFFF7B19A475E78C /* Pods-RunnerTests.release.xcconfig */, + 11F08D8EE19C1509D799AFB7 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + B3418DFF8B4A8BB5561AC49E /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + BF0135C5FA93809F65316263 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 08441217EF48F374801FEE83 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 08441217EF48F374801FEE83 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + B3418DFF8B4A8BB5561AC49E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + BF0135C5FA93809F65316263 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9EB2DF7CE6BC1C404ED9AA6F /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B490CC38BFFF7B19A475E78C /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 11F08D8EE19C1509D799AFB7 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 9FK3425VTA; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 9FK3425VTA; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 9FK3425VTA; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_auth/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ac78810 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_auth/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/pocketbase_auth/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_auth/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_auth/example/macos/Runner/AppDelegate.swift b/packages/pocketbase_auth/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/packages/pocketbase_auth/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/pocketbase_auth/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/pocketbase_auth/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_auth/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/pocketbase_auth/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..dda9752 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright ยฉ 2025 com.example. All rights reserved. diff --git a/packages/pocketbase_auth/example/macos/Runner/Configs/Debug.xcconfig b/packages/pocketbase_auth/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/pocketbase_auth/example/macos/Runner/Configs/Release.xcconfig b/packages/pocketbase_auth/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/pocketbase_auth/example/macos/Runner/Configs/Warnings.xcconfig b/packages/pocketbase_auth/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/pocketbase_auth/example/macos/Runner/DebugProfile.entitlements b/packages/pocketbase_auth/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..08c3ab1 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/packages/pocketbase_auth/example/macos/Runner/Info.plist b/packages/pocketbase_auth/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/pocketbase_auth/example/macos/Runner/MainFlutterWindow.swift b/packages/pocketbase_auth/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/pocketbase_auth/example/macos/Runner/Release.entitlements b/packages/pocketbase_auth/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/packages/pocketbase_auth/example/macos/RunnerTests/RunnerTests.swift b/packages/pocketbase_auth/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/packages/pocketbase_auth/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/pocketbase_auth/example/pocketbase b/packages/pocketbase_auth/example/pocketbase new file mode 100755 index 0000000..824eee3 Binary files /dev/null and b/packages/pocketbase_auth/example/pocketbase differ diff --git a/packages/pocketbase_auth/example/pubspec.yaml b/packages/pocketbase_auth/example/pubspec.yaml new file mode 100644 index 0000000..3621182 --- /dev/null +++ b/packages/pocketbase_auth/example/pubspec.yaml @@ -0,0 +1,91 @@ +name: example +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + pocketbase_auth: + path: ../ + go_router: ^14.0.0 + shared_preferences: ^2.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/packages/pocketbase_auth/example/web/favicon.png b/packages/pocketbase_auth/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/pocketbase_auth/example/web/favicon.png differ diff --git a/packages/pocketbase_auth/example/web/icons/Icon-192.png b/packages/pocketbase_auth/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/packages/pocketbase_auth/example/web/icons/Icon-192.png differ diff --git a/packages/pocketbase_auth/example/web/icons/Icon-512.png b/packages/pocketbase_auth/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/packages/pocketbase_auth/example/web/icons/Icon-512.png differ diff --git a/packages/pocketbase_auth/example/web/icons/Icon-maskable-192.png b/packages/pocketbase_auth/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/pocketbase_auth/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/pocketbase_auth/example/web/icons/Icon-maskable-512.png b/packages/pocketbase_auth/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/pocketbase_auth/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/pocketbase_auth/example/web/index.html b/packages/pocketbase_auth/example/web/index.html new file mode 100644 index 0000000..29b5808 --- /dev/null +++ b/packages/pocketbase_auth/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + diff --git a/packages/pocketbase_auth/example/web/manifest.json b/packages/pocketbase_auth/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/packages/pocketbase_auth/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/pocketbase_auth/example/windows/.gitignore b/packages/pocketbase_auth/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/packages/pocketbase_auth/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/pocketbase_auth/example/windows/CMakeLists.txt b/packages/pocketbase_auth/example/windows/CMakeLists.txt new file mode 100644 index 0000000..d960948 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/pocketbase_auth/example/windows/flutter/CMakeLists.txt b/packages/pocketbase_auth/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.cc b/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..4f78848 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.h b/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/pocketbase_auth/example/windows/flutter/generated_plugins.cmake b/packages/pocketbase_auth/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..88b22e5 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/pocketbase_auth/example/windows/runner/CMakeLists.txt b/packages/pocketbase_auth/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/pocketbase_auth/example/windows/runner/Runner.rc b/packages/pocketbase_auth/example/windows/runner/Runner.rc new file mode 100644 index 0000000..289fc7e --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/pocketbase_auth/example/windows/runner/flutter_window.cpp b/packages/pocketbase_auth/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/pocketbase_auth/example/windows/runner/flutter_window.h b/packages/pocketbase_auth/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/pocketbase_auth/example/windows/runner/main.cpp b/packages/pocketbase_auth/example/windows/runner/main.cpp new file mode 100644 index 0000000..a61bf80 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/pocketbase_auth/example/windows/runner/resource.h b/packages/pocketbase_auth/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/pocketbase_auth/example/windows/runner/resources/app_icon.ico b/packages/pocketbase_auth/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/pocketbase_auth/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/pocketbase_auth/example/windows/runner/runner.exe.manifest b/packages/pocketbase_auth/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/packages/pocketbase_auth/example/windows/runner/utils.cpp b/packages/pocketbase_auth/example/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/pocketbase_auth/example/windows/runner/utils.h b/packages/pocketbase_auth/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/pocketbase_auth/example/windows/runner/win32_window.cpp b/packages/pocketbase_auth/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/pocketbase_auth/example/windows/runner/win32_window.h b/packages/pocketbase_auth/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/packages/pocketbase_auth/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/pocketbase_auth/lib/pocketbase_auth.dart b/packages/pocketbase_auth/lib/pocketbase_auth.dart new file mode 100644 index 0000000..50e6035 --- /dev/null +++ b/packages/pocketbase_auth/lib/pocketbase_auth.dart @@ -0,0 +1,19 @@ +export 'package:pocketbase/pocketbase.dart'; +export 'src/auth_service.dart'; +export 'src/auth_interceptor.dart'; +export 'src/persistent_auth_store.dart'; +export 'src/ui/login_screen.dart'; +export 'src/ui/register_screen.dart'; +export 'src/ui/forgot_password_screen.dart'; +export 'src/ui/update_profile_screen.dart'; +export 'src/ui/change_email_screen.dart'; +export 'src/ui/verify_email_screen.dart'; +export 'src/ui/delete_account_screen.dart'; + +export 'src/view_models/login_view_model.dart'; +export 'src/view_models/register_view_model.dart'; +export 'src/view_models/forgot_password_view_model.dart'; +export 'src/view_models/update_profile_view_model.dart'; +export 'src/view_models/change_email_view_model.dart'; +export 'src/view_models/verify_email_view_model.dart'; +export 'src/view_models/delete_account_view_model.dart'; diff --git a/packages/pocketbase_auth/lib/src/auth_interceptor.dart b/packages/pocketbase_auth/lib/src/auth_interceptor.dart new file mode 100644 index 0000000..be3c0d4 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/auth_interceptor.dart @@ -0,0 +1,50 @@ +import 'package:http/http.dart' as http; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:pocketbase/pocketbase.dart'; + +class TokenRetryPolicy extends RetryPolicy { + final PocketBase _pb; + + TokenRetryPolicy(this._pb); + + @override + // Limit retries to 1 to prevent infinite loops if the refresh itself fails. + int get maxRetryAttempts => 1; + + @override + Future shouldAttemptRetryOnResponse(BaseResponse response) async { + // Check for 401 Unauthorized status + if (response.statusCode == 401) { + // Check if we even have a token to refresh. + // If we are anonymous, a 401 is valid and shouldn't trigger a refresh. + if (!_pb.authStore.isValid) return false; + + // print('[Auth] Token expired (401). Attempting refresh...'); + + try { + // PERFORMS THE AUTO REFRESH + // The SDK's authRefresh method automatically rotates the token + // and updates the AuthStore via the save() callback we defined earlier. + await _pb.collection('users').authRefresh(); + + return true; // Signal to retry the request + } catch (e) { + // print('[Auth] Refresh failed: $e. Logging out.'); + // If refresh fails (e.g., user banned, password changed), + // we must clear the store to trigger a logout in the UI. + _pb.authStore.clear(); + return false; // Do not retry + } + } + return false; + } +} + +http.Client clientFactory(PocketBase pb) { + return InterceptedClient.build( + interceptors: [ + // Optional: Add logging interceptor here for debugging + ], + retryPolicy: TokenRetryPolicy(pb), + ); +} diff --git a/packages/pocketbase_auth/lib/src/auth_service.dart b/packages/pocketbase_auth/lib/src/auth_service.dart new file mode 100644 index 0000000..0b96dd3 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/auth_service.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'package:pocketbase/pocketbase.dart'; + +class AuthService { + final PocketBase _pb; + // Broadcast controller allows multiple listeners (UI, Router, Analytics) + final StreamController _userController = + StreamController.broadcast(); + + AuthService(this._pb) { + // 1. Emit Initial State + // We synchronously check the store. If valid, emit the model. + _emitCurrent(); + + // 2. Listen to PocketBase AuthStore changes + // The onChange stream fires whenever authStore.save() or authStore.clear() is called. + _pb.authStore.onChange.listen((event) { + _emitCurrent(); + }); + } + + void _emitCurrent() { + final model = _pb.authStore.record; + if (_pb.authStore.isValid && model is RecordModel) { + _userController.add(model); + } else { + _userController.add(null); + } + } + + // Expose the stream for the UI to consume. + // We use .distinct() to ensure the UI only rebuilds if the user object actually changes. + Stream get authStateChanges => + _userController.stream.distinct(); + + // Current value accessor for synchronous checks (e.g. inside guards) + RecordModel? get currentUser => _pb.authStore.record; + + // --- Actions --- + + Future login(String email, String password) async { + await _pb.collection('users').authWithPassword(email, password); + } + + Future signup(String email, String password) async { + await _pb + .collection('users') + .create( + body: { + 'email': email, + 'password': password, + 'passwordConfirm': password, + 'name': email.split('@').first, + }, + ); + // Auto-login after signup + await login(email, password); + } + + Future logout() async { + _pb.authStore.clear(); + } + + Future resetPassword(String email) async { + await _pb.collection('users').requestPasswordReset(email); + } + + Future updateProfile(Map body) async { + final user = currentUser; + if (user == null) { + throw ClientException( + url: Uri(), + response: {'message': 'User not logged in'}, + ); + } + return await _pb.collection('users').update(user.id, body: body); + } + + Future requestEmailChange(String newEmail) async { + final user = currentUser; + if (user == null) { + throw ClientException( + url: Uri(), + response: {'message': 'User not logged in'}, + ); + } + await _pb.collection('users').requestEmailChange(newEmail); + } + + Future requestVerification() async { + final user = currentUser; + if (user == null) { + throw ClientException( + url: Uri(), + response: {'message': 'User not logged in'}, + ); + } + await _pb + .collection('users') + .requestVerification(user.getStringValue('email')); + } + + Future deleteAccount() async { + final user = currentUser; + if (user == null) { + throw ClientException( + url: Uri(), + response: {'message': 'User not logged in'}, + ); + } + await _pb.collection('users').delete(user.id); + _pb.authStore.clear(); + } +} diff --git a/packages/pocketbase_auth/lib/src/persistent_auth_store.dart b/packages/pocketbase_auth/lib/src/persistent_auth_store.dart new file mode 100644 index 0000000..02ff4da --- /dev/null +++ b/packages/pocketbase_auth/lib/src/persistent_auth_store.dart @@ -0,0 +1,42 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pocketbase/pocketbase.dart'; + +/// Abstract contract for authentication persistence. +/// Implement this to use different storage solutions (e.g., Hive, SecureStorage). +abstract class AuthPersistence { + Future saveAuthData(String data); + Future loadAuthData(); + Future clearAuthData(); +} + +/// SharedPreferences implementation of [AuthPersistence]. +class SharedPrefsAuthPersistence implements AuthPersistence { + final SharedPreferences _prefs; + static const String _kAuthKey = 'pb_auth_token'; + + SharedPrefsAuthPersistence(this._prefs); + + @override + Future saveAuthData(String data) async { + await _prefs.setString(_kAuthKey, data); + } + + @override + Future loadAuthData() async { + return _prefs.getString(_kAuthKey); + } + + @override + Future clearAuthData() async { + await _prefs.remove(_kAuthKey); + } +} + +/// The custom AuthStore that connects PocketBase to any [AuthPersistence] implementation. +class PersistentAuthStore extends AsyncAuthStore { + PersistentAuthStore({required AuthPersistence persistence, super.initial}) + : super( + save: (String data) => persistence.saveAuthData(data), + clear: () => persistence.clearAuthData(), + ); +} diff --git a/packages/pocketbase_auth/lib/src/ui/change_email_screen.dart b/packages/pocketbase_auth/lib/src/ui/change_email_screen.dart new file mode 100644 index 0000000..c99c1a5 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/change_email_screen.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import '../auth_service.dart'; +import '../view_models/change_email_view_model.dart'; + +class PocketBaseChangeEmailScreen extends StatefulWidget { + final AuthService authService; + + const PocketBaseChangeEmailScreen({super.key, required this.authService}); + + @override + State createState() => + _PocketBaseChangeEmailScreenState(); +} + +class _PocketBaseChangeEmailScreenState + extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + late final ChangeEmailViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = ChangeEmailViewModel(widget.authService); + } + + Future _requestEmailChange() async { + if (!_formKey.currentState!.validate()) return; + + try { + await _viewModel.requestEmailChange(_emailController.text.trim()); + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.success) { + return Scaffold( + appBar: AppBar(title: const Text('Change Email')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Verification email sent!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Check your new email address for instructions to verify the change.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Go Back'), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Change Email')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Enter your new email address.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'New Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the new email'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading + ? null + : _requestEmailChange, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Request Change'), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _emailController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/delete_account_screen.dart b/packages/pocketbase_auth/lib/src/ui/delete_account_screen.dart new file mode 100644 index 0000000..975e974 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/delete_account_screen.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../auth_service.dart'; +import '../view_models/delete_account_view_model.dart'; + +class PocketBaseDeleteAccountScreen extends StatefulWidget { + final AuthService authService; + + const PocketBaseDeleteAccountScreen({super.key, required this.authService}); + + @override + State createState() => + _PocketBaseDeleteAccountScreenState(); +} + +class _PocketBaseDeleteAccountScreenState + extends State { + final _confirmController = TextEditingController(); + late final DeleteAccountViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = DeleteAccountViewModel(widget.authService); + } + + Future _deleteAccount() async { + if (_confirmController.text != 'DELETE') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please type DELETE to confirm.')), + ); + return; + } + + try { + await _viewModel.deleteAccount(); + // Navigation handled by auth state change + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + return Scaffold( + appBar: AppBar( + title: const Text('Delete Account'), + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.red, + size: 80, + ), + const SizedBox(height: 24), + const Text( + 'Are you sure you want to delete your account?', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'This action is irreversible. All your data will be permanently removed.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextField( + controller: _confirmController, + decoration: const InputDecoration( + labelText: 'Type DELETE to confirm', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading ? null : _deleteAccount, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Permanently Delete Account'), + ), + ], + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _confirmController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/forgot_password_screen.dart b/packages/pocketbase_auth/lib/src/ui/forgot_password_screen.dart new file mode 100644 index 0000000..8ef9de0 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/forgot_password_screen.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import '../auth_service.dart'; +import '../view_models/forgot_password_view_model.dart'; + +class PocketBaseForgotPasswordScreen extends StatefulWidget { + final AuthService authService; + final VoidCallback? onBackToLogin; + + const PocketBaseForgotPasswordScreen({ + super.key, + required this.authService, + this.onBackToLogin, + }); + + @override + State createState() => + _PocketBaseForgotPasswordScreenState(); +} + +class _PocketBaseForgotPasswordScreenState + extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + late final ForgotPasswordViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = ForgotPasswordViewModel(widget.authService); + } + + Future _resetPassword() async { + if (!_formKey.currentState!.validate()) return; + + try { + await _viewModel.resetPassword(_emailController.text.trim()); + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.success) { + return Scaffold( + appBar: AppBar(title: const Text('Forgot Password')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Password reset link sent!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Check your email for instructions to reset your password.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: widget.onBackToLogin, + child: const Text('Back to Login'), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Forgot Password')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Enter your email address to receive a password reset link.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading ? null : _resetPassword, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Send Reset Link'), + ), + if (widget.onBackToLogin != null) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: _viewModel.isLoading + ? null + : widget.onBackToLogin, + child: const Text('Back to Login'), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _emailController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/login_screen.dart b/packages/pocketbase_auth/lib/src/ui/login_screen.dart new file mode 100644 index 0000000..1c0550b --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/login_screen.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import '../../pocketbase_auth.dart'; +import '../auth_service.dart'; +import '../view_models/login_view_model.dart'; + +class PocketBaseLoginScreen extends StatefulWidget { + final AuthService authService; + final VoidCallback? onSignup; + final VoidCallback? onForgotPassword; + + const PocketBaseLoginScreen({ + super.key, + required this.authService, + this.onSignup, + this.onForgotPassword, + }); + + @override + State createState() => _PocketBaseLoginScreenState(); +} + +class _PocketBaseLoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + late final LoginViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = LoginViewModel(widget.authService); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + + try { + await _viewModel.login( + _emailController.text.trim(), + _passwordController.text.trim(), + ); + // Navigation is handled by auth state stream + } catch (_) { + // Error is handled in ViewModel and UI updates via listener + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading ? null : _login, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Login'), + ), + if (widget.onForgotPassword != null) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: _viewModel.isLoading + ? null + : widget.onForgotPassword, + child: const Text('Forgot Password?'), + ), + ], + if (widget.onSignup != null) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: _viewModel.isLoading + ? null + : widget.onSignup, + child: const Text('Create an account'), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/register_screen.dart b/packages/pocketbase_auth/lib/src/ui/register_screen.dart new file mode 100644 index 0000000..3ba5970 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/register_screen.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; + +import '../auth_service.dart'; +import '../view_models/register_view_model.dart'; + +class PocketBaseRegisterScreen extends StatefulWidget { + final AuthService authService; + final VoidCallback? onLogin; + + const PocketBaseRegisterScreen({ + super.key, + required this.authService, + this.onLogin, + }); + + @override + State createState() => + _PocketBaseRegisterScreenState(); +} + +class _PocketBaseRegisterScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + late final RegisterViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = RegisterViewModel(widget.authService); + } + + Future _signup() async { + if (!_formKey.currentState!.validate()) return; + + try { + await _viewModel.signup( + _emailController.text.trim(), + _passwordController.text.trim(), + _confirmPasswordController.text.trim(), + ); + // Navigation is handled by auth state stream + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('Register')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + decoration: const InputDecoration( + labelText: 'Confirm Password', + border: OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please confirm your password'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading ? null : _signup, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Register'), + ), + if (widget.onLogin != null) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: _viewModel.isLoading ? null : widget.onLogin, + child: const Text('Already have an account? Login'), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/update_profile_screen.dart b/packages/pocketbase_auth/lib/src/ui/update_profile_screen.dart new file mode 100644 index 0000000..d92665c --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/update_profile_screen.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import '../auth_service.dart'; +import '../view_models/update_profile_view_model.dart'; + +class PocketBaseUpdateProfileScreen extends StatefulWidget { + final AuthService authService; + + const PocketBaseUpdateProfileScreen({super.key, required this.authService}); + + @override + State createState() => + _PocketBaseUpdateProfileScreenState(); +} + +class _PocketBaseUpdateProfileScreenState + extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + late final UpdateProfileViewModel _viewModel; + bool _isInit = true; + + @override + void initState() { + super.initState(); + _viewModel = UpdateProfileViewModel(widget.authService); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_isInit) { + final user = widget.authService.currentUser; + if (user != null) { + _nameController.text = user.getStringValue('name'); + } + _isInit = false; + } + } + + Future _updateProfile() async { + if (!_formKey.currentState!.validate()) return; + + try { + await _viewModel.updateProfile(name: _nameController.text.trim()); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile updated successfully')), + ); + } + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('Update Profile')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _viewModel.isLoading ? null : _updateProfile, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Update Profile'), + ), + ], + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _nameController.dispose(); + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/ui/verify_email_screen.dart b/packages/pocketbase_auth/lib/src/ui/verify_email_screen.dart new file mode 100644 index 0000000..bc6afc2 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/ui/verify_email_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import '../auth_service.dart'; +import '../view_models/verify_email_view_model.dart'; + +class PocketBaseVerifyEmailScreen extends StatefulWidget { + final AuthService authService; + + const PocketBaseVerifyEmailScreen({super.key, required this.authService}); + + @override + State createState() => + _PocketBaseVerifyEmailScreenState(); +} + +class _PocketBaseVerifyEmailScreenState + extends State { + late final VerifyEmailViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = VerifyEmailViewModel(widget.authService); + } + + Future _requestVerification() async { + try { + await _viewModel.requestVerification(); + } catch (_) { + // Error handled by view model + } + } + + @override + Widget build(BuildContext context) { + // We need to re-fetch the user to check verification status, or rely on auth state updates. + // However, for this simple screen, we might just use the current user from authService. + // Ideally, the ViewModel should also expose the user or verification status if it changes. + // For now, let's keep using widget.authService.currentUser but maybe we should move that to VM too? + // Let's stick to the screen accessing authService for user info as it was doing. + final user = widget.authService.currentUser; + final isVerified = user?.getBoolValue('verified') ?? false; + + return ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.success) { + return Scaffold( + appBar: AppBar(title: const Text('Verify Email')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Verification email sent!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Check your email for instructions to verify your account.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Go Back'), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Verify Email')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isVerified) ...[ + const Icon(Icons.verified, color: Colors.green, size: 64), + const SizedBox(height: 16), + const Text( + 'Your email is verified!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ] else ...[ + const Icon( + Icons.warning_amber, + color: Colors.orange, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Your email is not verified.', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Email: ${user?.getStringValue('email')}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (_viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _viewModel.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + ElevatedButton( + onPressed: _viewModel.isLoading + ? null + : _requestVerification, + child: _viewModel.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Send Verification Email'), + ), + ], + ], + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _viewModel.dispose(); + super.dispose(); + } +} diff --git a/packages/pocketbase_auth/lib/src/utils.dart b/packages/pocketbase_auth/lib/src/utils.dart new file mode 100644 index 0000000..a6f7dca --- /dev/null +++ b/packages/pocketbase_auth/lib/src/utils.dart @@ -0,0 +1,11 @@ +import 'package:pocketbase/pocketbase.dart'; + +String parseErrorMessage(dynamic error) { + if (error is ClientException) { + final response = error.response; + if (response['message'] != null) { + return response['message'] as String; + } + } + return error.toString(); +} diff --git a/packages/pocketbase_auth/lib/src/view_models/change_email_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/change_email_view_model.dart new file mode 100644 index 0000000..cd8248c --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/change_email_view_model.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class ChangeEmailViewModel extends ChangeNotifier { + final AuthService _authService; + + ChangeEmailViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + bool _success = false; + bool get success => _success; + + Future requestEmailChange(String newEmail) async { + _isLoading = true; + _errorMessage = null; + _success = false; + notifyListeners(); + + try { + await _authService.requestEmailChange(newEmail); + _success = true; + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/delete_account_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/delete_account_view_model.dart new file mode 100644 index 0000000..43d1081 --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/delete_account_view_model.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class DeleteAccountViewModel extends ChangeNotifier { + final AuthService _authService; + + DeleteAccountViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + Future deleteAccount() async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + await _authService.deleteAccount(); + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/forgot_password_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/forgot_password_view_model.dart new file mode 100644 index 0000000..09587bd --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/forgot_password_view_model.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class ForgotPasswordViewModel extends ChangeNotifier { + final AuthService _authService; + + ForgotPasswordViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + bool _success = false; + bool get success => _success; + + Future resetPassword(String email) async { + _isLoading = true; + _errorMessage = null; + _success = false; + notifyListeners(); + + try { + await _authService.resetPassword(email); + _success = true; + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/login_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/login_view_model.dart new file mode 100644 index 0000000..b96ee6b --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/login_view_model.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class LoginViewModel extends ChangeNotifier { + final AuthService _authService; + + LoginViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + Future login(String email, String password) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + await _authService.login(email, password); + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/register_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/register_view_model.dart new file mode 100644 index 0000000..a7a68eb --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/register_view_model.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class RegisterViewModel extends ChangeNotifier { + final AuthService _authService; + + RegisterViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + Future signup( + String email, + String password, + String confirmPassword, + ) async { + if (password != confirmPassword) { + _errorMessage = "Passwords do not match"; + notifyListeners(); + return; // Or throw custom error + } + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + await _authService.signup(email, password); + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/update_profile_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/update_profile_view_model.dart new file mode 100644 index 0000000..b81d14b --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/update_profile_view_model.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class UpdateProfileViewModel extends ChangeNotifier { + final AuthService _authService; + + UpdateProfileViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + bool _success = false; + bool get success => _success; + + Future updateProfile({String? name, String? avatar}) async { + _isLoading = true; + _errorMessage = null; + _success = false; + notifyListeners(); + + try { + final body = {}; + if (name != null) body['name'] = name; + if (avatar != null) body['avatar'] = avatar; + + await _authService.updateProfile(body); + _success = true; + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/lib/src/view_models/verify_email_view_model.dart b/packages/pocketbase_auth/lib/src/view_models/verify_email_view_model.dart new file mode 100644 index 0000000..43d3bcc --- /dev/null +++ b/packages/pocketbase_auth/lib/src/view_models/verify_email_view_model.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import '../auth_service.dart'; +import '../utils.dart'; + +class VerifyEmailViewModel extends ChangeNotifier { + final AuthService _authService; + + VerifyEmailViewModel(this._authService); + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + bool _success = false; + bool get success => _success; + + Future requestVerification() async { + _isLoading = true; + _errorMessage = null; + _success = false; + notifyListeners(); + + try { + await _authService.requestVerification(); + _success = true; + } catch (e) { + _errorMessage = parseErrorMessage(e); + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/packages/pocketbase_auth/pubspec.yaml b/packages/pocketbase_auth/pubspec.yaml new file mode 100644 index 0000000..ec643bc --- /dev/null +++ b/packages/pocketbase_auth/pubspec.yaml @@ -0,0 +1,31 @@ +name: pocketbase_auth +description: Pocketbase auth client with built in UI screens and token refresh +publish_to: 'none' +version: 1.0.0+1 +homepage: https://github.com/fluttercommunity/flutter_whatsnew +maintainer: Rody Davis (@rodydavis) +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.38.5 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + http: ^1.6.0 + fetch_client: ^1.2.0 + pocketbase: ^0.23.0+1 + url_launcher: ^6.3.2 + stream_transform: ^2.1.1 + http_interceptor: ^2.0.0 + shared_preferences: ^2.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/packages/pocketbase_sync/CHANGELOG.md b/packages/pocketbase_sync/CHANGELOG.md new file mode 100644 index 0000000..35c42eb --- /dev/null +++ b/packages/pocketbase_sync/CHANGELOG.md @@ -0,0 +1,974 @@ +## v0.22.6 + +- Admin UI accessibility improvements: + - Fixed the dropdowns tab/enter/space keyboard navigation ([#4607](https://github.com/pocketbase/pocketbase/issues/4607)). + - Added `role`, `aria-label`, `aria-hidden` attributes to some of the elements in attempt to better assist screen readers. + + +## v0.22.5 + +- Minor test helpers fixes ([#4600](https://github.com/pocketbase/pocketbase/issues/4600)): + - Call the `OnTerminate` hook on `TestApp.Cleanup()`. + - Automatically run the DB migrations on initializing the test app with `tests.NewTestApp()`. + +- Added more elaborate warning message when restoring a backup explaining how the operation works. + +- Skip irregular files (symbolic links, sockets, etc.) when restoring a backup zip from the Admin UI or calling `archive.Extract(src, dst)` because they come with too many edge cases and ambiguities. +
+ More details + + This was initially reported as security issue (_thanks Harvey Spec_) but in the PocketBase context it is not something that can be exploited without an admin intervention and since the general expectations are that the PocketBase admins can do anything and they are the one who manage their server, this should be treated with the same diligence when using `scp`/`rsync`/`rclone`/etc. with untrusted file sources. + + It is not possible (_or at least I'm not aware how to do that easily_) to perform virus/malicious content scanning on the uploaded backup archive files and some caution is always required when using the Admin UI or running shell commands, hence the backup-restore warning text. + + **Or in other words, if someone sends you a file and tell you to upload it to your server (either as backup zip or manually via scp) obviously you shouldn't do that unless you really trust them.** + + PocketBase is like any other regular application that you run on your server and there is no builtin "sandbox" for what the PocketBase process can execute. This is left to the developers to restrict on application or OS level depending on their needs. If you are self-hosting PocketBase you usually don't have to do that, but if you are offering PocketBase as a service and allow strangers to run their own PocketBase instances on your server then you'll need to implement the isolation mechanisms on your own. +
+ + +## v0.22.4 + +- Removed conflicting styles causing the detailed codeblock log data preview to not visualize properly ([#4505](https://github.com/pocketbase/pocketbase/pull/4505)). + +- Minor JSVM improvements: + - Added `$filesystem.fileFromUrl(url, optSecTimeout)` helper. + - Implemented the `FormData` interface and added support for sending `multipart/form-data` requests with `$http.send()` ([#4544](https://github.com/pocketbase/pocketbase/discussions/4544)). + + +## v0.22.3 + +- Fixed the z-index of the current admin dropdown on Safari ([#4492](https://github.com/pocketbase/pocketbase/issues/4492)). + +- Fixed `OnAfterApiError` debug log `nil` error reference ([#4498](https://github.com/pocketbase/pocketbase/issues/4498)). + +- Added the field name as part of the `@request.data.someRelField.*` join to handle the case when a collection has 2 or more relation fields pointing to the same place ([#4500](https://github.com/pocketbase/pocketbase/issues/4500)). + +- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.1 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.1). + + +## v0.22.2 + +- Fixed a small regression introduced with v0.22.0 that was causing some missing unknown fields to always return an error instead of applying the specific `nullifyMisingField` resolver option to the query. + + +## v0.22.1 + +- Fixed Admin UI record and collection panels not reinitializing properly on browser back/forward navigation ([#4462](https://github.com/pocketbase/pocketbase/issues/4462)). + +- Initialize `RecordAuthWithOAuth2Event.IsNewRecord` for the `OnRecordBeforeAuthWithOAuth2Request` hook ([#4437](https://github.com/pocketbase/pocketbase/discussions/4437)). + +- Added error checks to the autogenerated Go migrations ([#4448](https://github.com/pocketbase/pocketbase/issues/4448)). + + +## v0.22.0 + +- Added Planning Center OAuth2 provider ([#4393](https://github.com/pocketbase/pocketbase/pull/4393); thanks @alxjsn). + +- Admin UI improvements: + - Autosync collection changes across multiple open browser tabs. + - Fixed vertical image popup preview scrolling. + - Added options to export a subset of collections. + - Added option to import a subset of collections without deleting the others ([#3403](https://github.com/pocketbase/pocketbase/issues/3403)). + +- Added support for back/indirect relation `filter`/`sort` (single and multiple). + The syntax to reference back relation fields is `yourCollection_via_yourRelField.*`. + โš ๏ธ To avoid excessive joins, the nested relations resolver is now limited to max 6 level depth (the same as `expand`). + _Note that in the future there will be also more advanced and granular options to specify a subset of the fields that are filterable/sortable._ + +- Added support for multiple back/indirect relation `expand` and updated the keys to use the `_via_` reference syntax (`yourCollection_via_yourRelField`). + _To minimize the breaking changes, the old parenthesis reference syntax (`yourCollection(yourRelField)`) will still continue to work but it is soft-deprecated and there will be a console log reminding you to change it to the new one._ + +- โš ๏ธ Collections and fields are no longer allowed to have `_via_` in their name to avoid collisions with the back/indirect relation reference syntax. + +- Added `jsvm.Config.OnInit` optional config function to allow registering custom Go bindings to the JSVM. + +- Added `@request.context` rule field that can be used to apply a different set of constraints based on the API rule execution context. + For example, to disallow user creation by an OAuth2 auth, you could set for the users Create API rule `@request.context != "oauth2"`. + The currently supported `@request.context` values are: + ``` + default + realtime + protectedFile + oauth2 + ``` + +- Adjusted the `cron.Start()` to start the ticker at the `00` second of the cron interval ([#4394](https://github.com/pocketbase/pocketbase/discussions/4394)). + _Note that the cron format has only minute granularity and there is still no guarantee that the scheduled job will be always executed at the `00` second._ + +- Fixed auto backups cron not reloading properly after app settings change ([#4431](https://github.com/pocketbase/pocketbase/discussions/4431)). + +- Upgraded to `aws-sdk-go-v2` and added special handling for GCS to workaround the previous [GCS headers signature issue](https://github.com/pocketbase/pocketbase/issues/2231) that we had with v2. + _This should also fix the SVG/JSON zero response when using Cloudflare R2 ([#4287](https://github.com/pocketbase/pocketbase/issues/4287#issuecomment-1925168142), [#2068](https://github.com/pocketbase/pocketbase/discussions/2068), [#2952](https://github.com/pocketbase/pocketbase/discussions/2952))._ + _โš ๏ธ If you are using S3 for uploaded files or backups, please verify that you have a green check in the Admin UI for your S3 configuration (I've tested the new version with GCS, MinIO, Cloudflare R2 and Wasabi)._ + +- Added `:each` modifier support for `file` and `relation` type fields (_previously it was supported only for `select` type fields_). + +- Other minor improvements (updated the `ghupdate` plugin to use the configured executable name when printing to the console, fixed the error reporting of `admin update/delete` commands, etc.). + + +## v0.21.3 + +- Ignore the JS required validations for disabled OIDC providers ([#4322](https://github.com/pocketbase/pocketbase/issues/4322)). + +- Allow `HEAD` requests to the `/api/health` endpoint ([#4310](https://github.com/pocketbase/pocketbase/issues/4310)). + +- Fixed the `editor` field value when visualized inside the View collection preview panel. + +- Manually clear all TinyMCE events on editor removal (_workaround for [tinymce#9377](https://github.com/tinymce/tinymce/issues/9377)_). + + +## v0.21.2 + +- Fixed `@request.auth.*` initialization side-effect which caused the current authenticated user email to not being returned in the user auth response ([#2173](https://github.com/pocketbase/pocketbase/issues/2173#issuecomment-1932332038)). + _The current authenticated user email should be accessible always no matter of the `emailVisibility` state._ + +- Fixed `RecordUpsert.RemoveFiles` godoc example. + +- Bumped to `NumCPU()+2` the `thumbGenSem` limit as some users reported that it was too restrictive. + + +## v0.21.1 + +- Small fix for the Admin UI related to the _Settings > Sync_ menu not being visible even when the "Hide controls" toggle is off. + + +## v0.21.0 + +- Added Bitbucket OAuth2 provider ([#3948](https://github.com/pocketbase/pocketbase/pull/3948); thanks @aabajyan). + +- Mark user as verified on confirm password reset ([#4066](https://github.com/pocketbase/pocketbase/issues/4066)). + _If the user email has changed after issuing the reset token (eg. updated by an admin), then the `verified` user state remains unchanged._ + +- Added support for loading a serialized json payload for `multipart/form-data` requests using the special `@jsonPayload` key. + _This is intended to be used primarily by the SDKs to resolve [js-sdk#274](https://github.com/pocketbase/js-sdk/issues/274)._ + +- Added graceful OAuth2 redirect error handling ([#4177](https://github.com/pocketbase/pocketbase/issues/4177)). + _Previously on redirect error we were returning directly a standard json error response. Now on redirect error we'll redirect to a generic OAuth2 failure screen (similar to the success one) and will attempt to auto close the OAuth2 popup._ + _The SDKs are also updated to handle the OAuth2 redirect error and it will be returned as Promise rejection of the `authWithOAuth2()` call._ + +- Exposed `$apis.gzip()` and `$apis.bodyLimit(bytes)` middlewares to the JSVM. + +- Added `TestMailer.SentMessages` field that holds all sent test app emails until cleanup. + +- Optimized the cascade delete of records with multiple `relation` fields. + +- Updated the `serve` and `admin` commands error reporting. + +- Minor Admin UI improvements (reduced the min table row height, added option to duplicate fields, added new TinyMCE codesample plugin languages, hide the collection sync settings when the `Settings.Meta.HideControls` is enabled, etc.) + + +## v0.20.7 + +- Fixed the Admin UI auto indexes update when renaming fields with a common prefix ([#4160](https://github.com/pocketbase/pocketbase/issues/4160)). + + +## v0.20.6 + +- Fixed JSVM types generation for functions with omitted arg types ([#4145](https://github.com/pocketbase/pocketbase/issues/4145)). + +- Updated Go deps. + + +## v0.20.5 + +- Minor CSS fix for the Admin UI to prevent the searchbar within a popup from expanding too much and pushing the controls out of the visible area ([#4079](https://github.com/pocketbase/pocketbase/issues/4079#issuecomment-1876994116)). + + +## v0.20.4 + +- Small fix for a regression introduced with the recent `json` field changes that was causing View collection column expressions recognized as `json` to fail to resolve ([#4072](https://github.com/pocketbase/pocketbase/issues/4072)). + + +## v0.20.3 + +- Fixed the `json` field query comparisons to work correctly with plain JSON values like `null`, `bool` `number`, etc. ([#4068](https://github.com/pocketbase/pocketbase/issues/4068)). + Since there are plans in the future to allow custom SQLite builds and also in some situations it may be useful to be able to distinguish `NULL` from `''`, + for the `json` fields (and for any other future non-standard field) we no longer apply `COALESCE` by default, aka.: + ``` + Dataset: + 1) data: json(null) + 2) data: json('') + + For the filter "data = null" only 1) will resolve to TRUE. + For the filter "data = ''" only 2) will resolve to TRUE. + ``` + +- Minor Go tests improvements + - Sorted the record cascade delete references to ensure that the delete operation will preserve the order of the fired events when running the tests. + - Marked some of the tests as safe for parallel execution to speed up a little the GitHub action build times. + + +## v0.20.2 + +- Added `sleep(milliseconds)` JSVM binding. + _It works the same way as Go `time.Sleep()`, aka. it pauses the goroutine where the JSVM code is running._ + +- Fixed multi-line text paste in the Admin UI search bar ([#4022](https://github.com/pocketbase/pocketbase/discussions/4022)). + +- Fixed the monospace font loading in the Admin UI. + +- Fixed various reported docs and code comment typos. + + +## v0.20.1 + +- Added `--dev` flag and its accompanying `app.IsDev()` method (_in place of the previously removed `--debug`_) to assist during development ([#3918](https://github.com/pocketbase/pocketbase/discussions/3918)). + The `--dev` flag prints in the console "everything" and more specifically: + - the data DB SQL statements + - all `app.Logger().*` logs (debug, info, warning, error, etc.), no matter of the logs persistence settings in the Admin UI + +- Minor Admin UI fixes: + - Fixed the log `error` label text wrapping. + - Added the log `referer` (_when it is from a different source_) and `details` labels in the logs listing. + - Removed the blank current time entry from the logs chart because it was causing confusion when used with custom time ranges. + - Updated the SQL syntax highlighter and keywords autocompletion in the Admin UI to recognize `CAST(x as bool)` expressions. + +- Replaced the default API tests timeout with a new `ApiScenario.Timeout` option ([#3930](https://github.com/pocketbase/pocketbase/issues/3930)). + A negative or zero value means no tests timeout. + If a single API test takes more than 3s to complete it will have a log message visible when the test fails or when `go test -v` flag is used. + +- Added timestamp at the beginning of the generated JSVM types file to avoid creating it everytime with the app startup. + + +## v0.20.0 + +- Added `expand`, `filter`, `fields`, custom query and headers parameters support for the realtime subscriptions. + _Requires JS SDK v0.20.0+ or Dart SDK v0.17.0+._ + + ```js + // JS SDK v0.20.0 + pb.collection("example").subscribe("*", (e) => { + ... + }, { + expand: "someRelField", + filter: "status = 'active'", + fields: "id,expand.someRelField.*:excerpt(100)", + }) + ``` + + ```dart + // Dart SDK v0.17.0 + pb.collection("example").subscribe("*", (e) { + ... + }, + expand: "someRelField", + filter: "status = 'active'", + fields: "id,expand.someRelField.*:excerpt(100)", + ) + ``` + +- Generalized the logs to allow any kind of application logs, not just requests. + + The new `app.Logger()` implements the standard [`log/slog` interfaces](https://pkg.go.dev/log/slog) available with Go 1.21. + ``` + // Go: https://pocketbase.io/docs/go-logging/ + app.Logger().Info("Example message", "total", 123, "details", "lorem ipsum...") + + // JS: https://pocketbase.io/docs/js-logging/ + $app.logger().info("Example message", "total", 123, "details", "lorem ipsum...") + ``` + + For better performance and to minimize blocking on hot paths, logs are currently written with + debounce and on batches: + - 3 seconds after the last debounced log write + - when the batch threshold is reached (currently 200) + - right before app termination to attempt saving everything from the existing logs queue + + Some notable log related changes: + + - โš ๏ธ Bumped the minimum required Go version to 1.21. + + - โš ๏ธ Removed `_requests` table in favor of the generalized `_logs`. + _Note that existing logs will be deleted!_ + + - โš ๏ธ Renamed the following `Dao` log methods: + ```go + Dao.RequestQuery(...) -> Dao.LogQuery(...) + Dao.FindRequestById(...) -> Dao.FindLogById(...) + Dao.RequestsStats(...) -> Dao.LogsStats(...) + Dao.DeleteOldRequests(...) -> Dao.DeleteOldLogs(...) + Dao.SaveRequest(...) -> Dao.SaveLog(...) + ``` + - โš ๏ธ Removed `app.IsDebug()` and the `--debug` flag. + This was done to avoid the confusion with the new logger and its debug severity level. + If you want to store debug logs you can set `-4` as min log level from the Admin UI. + + - Refactored Admin UI Logs: + - Added new logs table listing. + - Added log settings option to toggle the IP logging for the activity logger. + - Added log settings option to specify a minimum log level. + - Added controls to export individual or bulk selected logs as json. + - Other minor improvements and fixes. + +- Added new `filesystem/System.Copy(src, dest)` method to copy existing files from one location to another. + _This is usually useful when duplicating records with `file` field(s) programmatically._ + +- Added `filesystem.NewFileFromUrl(ctx, url)` helper method to construct a `*filesystem.BytesReader` file from the specified url. + +- OAuth2 related additions: + + - Added new `PKCE()` and `SetPKCE(enable)` OAuth2 methods to indicate whether the PKCE flow is supported or not. + _The PKCE value is currently configurable from the UI only for the OIDC providers._ + _This was added to accommodate OIDC providers that may throw an error if unsupported PKCE params are submitted with the auth request (eg. LinkedIn; see [#3799](https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312))._ + + - Added new `displayName` field for each `listAuthMethods()` OAuth2 provider item. + _The value of the `displayName` property is currently configurable from the UI only for the OIDC providers._ + + - Added `expiry` field to the OAuth2 user response containing the _optional_ expiration time of the OAuth2 access token ([#3617](https://github.com/pocketbase/pocketbase/discussions/3617)). + + - Allow a single OAuth2 user to be used for authentication in multiple auth collection. + _โš ๏ธ Because now you can have more than one external provider with `collectionId-provider-providerId` pair, `Dao.FindExternalAuthByProvider(provider, providerId)` method was removed in favour of the more generic `Dao.FindFirstExternalAuthByExpr(expr)`._ + +- Added `onlyVerified` auth collection option to globally disallow authentication requests for unverified users. + +- Added support for single line comments (ex. `// your comment`) in the API rules and filter expressions. + +- Added support for specifying a collection alias in `@collection.someCollection:alias.*`. + +- Soft-deprecated and renamed `app.Cache()` with `app.Store()`. + +- Minor JSVM updates and fixes: + + - Updated `$security.parseUnverifiedJWT(token)` and `$security.parseJWT(token, key)` to return the token payload result as plain object. + + - Added `$apis.requireGuestOnly()` middleware JSVM binding ([#3896](https://github.com/pocketbase/pocketbase/issues/3896)). + +- Use `IS NOT` instead of `!=` as not-equal SQL query operator to handle the cases when comparing with nullable columns or expressions (eg. `json_extract` over `json` field). + _Based on my local dataset I wasn't able to find a significant difference in the performance between the 2 operators, but if you stumble on a query that you think may be affected negatively by this, please report it and I'll test it further._ + +- Added `MaxSize` `json` field option to prevent storing large json data in the db ([#3790](https://github.com/pocketbase/pocketbase/issues/3790)). + _Existing `json` fields are updated with a system migration to have a ~2MB size limit (it can be adjusted from the Admin UI)._ + +- Fixed negative string number normalization support for the `json` field type. + +- Trigger the `app.OnTerminate()` hook on `app.Restart()` call. + _A new bool `IsRestart` field was also added to the `core.TerminateEvent` event._ + +- Fixed graceful shutdown handling and speed up a little the app termination time. + +- Limit the concurrent thumbs generation to avoid high CPU and memory usage in spiky scenarios ([#3794](https://github.com/pocketbase/pocketbase/pull/3794); thanks @t-muehlberger). + _Currently the max concurrent thumbs generation processes are limited to "total of logical process CPUs + 1"._ + _This is arbitrary chosen and may change in the future depending on the users feedback and usage patterns._ + _If you are experiencing OOM errors during large image thumb generations, especially in container environment, you can try defining the `GOMEMLIMIT=500MiB` env variable before starting the executable._ + +- Slightly speed up (~10%) the thumbs generation by changing from cubic (`CatmullRom`) to bilinear (`Linear`) resampling filter (_the quality difference is very little_). + +- Added a default red colored Stderr output in case of a console command error. + _You can now also silence individually custom commands errors using the `cobra.Command.SilenceErrors` field._ + +- Fixed links formatting in the autogenerated html->text mail body. + +- Removed incorrectly imported empty `local('')` font-face declarations. + + +## v0.19.4 + +- Fixed TinyMCE source code viewer textarea styles ([#3715](https://github.com/pocketbase/pocketbase/issues/3715)). + +- Fixed `text` field min/max validators to properly count multi-byte characters ([#3735](https://github.com/pocketbase/pocketbase/issues/3735)). + +- Allowed hyphens in `username` ([#3697](https://github.com/pocketbase/pocketbase/issues/3697)). + _More control over the system fields settings will be available in the future._ + +- Updated the JSVM generated types to use directly the value type instead of `* | undefined` union in functions/methods return declarations. + + +## v0.19.3 + +- Added the release notes to the console output of `./pocketbase update` ([#3685](https://github.com/pocketbase/pocketbase/discussions/3685)). + +- Added missing documentation for the JSVM `$mails.*` bindings. + +- Relaxed the OAuth2 redirect url validation to allow any string value ([#3689](https://github.com/pocketbase/pocketbase/pull/3689); thanks @sergeypdev). + _Note that the redirect url format is still bound to the accepted values by the specific OAuth2 provider._ + + +## v0.19.2 + +- Updated the JSVM generated types ([#3627](https://github.com/pocketbase/pocketbase/issues/3627), [#3662](https://github.com/pocketbase/pocketbase/issues/3662)). + + +## v0.19.1 + +- Fixed `tokenizer.Scan()/ScanAll()` to ignore the separators from the default trim cutset. + An option to return also the empty found tokens was also added via `Tokenizer.KeepEmptyTokens(true)`. + _This should fix the parsing of whitespace characters around view query column names when no quotes are used ([#3616](https://github.com/pocketbase/pocketbase/discussions/3616#discussioncomment-7398564))._ + +- Fixed the `:excerpt(max, withEllipsis?)` `fields` query param modifier to properly add space to the generated text fragment after block tags. + + +## v0.19.0 + +- Added Patreon OAuth2 provider ([#3323](https://github.com/pocketbase/pocketbase/pull/3323); thanks @ghostdevv). + +- Added mailcow OAuth2 provider ([#3364](https://github.com/pocketbase/pocketbase/pull/3364); thanks @thisni1s). + +- Added support for `:excerpt(max, withEllipsis?)` `fields` modifier that will return a short plain text version of any string value (html tags are stripped). + This could be used to minimize the downloaded json data when listing records with large `editor` html values. + ```js + await pb.collection("example").getList(1, 20, { + "fields": "*,description:excerpt(100)" + }) + ``` + +- Several Admin UI improvements: + - Count the total records separately to speed up the query execution for large datasets ([#3344](https://github.com/pocketbase/pocketbase/issues/3344)). + - Enclosed the listing scrolling area within the table so that the horizontal scrollbar and table header are always reachable ([#2505](https://github.com/pocketbase/pocketbase/issues/2505)). + - Allowed opening the record preview/update form via direct URL ([#2682](https://github.com/pocketbase/pocketbase/discussions/2682)). + - Reintroduced the local `date` field tooltip on hover. + - Speed up the listing loading times for records with large `editor` field values by initially fetching only a partial of the records data (the complete record data is loaded on record preview/update). + - Added "Media library" (collection images picker) support for the TinyMCE `editor` field. + - Added support to "pin" collections in the sidebar. + - Added support to manually resize the collections sidebar. + - More clear "Nonempty" field label style. + - Removed the legacy `.woff` and `.ttf` fonts and keep only `.woff2`. + +- Removed the explicit `Content-Type` charset from the realtime response due to compatibility issues with IIS ([#3461](https://github.com/pocketbase/pocketbase/issues/3461)). + _The `Connection:keep-alive` realtime response header was also removed as it is not really used with HTTP2 anyway._ + +- Added new JSVM bindings: + - `new Cookie({ ... })` constructor for creating `*http.Cookie` equivalent value. + - `new SubscriptionMessage({ ... })` constructor for creating a custom realtime subscription payload. + - Soft-deprecated `$os.exec()` in favour of `$os.cmd()` to make it more clear that the call only prepares the command and doesn't execute it. + +- โš ๏ธ Bumped the min required Go version to 1.19. + + +## v0.18.10 + +- Added global `raw` template function to allow outputting raw/verbatim HTML content in the JSVM templates ([#3476](https://github.com/pocketbase/pocketbase/discussions/3476)). + ``` + {{.description|raw}} + ``` + +- Trimmed view query semicolon and allowed single quotes for column aliases ([#3450](https://github.com/pocketbase/pocketbase/issues/3450#issuecomment-1748044641)). + _Single quotes are usually [not a valid identifier quote characters](https://www.sqlite.org/lang_keywords.html), but for resilience and compatibility reasons SQLite allows them in some contexts where only an identifier is expected._ + +- Bumped the GitHub action to use [min Go 1.21.2](https://github.com/golang/go/issues?q=milestone%3AGo1.21.2) (_the fixed issues are not critical as they are mostly related to the compiler/build tools_). + + +## v0.18.9 + +- Fixed empty thumbs directories not getting deleted on Windows after deleting a record img file ([#3382](https://github.com/pocketbase/pocketbase/issues/3382)). + +- Updated the generated JSVM typings to silent the TS warnings when trying to access a field/method in a Go->TS interface. + + +## v0.18.8 + +- Minor fix for the View collections API Preview and Admin UI listings incorrectly showing the `created` and `updated` fields as `N/A` when the view query doesn't have them. + + +## v0.18.7 + +- Fixed JS error in the Admin UI when listing records with invalid `relation` field value ([#3372](https://github.com/pocketbase/pocketbase/issues/3372)). + _This could happen usually only during custom SQL import scripts or when directly modifying the record field value without data validations._ + +- Updated Go deps and the generated JSVM types. + + +## v0.18.6 + +- Return the response headers and cookies in the `$http.send()` result ([#3310](https://github.com/pocketbase/pocketbase/discussions/3310)). + +- Added more descriptive internal error message for missing user/admin email on password reset requests. + +- Updated Go deps. + + +## v0.18.5 + +- Fixed minor Admin UI JS error in the auth collection options panel introduced with the change from v0.18.4. + + +## v0.18.4 + +- Added escape character (`\`) support in the Admin UI to allow using `select` field values with comma ([#2197](https://github.com/pocketbase/pocketbase/discussions/2197)). + + +## v0.18.3 + +- Exposed a global JSVM `readerToString(reader)` helper function to allow reading Go `io.Reader` values ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)). + +- Bumped the GitHub action to use [min Go 1.21.1](https://github.com/golang/go/issues?q=milestone%3AGo1.21.1+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `html/template` and `net/http` security fixes. + + +## v0.18.2 + +- Prevent breaking the record form in the Admin UI in case the browser's localStorage quota has been exceeded when uploading or storing large `editor` values ([#3265](https://github.com/pocketbase/pocketbase/issues/3265)). + +- Updated docs and missing JSVM typings. + +- Exposed additional crypto primitives under the `$security.*` JSVM namespace ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)): + ```js + // HMAC with SHA256 + $security.hs256("hello", "secret") + + // HMAC with SHA512 + $security.hs512("hello", "secret") + + // compare 2 strings with a constant time + $security.equal(hash1, hash2) + ``` + + +## v0.18.1 + +- Excluded the local temp dir from the backups ([#3261](https://github.com/pocketbase/pocketbase/issues/3261)). + + +## v0.18.0 + +- Simplified the `serve` command to accept domain name(s) as argument to reduce any additional manual hosts setup that sometimes previously was needed when deploying on production ([#3190](https://github.com/pocketbase/pocketbase/discussions/3190)). + ```sh + ./pocketbase serve yourdomain.com + ``` + +- Added `fields` wildcard (`*`) support. + +- Added option to upload a backup file from the Admin UI ([#2599](https://github.com/pocketbase/pocketbase/issues/2599)). + +- Registered a custom Deflate compressor to speedup (_nearly 2-3x_) the backups generation for the sake of a small zip size increase. + _Based on several local tests, `pb_data` of ~500MB (from which ~350MB+ are several hundred small files) results in a ~280MB zip generated for ~11s (previously it resulted in ~250MB zip but for ~35s)._ + +- Added the application name as part of the autogenerated backup name for easier identification ([#3066](https://github.com/pocketbase/pocketbase/issues/3066)). + +- Added new `SmtpConfig.LocalName` option to specify a custom domain name (or IP address) for the initial EHLO/HELO exchange ([#3097](https://github.com/pocketbase/pocketbase/discussions/3097)). + _This is usually required for verification purposes only by some SMTP providers, such as on-premise [Gmail SMTP-relay](https://support.google.com/a/answer/2956491)._ + +- Added `NoDecimal` `number` field option. + +- `editor` field improvements: + - Added new "Strip urls domain" option to allow controlling the default TinyMCE urls behavior (_default to `false` for new content_). + - Normalized pasted text while still preserving links, lists, tables, etc. formatting ([#3257](https://github.com/pocketbase/pocketbase/issues/3257)). + +- Added option to auto generate admin and auth record passwords from the Admin UI. + +- Added JSON validation and syntax highlight for the `json` field in the Admin UI ([#3191](https://github.com/pocketbase/pocketbase/issues/3191)). + +- Added datetime filter macros: + ``` + // all macros are UTC based + @second - @now second number (0-59) + @minute - @now minute number (0-59) + @hour - @now hour number (0-23) + @weekday - @now weekday number (0-6) + @day - @now day number + @month - @now month number + @year - @now year number + @todayStart - beginning of the current day as datetime string + @todayEnd - end of the current day as datetime string + @monthStart - beginning of the current month as datetime string + @monthEnd - end of the current month as datetime string + @yearStart - beginning of the current year as datetime string + @yearEnd - end of the current year as datetime string + ``` + +- Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)): + ``` + @yearly - "0 0 1 1 *" + @annually - "0 0 1 1 *" + @monthly - "0 0 1 * *" + @weekly - "0 0 * * 0" + @daily - "0 0 * * *" + @midnight - "0 0 * * *" + @hourly - "0 * * * *" + ``` + +- โš ๏ธ Added offset argument `Dao.FindRecordsByFilter(collection, filter, sort, limit, offset, [params...])`. + _If you don't need an offset, you can set it to `0`._ + +- To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input. + The placeholders are in the same format as when binding regular SQL parameters. + ```go + // unsanitized and untrusted filter variables + status := "..." + author := "..." + + app.Dao().FindFirstRecordByFilter("articles", "status={:status} && author={:author}", dbx.Params{ + "status": status, + "author": author, + }) + + app.Dao().FindRecordsByFilter("articles", "status={:status} && author={:author}", "-created", 10, 0, dbx.Params{ + "status": status, + "author": author, + }) + ``` + +- Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions. + +- Added JSVM helper crypto primitives under the `$security.*` namespace: + ```js + $security.md5(text) + $security.sha256(text) + $security.sha512(text) + ``` + +- โš ๏ธ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections. + +- โš ๏ธ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)). + +- โš ๏ธ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)). + +- โš ๏ธ Changes to `tests.ApiScenario` struct: + + - The `ApiScenario.AfterTestFunc` now receive as 3rd argument `*http.Response` pointer instead of `*echo.Echo` as the latter is not really useful in this context. + ```go + // old + AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) + + // new + AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) + ``` + + - The `ApiScenario.TestAppFactory` now accept the test instance as argument and no longer expect an error as return result ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025#discussioncomment-6592272)). + ```go + // old + TestAppFactory: func() (*tests.TestApp, error) + + // new + TestAppFactory: func(t *testing.T) *tests.TestApp + ``` + _Returning a `nil` app instance from the factory results in test failure. You can enforce a custom test failure by calling `t.Fatal(err)` inside the factory._ + +- Bumped the min required TLS version to 1.2 in order to improve the cert reputation score. + +- Reduced the default JSVM prewarmed pool size to 25 to reduce the initial memory consumptions (_you can manually adjust the pool size with `--hooksPool=50` if you need to, but the default should suffice for most cases_). + +- Update `gocloud.dev` dependency to v0.34 and explicitly set the new `NoTempDir` fileblob option to prevent the cross-device link error introduced with v0.33. + +- Other minor Admin UI and docs improvements. + + +## v0.17.7 + +- Fixed the autogenerated `down` migrations to properly revert the old collection rules in case a change was made in `up` ([#3192](https://github.com/pocketbase/pocketbase/pull/3192); thanks @impact-merlinmarek). + _Existing `down` migrations can't be fixed but that should be ok as usually the `down` migrations are rarely used against prod environments since they can cause data loss and, while not ideal, the previous old behavior of always setting the rules to `null/nil` is safer than not updating the rules at all._ + +- Updated some Go deps. + + +## v0.17.6 + +- Fixed JSVM `require()` file path error when using Windows-style path delimiters ([#3163](https://github.com/pocketbase/pocketbase/issues/3163#issuecomment-1685034438)). + + +## v0.17.5 + +- Added quotes around the wrapped view query columns introduced with v0.17.4. + + +## v0.17.4 + +- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)). + _With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._ + +- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)). + +- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev). + +- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)). + +- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it. + + +## v0.17.3 + +- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)). + +- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)). + +- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`. + +- Enabled lazy loading for the Admin UI thumb images. + + +## v0.17.2 + +- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })` + to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)). + The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_). + +- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)). + +- Fixed `apis.RequestData` deprecation log note ([#3068](https://github.com/pocketbase/pocketbase/pull/3068); thanks @gungjodi). + + +## v0.17.1 + +- Use relative path when redirecting to the OAuth2 providers page in the Admin UI to support subpath deployments ([#3026](https://github.com/pocketbase/pocketbase/pull/3026); thanks @sonyarianto). + +- Manually trigger the `OnBeforeServe` hook for `tests.ApiScenario` ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025)). + +- Trigger the JSVM `cronAdd()` handler only on app `serve` to prevent unexpected (and eventually duplicated) cron handler calls when custom console commands are used ([#3024](https://github.com/pocketbase/pocketbase/discussions/3024#discussioncomment-6592703)). + +- The `console.log()` messages are now written to the `stdout` instead of `stderr`. + + +## v0.17.0 + +- New more detailed guides for using PocketBase as framework (both Go and JS). + _If you find any typos or issues with the docs please report them in https://github.com/pocketbase/site._ + +- Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja). + They are available by default with the prebuilt executable if you create `*.pb.js` file(s) in the `pb_hooks` directory. + Lower your expectations because the integration comes with some limitations. For more details please check the [Extend with JavaScript](https://pocketbase.io/docs/js-overview/) guide. + Optionally, you can also enable the JS app hooks as part of a custom Go build for dynamic scripting but you need to register the `jsvm` plugin manually: + ```go + jsvm.MustRegister(app core.App, config jsvm.Config{}) + ``` + +- Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta). + +- Added VK OAuth2 provider ([#2533](https://github.com/pocketbase/pocketbase/pull/2533); thanks @imperatrona). + +- Added Yandex OAuth2 provider ([#2762](https://github.com/pocketbase/pocketbase/pull/2762); thanks @imperatrona). + +- Added new fields to `core.ServeEvent`: + ```go + type ServeEvent struct { + App App + Router *echo.Echo + // new fields + Server *http.Server // allows adjusting the HTTP server config (global timeouts, TLS options, etc.) + CertManager *autocert.Manager // allows adjusting the autocert options (cache dir, host policy, etc.) + } + ``` + +- Added `record.ExpandedOne(rel)` and `record.ExpandedAll(rel)` helpers to retrieve casted single or multiple expand relations from the already loaded "expand" Record data. + +- Added rule and filter record `Dao` helpers: + ```go + app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10) + app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true") + app.Dao().CanAccessRecord(record, requestInfo, rule) + ``` + +- Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks. + +- Use a default fetch function that will return all relations in case the `fetchFunc` argument of `Dao.ExpandRecord(record, expands, fetchFunc)` and `Dao.ExpandRecords(records, expands, fetchFunc)` is `nil`. + +- For convenience it is now possible to call `Dao.RecordQuery(collectionModelOrIdentifier)` with just the collection id or name. + In case an invalid collection id/name string is passed the query will be resolved with cancelled context error. + +- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`. + +- Added support for wrapped API errors (_in case Go 1.20+ is used with multiple wrapped errors, the first `apis.ApiError` takes precedence_). + +- Added `?download=1` file query parameter to the file serving endpoint to force the browser to always download the file and not show its preview. + +- Added new utility `github.com/pocketbase/pocketbase/tools/template` subpackage to assist with rendering HTML templates using the standard Go `html/template` and `text/template` syntax. + +- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)). + +- Soft-deprecated `security.NewToken()` in favor of `security.NewJWT()`. + +- `Hook.Add()` and `Hook.PreAdd` now returns a unique string identifier that could be used to remove the registered hook handler via `Hook.Remove(handlerId)`. + +- Changed the after* hooks to be called right before writing the user response, allowing users to return response errors from the after hooks. + There is also no longer need for returning explicitly `hook.StopPropagtion` when writing custom response body in a hook because we will skip the finalizer response body write if a response was already "committed". + +- โš ๏ธ Renamed `*Options{}` to `Config{}` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls: + ```go + old: pocketbase.NewWithConfig(config *pocketbase.Config) *pocketbase.PocketBase + new: pocketbase.NewWithConfig(config pocketbase.Config) *pocketbase.PocketBase + + old: core.NewBaseApp(config *core.BaseAppConfig) *core.BaseApp + new: core.NewBaseApp(config core.BaseAppConfig) *core.BaseApp + + old: apis.Serve(app core.App, options *apis.ServeOptions) error + new: apis.Serve(app core.App, config apis.ServeConfig) (*http.Server, error) + + old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions) + new: jsvm.MustRegister(app core.App, config jsvm.Config) + + old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options) + new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config) + + old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options) + new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config) + ``` + +- โš ๏ธ Changed the type of `subscriptions.Message.Data` from `string` to `[]byte` because `Data` usually is a json bytes slice anyway. + +- โš ๏ธ Renamed `models.RequestData` to `models.RequestInfo` and soft-deprecated `apis.RequestData(c)` in favor of `apis.RequestInfo(c)` to avoid the stuttering with the `Data` field. + _The old `apis.RequestData()` method still works to minimize the breaking changes but it is recommended to replace it with `apis.RequestInfo(c)`._ + +- โš ๏ธ Changes to the List/Search APIs + - Added new query parameter `?skipTotal=1` to skip the `COUNT` query performed with the list/search actions ([#2965](https://github.com/pocketbase/pocketbase/discussions/2965)). + If `?skipTotal=1` is set, the response fields `totalItems` and `totalPages` will have `-1` value (this is to avoid having different JSON responses and to differentiate from the zero default). + With the latest JS SDK 0.16+ and Dart SDK v0.11+ versions `skipTotal=1` is set by default for the `getFirstListItem()` and `getFullList()` requests. + + - The count and regular select statements also now executes concurrently, meaning that we no longer perform normalization over the `page` parameter and in case the user + request a page that doesn't exist (eg. `?page=99999999`) we'll return empty `items` array. + + - Reverted the default `COUNT` column to `id` as there are some common situations where it can negatively impact the query performance. + Additionally, from this version we also set `PRAGMA temp_store = MEMORY` so that also helps with the temp B-TREE creation when `id` is used. + _There are still scenarios where `COUNT` queries with `rowid` executes faster, but the majority of the time when nested relations lookups are used it seems to have the opposite effect (at least based on the benchmarks dataset)._ + +- โš ๏ธ Disallowed relations to views **from non-view** collections ([#3000](https://github.com/pocketbase/pocketbase/issues/3000)). + The change was necessary because I wasn't able to find an efficient way to track view changes and the previous behavior could have too many unexpected side-effects (eg. view with computed ids). + There is a system migration that will convert the existing view `relation` fields to `json` (multiple) and `text` (single) fields. + This could be a breaking change if you have `relation` to view and use `expand` or some of the `relation` view fields as part of a collection rule. + +- โš ๏ธ Added an extra `action` argument to the `Dao` hooks to allow skipping the default persist behavior. + In preparation for the logs generalization, the `Dao.After*Func` methods now also allow returning an error. + +- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)). + +- Fixed zero-default value not being used if the field is not explicitly set when manually creating records ([#2992](https://github.com/pocketbase/pocketbase/issues/2992)). + Additionally, `record.Get(field)` will now always return normalized value (the same as in the json serialization) for consistency and to avoid ambiguities with what is stored in the related DB table. + The schema fields columns `DEFAULT` definition was also updated for new collections to ensure that `NULL` values can't be accidentally inserted. + +- Fixed `migrate down` not returning the correct `lastAppliedMigrations()` when the stored migration applied time is in seconds. + +- Fixed realtime delete event to be called after the record was deleted from the DB (_including transactions and cascade delete operations_). + +- Other minor fixes and improvements (typos and grammar fixes, updated dependencies, removed unnecessary 404 error check in the Admin UI, etc.). + + +## v0.16.10 + +- Added multiple valued fields (`relation`, `select`, `file`) normalizations to ensure that the zero-default value of a newly created multiple field is applied for already existing data ([#2930](https://github.com/pocketbase/pocketbase/issues/2930)). + + +## v0.16.9 + +- Register the `eagerRequestInfoCache` middleware only for the internal `api` group routes to avoid conflicts with custom route handlers ([#2914](https://github.com/pocketbase/pocketbase/issues/2914)). + + +## v0.16.8 + +- Fixed unique validator detailed error message not being returned when camelCase field name is used ([#2868](https://github.com/pocketbase/pocketbase/issues/2868)). + +- Updated the index parser to allow no space between the table name and the columns list ([#2864](https://github.com/pocketbase/pocketbase/discussions/2864#discussioncomment-6373736)). + +- Updated go deps. + + +## v0.16.7 + +- Minor optimization for the list/search queries to use `rowid` with the `COUNT` statement when available. + _This eliminates the temp B-TREE step when executing the query and for large datasets (eg. 150k) it could have 10x improvement (from ~580ms to ~60ms)._ + + +## v0.16.6 + +- Fixed collection index column sort normalization in the Admin UI ([#2681](https://github.com/pocketbase/pocketbase/pull/2681); thanks @SimonLoir). + +- Removed unnecessary admins count in `apis.RequireAdminAuthOnlyIfAny()` middleware ([#2726](https://github.com/pocketbase/pocketbase/pull/2726); thanks @svekko). + +- Fixed `multipart/form-data` request bind not populating map array values ([#2763](https://github.com/pocketbase/pocketbase/discussions/2763#discussioncomment-6278902)). + +- Upgraded npm and Go dependencies. + + +## v0.16.5 + +- Fixed the Admin UI serialization of implicit relation display fields ([#2675](https://github.com/pocketbase/pocketbase/issues/2675)). + +- Reset the Admin UI sort in case the active sort collection field is renamed or deleted. + + +## v0.16.4 + +- Fixed the selfupdate command not working on Windows due to missing `.exe` in the extracted binary path ([#2589](https://github.com/pocketbase/pocketbase/discussions/2589)). + _Note that the command on Windows will work from v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._ + +- Added `int64`, `int32`, `uint`, `uint64` and `uint32` support when scanning `types.DateTime` ([#2602](https://github.com/pocketbase/pocketbase/discussions/2602)) + +- Updated dependencies. + + +## v0.16.3 + +- Fixed schema fields sort not working on Safari/Gnome Web ([#2567](https://github.com/pocketbase/pocketbase/issues/2567)). + +- Fixed default `PRAGMA`s not being applied for new connections ([#2570](https://github.com/pocketbase/pocketbase/discussions/2570)). + + +## v0.16.2 + +- Fixed backups archive not excluding the local `backups` directory on Windows ([#2548](https://github.com/pocketbase/pocketbase/discussions/2548#discussioncomment-5979712)). + +- Changed file field to not use `dataTransfer.effectAllowed` when dropping files since it is not reliable and consistent across different OS and browsers ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)). + +- Auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on Docker restart ([#2551](https://github.com/pocketbase/pocketbase/discussions/2551)). + +- Fixed missing view id field error message typo. + + +## v0.16.1 + +- Fixed backup restore not working in a container environment when `pb_data` is mounted as volume ([#2519](https://github.com/pocketbase/pocketbase/issues/2519)). + +- Fixed Dart SDK realtime API preview example ([#2523](https://github.com/pocketbase/pocketbase/pull/2523); thanks @xFrann). + +- Fixed typo in the backups create panel ([#2526](https://github.com/pocketbase/pocketbase/pull/2526); thanks @dschissler). + +- Removed unnecessary slice length check in `list.ExistInSlice` ([#2527](https://github.com/pocketbase/pocketbase/pull/2527); thanks @KunalSin9h). + +- Avoid mutating the cached request data on OAuth2 user create ([#2535](https://github.com/pocketbase/pocketbase/discussions/2535)). + +- Fixed Export Collections "Download as JSON" ([#2540](https://github.com/pocketbase/pocketbase/issues/2540)). + +- Fixed file field drag and drop not working in Firefox and Safari ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)). + + +## v0.16.0 + +- Added automated backups (_+ cron rotation_) APIs and UI for the `pb_data` directory. + The backups can be also initialized programmatically using `app.CreateBackup("backup.zip")`. + There is also experimental restore method - `app.RestoreBackup("backup.zip")` (_currently works only on UNIX systems as it relies on execve_). + The backups can be stored locally or in external S3 storage (_it has its own configuration, separate from the file uploads storage filesystem_). + +- Added option to limit the returned API fields using the `?fields` query parameter. + The "fields picker" is applied for `SearchResult.Items` and every other JSON response. For example: + ```js + // original: {"id": "RECORD_ID", "name": "abc", "description": "...something very big...", "items": ["id1", "id2"], "expand": {"items": [{"id": "id1", "name": "test1"}, {"id": "id2", "name": "test2"}]}} + // output: {"name": "abc", "expand": {"items": [{"name": "test1"}, {"name": "test2"}]}} + const result = await pb.collection("example").getOne("RECORD_ID", { + expand: "items", + fields: "name,expand.items.name", + }) + ``` + +- Added new `./pocketbase update` command to selfupdate the prebuilt executable (with option to generate a backup of your `pb_data`). + +- Added new `./pocketbase admin` console command: + ```sh + // creates new admin account + ./pocketbase admin create test@example.com 123456890 + + // changes the password of an existing admin account + ./pocketbase admin update test@example.com 0987654321 + + // deletes single admin account (if exists) + ./pocketbase admin delete test@example.com + ``` + +- Added `apis.Serve(app, options)` helper to allow starting the API server programmatically. + +- Updated the schema fields Admin UI for "tidier" fields visualization. + +- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP". + +- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_). + +- Added new `tools/cron` helper subpackage for scheduling task using cron-like syntax (_this eventually may get exported in the future in a separate repo_). + +- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix. + +- Added new `App.NewBackupsFilesystem()` helper to create a dedicated filesystem abstraction for managing app data backups. + +- Added new `App.OnTerminate()` hook (_executed right before app termination, eg. on `SIGTERM` signal_). + +- Added `accept` file field attribute with the field MIME types ([#2466](https://github.com/pocketbase/pocketbase/pull/2466); thanks @Nikhil1920). + +- Added support for multiple files sort in the Admin UI ([#2445](https://github.com/pocketbase/pocketbase/issues/2445)). + +- Added support for multiple relations sort in the Admin UI. + +- Added `meta.isNew` to the OAuth2 auth JSON response to indicate a newly OAuth2 created PocketBase user. diff --git a/packages/pocketbase_sync/LICENSE b/packages/pocketbase_sync/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/pocketbase_sync/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/pocketbase_sync/README.md b/packages/pocketbase_sync/README.md new file mode 100644 index 0000000..97b9557 --- /dev/null +++ b/packages/pocketbase_sync/README.md @@ -0,0 +1,123 @@ +# PocketBase Sync + +A robust, offline-first synchronization library for [PocketBase](https://pocketbase.io) and Flutter. + +This package provides a `PocketBaseSyncManager` that handles the complexities of syncing local data with a PocketBase backend, including conflict resolution, bandwidth optimization, and remote deletion support. + +## Features + +* **Offline-First**: Read and write data locally (e.g., using Drift or Hive) and sync when online. +* **3-Way Merge Strategy**: Automatically resolves conflicts. Server changes win on collision, but non-conflicting local changes are preserved. +* **Remote Deletion**: Detects records deleted on the server and removes them locally during sync. +* **Bandwidth Optimized**: + * **Incremental Pull**: Only fetches records updated since the last sync. + * **Differential Push**: Uses `HybridDiffer` (Myers diff) to send only changed fields to the server. +* **Tombstones**: Handles local deletions by marking records as deleted and removing them from the server during sync. +* **Auto-Cleanup**: Automatically cleans up expired tombstones to keep the local database tidy. + +## Installation + +Add `pocketbase_sync` to your `pubspec.yaml`: + +```yaml +dependencies: + pocketbase_sync: ^0.0.1 + pocketbase: ^0.18.0 +``` + +## Usage + +### 1. Implement SyncRepository + +You need to implement the `SyncRepository` interface to tell the manager how to store and retrieve data locally. This is typically done with a local database like Drift or Hive. + +```dart +class MyRepository implements SyncRepository { + // ... implement CRUD operations ... + + @override + Future>> getAll() async { + // Return all records (including deleted/messy ones) + } + + @override + Future getLastSyncTime() async { + // Return stored last sync timestamp + } + + @override + Future setLastSyncTime(DateTime time) async { + // Persist new sync timestamp + } +} +``` + +### 2. Initialize the Manager + +Create an instance of `PocketBaseSyncManager`, providing your PocketBase client, collection name, and repository. + +```dart +final pb = PocketBase('https://my-app.pockethost.io'); +final repository = MyRepository(); + +final manager = PocketBaseSyncManager( + pb: pb, + collection: 'notes', + repository: repository, + // Mappers to convert between your Model and JSON + fromJson: (json) => MyModel.fromJson(json), + toJson: (model) => model.toJson(), +); +``` + +### 3. Perform Operations + +Use the manager to perform operations instead of writing directly to your local DB or SDK. The manager ensures changes are tracked for sync. + +```dart +// Create +await manager.create( + manager.generateId(), + MyModel(content: 'Hello World') +); + +// Update +await manager.update('RECORD_ID', updatedModel); + +// Delete +await manager.delete('RECORD_ID'); +``` + +### 4. Sync + +Call `sync()` to push local changes and pull remote updates. You can do this on an interval, on app start, or manually. + +```dart +try { + await manager.sync(); + print('Sync completed successfully'); +} catch (e) { + print('Sync failed: $e'); +} +``` + +## How it Works + +### Push (Local to Remote) +1. Iterates through local records marked as `isDirty`. +2. If `isDeleted` is true, deletes the record on the server. +3. Otherwise, calculates a diff between the current data and the `baseData` (state at last sync). +4. Sends a PATCH request with only the changed fields. + +### Pull (Remote to Local) +1. Fetches records from PocketBase updated `updated >= lastSyncTime`. +2. **Reconciliation**: Fetches a list of **all** IDs from the server to detect deletions. +3. **Merge**: + * If a record exists locally and remotely, it performs a 3-way merge. + * If server data conflicts with local changes, server wins. + * If no conflict, updates are applied. +4. Updates the local `baseData` and clears the `isDirty` flag for synced records. + +## License + +MIT diff --git a/packages/pocketbase_sync/build.yaml b/packages/pocketbase_sync/build.yaml new file mode 100644 index 0000000..2a9825b --- /dev/null +++ b/packages/pocketbase_sync/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - example/** diff --git a/packages/pocketbase_sync/example/.gitignore b/packages/pocketbase_sync/example/.gitignore new file mode 100644 index 0000000..4eadf9d --- /dev/null +++ b/packages/pocketbase_sync/example/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +./pocketbase +pb_data/ \ No newline at end of file diff --git a/packages/pocketbase_sync/example/.metadata b/packages/pocketbase_sync/example/.metadata new file mode 100644 index 0000000..829937b --- /dev/null +++ b/packages/pocketbase_sync/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: android + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: ios + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: linux + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: macos + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: web + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: windows + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/pocketbase_sync/example/README.md b/packages/pocketbase_sync/example/README.md new file mode 100644 index 0000000..c5b49bd --- /dev/null +++ b/packages/pocketbase_sync/example/README.md @@ -0,0 +1,19 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + +U: rody.davis.jr@gmail.com +P: razroq-hedne5-cafdaT \ No newline at end of file diff --git a/packages/pocketbase_sync/example/analysis_options.yaml b/packages/pocketbase_sync/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/pocketbase_sync/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/pocketbase_sync/example/android/.gitignore b/packages/pocketbase_sync/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/packages/pocketbase_sync/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/packages/pocketbase_sync/example/android/app/build.gradle.kts b/packages/pocketbase_sync/example/android/app/build.gradle.kts new file mode 100644 index 0000000..c3d837e --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/packages/pocketbase_sync/example/android/app/src/debug/AndroidManifest.xml b/packages/pocketbase_sync/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/main/AndroidManifest.xml b/packages/pocketbase_sync/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..74a78b9 --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/pocketbase_sync/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..ac81bae --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/pocketbase_sync/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/drawable/launch_background.xml b/packages/pocketbase_sync/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/packages/pocketbase_sync/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/values-night/styles.xml b/packages/pocketbase_sync/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/main/res/values/styles.xml b/packages/pocketbase_sync/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/pocketbase_sync/example/android/app/src/profile/AndroidManifest.xml b/packages/pocketbase_sync/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/pocketbase_sync/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/pocketbase_sync/example/android/build.gradle.kts b/packages/pocketbase_sync/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/packages/pocketbase_sync/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/pocketbase_sync/example/android/gradle.properties b/packages/pocketbase_sync/example/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/packages/pocketbase_sync/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/packages/pocketbase_sync/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/pocketbase_sync/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/packages/pocketbase_sync/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/packages/pocketbase_sync/example/android/settings.gradle.kts b/packages/pocketbase_sync/example/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/packages/pocketbase_sync/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/packages/pocketbase_sync/example/ios/.gitignore b/packages/pocketbase_sync/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/pocketbase_sync/example/ios/Flutter/AppFrameworkInfo.plist b/packages/pocketbase_sync/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/packages/pocketbase_sync/example/ios/Flutter/Debug.xcconfig b/packages/pocketbase_sync/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/packages/pocketbase_sync/example/ios/Flutter/Release.xcconfig b/packages/pocketbase_sync/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.pbxproj b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..26d8d55 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,619 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9FK3425VTA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/pocketbase_sync/example/ios/Runner/AppDelegate.swift b/packages/pocketbase_sync/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/pocketbase_sync/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/pocketbase_sync/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/ios/Runner/Base.lproj/Main.storyboard b/packages/pocketbase_sync/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/ios/Runner/Info.plist b/packages/pocketbase_sync/example/ios/Runner/Info.plist new file mode 100644 index 0000000..5458fc4 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/packages/pocketbase_sync/example/ios/Runner/Runner-Bridging-Header.h b/packages/pocketbase_sync/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/pocketbase_sync/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/pocketbase_sync/example/ios/RunnerTests/RunnerTests.swift b/packages/pocketbase_sync/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/packages/pocketbase_sync/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/pocketbase_sync/example/lib/main.dart b/packages/pocketbase_sync/example/lib/main.dart new file mode 100644 index 0000000..68d0bbc --- /dev/null +++ b/packages/pocketbase_sync/example/lib/main.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +import 'ui/home_page.dart'; + +void main() { + // Setup Logging + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + if (kDebugMode) { + print('${record.level.name}: ${record.time}: ${record.message}'); + } + }); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'PocketBase Sync Example', + theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true), + home: const SyncHomePage(), + ); + } +} diff --git a/packages/pocketbase_sync/example/lib/models/message.dart b/packages/pocketbase_sync/example/lib/models/message.dart new file mode 100644 index 0000000..7ab3eb9 --- /dev/null +++ b/packages/pocketbase_sync/example/lib/models/message.dart @@ -0,0 +1,14 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +part 'message.mapper.dart'; + +@MappableClass() +class Message with MessageMappable { + final String id; + final String message; + final String author; + + Message({required this.id, required this.message, required this.author}); + + static const fromJson = MessageMapper.fromJson; +} diff --git a/packages/pocketbase_sync/example/lib/models/message.mapper.dart b/packages/pocketbase_sync/example/lib/models/message.mapper.dart new file mode 100644 index 0000000..0701a12 --- /dev/null +++ b/packages/pocketbase_sync/example/lib/models/message.mapper.dart @@ -0,0 +1,134 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'message.dart'; + +class MessageMapper extends ClassMapperBase { + MessageMapper._(); + + static MessageMapper? _instance; + static MessageMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = MessageMapper._()); + } + return _instance!; + } + + @override + final String id = 'Message'; + + static String _$id(Message v) => v.id; + static const Field _f$id = Field('id', _$id); + static String _$message(Message v) => v.message; + static const Field _f$message = Field('message', _$message); + static String _$author(Message v) => v.author; + static const Field _f$author = Field('author', _$author); + + @override + final MappableFields fields = const { + #id: _f$id, + #message: _f$message, + #author: _f$author, + }; + + static Message _instantiate(DecodingData data) { + return Message( + id: data.dec(_f$id), + message: data.dec(_f$message), + author: data.dec(_f$author), + ); + } + + @override + final Function instantiate = _instantiate; + + static Message fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static Message fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin MessageMappable { + String toJson() { + return MessageMapper.ensureInitialized().encodeJson( + this as Message, + ); + } + + Map toMap() { + return MessageMapper.ensureInitialized().encodeMap( + this as Message, + ); + } + + MessageCopyWith get copyWith => + _MessageCopyWithImpl( + this as Message, + $identity, + $identity, + ); + @override + String toString() { + return MessageMapper.ensureInitialized().stringifyValue(this as Message); + } + + @override + bool operator ==(Object other) { + return MessageMapper.ensureInitialized().equalsValue( + this as Message, + other, + ); + } + + @override + int get hashCode { + return MessageMapper.ensureInitialized().hashValue(this as Message); + } +} + +extension MessageValueCopy<$R, $Out> on ObjectCopyWith<$R, Message, $Out> { + MessageCopyWith<$R, Message, $Out> get $asMessage => + $base.as((v, t, t2) => _MessageCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class MessageCopyWith<$R, $In extends Message, $Out> + implements ClassCopyWith<$R, $In, $Out> { + $R call({String? id, String? message, String? author}); + MessageCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _MessageCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, Message, $Out> + implements MessageCopyWith<$R, Message, $Out> { + _MessageCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + MessageMapper.ensureInitialized(); + @override + $R call({String? id, String? message, String? author}) => $apply( + FieldCopyWithData({ + if (id != null) #id: id, + if (message != null) #message: message, + if (author != null) #author: author, + }), + ); + @override + Message $make(CopyWithData data) => Message( + id: data.get(#id, or: $value.id), + message: data.get(#message, or: $value.message), + author: data.get(#author, or: $value.author), + ); + + @override + MessageCopyWith<$R2, Message, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _MessageCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/pocketbase_sync/example/lib/services/log_service.dart b/packages/pocketbase_sync/example/lib/services/log_service.dart new file mode 100644 index 0000000..cf22a70 --- /dev/null +++ b/packages/pocketbase_sync/example/lib/services/log_service.dart @@ -0,0 +1,28 @@ +import 'package:logging/logging.dart'; +import 'package:flutter/foundation.dart'; + +class LogService extends ChangeNotifier { + static final LogService _instance = LogService._internal(); + factory LogService() => _instance; + LogService._internal(); + + final List _logs = []; + List get logs => List.unmodifiable(_logs); + + void init() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + _logs.add(record); + // Limit to last 1000 logs to prevent memory issues + if (_logs.length > 1000) { + _logs.removeAt(0); + } + notifyListeners(); + }); + } + + void clear() { + _logs.clear(); + notifyListeners(); + } +} diff --git a/packages/pocketbase_sync/example/lib/ui/home_page.dart b/packages/pocketbase_sync/example/lib/ui/home_page.dart new file mode 100644 index 0000000..dc7f6af --- /dev/null +++ b/packages/pocketbase_sync/example/lib/ui/home_page.dart @@ -0,0 +1,448 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:fetch_client/fetch_client.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; +import 'package:pocketbase_sync/sync_managers/in_memory.dart'; + +import '../models/message.dart'; +import '../services/log_service.dart' as app_logs; +import 'logs_page.dart'; + +class SyncHomePage extends StatefulWidget { + const SyncHomePage({super.key}); + + @override + State createState() => _SyncHomePageState(); +} + +class _SyncHomePageState extends State { + late final PocketBase pb; + late final InMemoryRepository repository; + late final PocketBaseSyncManager manager; + final _logger = Logger('SyncHomePage'); + + bool _isLoading = true; // Initial full-screen load + bool _isSyncing = false; // Background sync indicator + List> _records = []; + String? _currentUserEmail; + + // Editing State + String? _editingId; + late final TextEditingController _editController; + + // New State Variables + bool _autoSync = true; + bool _isOffline = false; + StreamSubscription? _updateSubscription; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + app_logs.LogService().init(); // Initialize Logger + + // Initialize synchronously to avoid LateInitializationError in build() + pb = PocketBase( + 'http://127.0.0.1:8090', + httpClientFactory: kIsWeb + ? () => FetchClient(streamRequests: true) + : null, + ); + repository = InMemoryRepository(); + manager = PocketBaseSyncManager( + pb: pb, + collection: 'messages', + fromJson: (j) => MessageMapper.fromMap(j), + toJson: (m) => m.toMap(), + repository: repository, + autoSyncInterval: const Duration(seconds: 5), + ); + + _updateSubscription = manager.onUpdate.listen((_) { + _refreshList(); + }); + + _editController = TextEditingController(); + + _init(); + } + + @override + void dispose() { + _editController.dispose(); + _updateSubscription?.cancel(); + manager.dispose(); + super.dispose(); + } + + Future _init() async { + // 1. Authenticate (Anonymous/Dummy for demo, or real auth) + try { + final id = Random().nextInt(10000); + final email = 'test_sync_$id}@example.com'; + final password = 'password123456'; + final name = 'Test User $id'; + + try { + await pb + .collection('users') + .create( + body: { + 'email': email, + 'password': password, + 'passwordConfirm': password, + 'name': name, + }, + ); + } catch (_) {} + + final auth = await pb + .collection('users') + .authWithPassword(email, password); + _currentUserEmail = auth.record?.getStringValue('email'); + } catch (e, stack) { + _logger.warning('Auth failed', e, stack); + } + + // Init Realtime Subscription + try { + manager.subscribe(); + } catch (e) { + _logger.warning('Realtime subscription failed', e); + } + + // Load initial empty state + await _refreshList(); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _refreshList() async { + final records = await repository.getAll(); + setState(() { + _records = records.where((r) => !r.isDeleted).toList(); + }); + } + + Future _sync() async { + if (_isOffline) { + return; + } + + setState(() => _isSyncing = true); // Show linear progress + try { + await manager.sync(); + await _refreshList(); + } catch (e, stack) { + _logger.severe('Sync failed', e, stack); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Sync failed: $e'))); + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + + void _toggleAutoSync(bool value) { + setState(() { + _autoSync = value; + }); + + if (_autoSync) { + manager.autoSyncInterval = const Duration(seconds: 5); + _sync(); // Initial sync + } else { + manager.autoSyncInterval = null; + } + } + + Future _addMessage() async { + final id = manager.generateId(); + final message = Message( + id: id, + message: 'Hello at ${DateTime.now().toIso8601String()}', + author: pb.authStore.model?.id ?? 'anon', + ); + + await manager.create(id, message); + await _refreshList(); + _refresh(); + } + + void _refresh() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(seconds: 1), _sync); + } + + void _handleEdit(SyncRecord record) { + setState(() { + _editingId = record.id; + _editController.text = record.data.message; + }); + } + + Future _saveEdit(String id, Message original) async { + final newText = _editController.text.trim(); + if (newText.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Message cannot be empty'))); + return; + } + + if (newText == original.message) { + _cancelEdit(); + return; + } + + try { + // Create updated copy + final updated = Message( + id: original.id, + message: newText, + author: original.author, + ); + + await manager.update(id, updated); + await _refreshList(); + _cancelEdit(); + _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Update failed: $e'))); + } + } + + void _cancelEdit() { + setState(() { + _editingId = null; + _editController.clear(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('PB Sync Example'), + actions: [ + if (_isSyncing || _isLoading) + const Padding( + padding: EdgeInsets.all(12.0), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ) + else + IconButton( + icon: const Icon(Icons.sync), + onPressed: (_isLoading || _isOffline) ? null : _sync, + ), + ], + ), + drawer: Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration(color: Colors.blue), + child: Text( + 'Sync Settings', + style: TextStyle(color: Colors.white, fontSize: 24), + ), + ), + ValueListenableBuilder( + valueListenable: manager.isConnectedNotifier, + builder: (context, isConnected, child) { + String modeText; + Color statusColor; + IconData icon; + + if (isConnected) { + modeText = 'Mode: Realtime'; + statusColor = Colors.green; + icon = Icons.bolt; + } else if (_autoSync) { + modeText = 'Mode: Polling (5s)'; + statusColor = Colors.orange; + icon = Icons.sync; + } else { + modeText = 'Mode: Manual'; + statusColor = Colors.grey; + icon = Icons.touch_app; + } + + return ListTile( + title: Text( + modeText, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + leading: Icon(icon, color: statusColor), + trailing: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + ); + }, + ), + const Divider(), + SwitchListTile( + title: const Text('Auto-Sync (5s)'), + value: _autoSync, + onChanged: _toggleAutoSync, + secondary: const Icon(Icons.autorenew), + ), + SwitchListTile( + title: const Text('Offline Mode'), + subtitle: const Text('Simulate network disconnect'), + value: _isOffline, + onChanged: (val) { + if (mounted) setState(() => _isOffline = val); + if (_isOffline) { + manager.unsubscribe(); + } else { + manager.subscribe(); + _sync(); + } + }, + secondary: const Icon(Icons.wifi_off), + ), + const Divider(), + ListTile( + title: const Text('View Logs'), + leading: const Icon(Icons.bug_report), + onTap: () { + Navigator.pop(context); // Close Drawer + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LogsPage()), + ); + }, + ), + ], + ), + ), + body: Column( + children: [ + if (_isOffline) + Container( + width: double.infinity, + color: Colors.redAccent, + padding: const EdgeInsets.all(8), + child: const Text( + 'OFFLINE MODE ENABLED', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + if (_currentUserEmail != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Logged in as: $_currentUserEmail'), + ), + Expanded( + child: _records.isEmpty + ? const Center(child: Text("No messages. Add one!")) + : ListView.builder( + itemCount: _records.length, + itemBuilder: (context, index) { + final record = _records[index]; + final isEditing = _editingId == record.id; + + return ListTile( + leading: Icon( + record.isDirty + ? Icons.cloud_upload + : Icons.cloud_done, + color: record.isDirty ? Colors.orange : Colors.green, + ), + title: isEditing + ? TextField( + controller: _editController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Edit message...', + border: OutlineInputBorder(), + ), + ) + : Text(record.data.message), + subtitle: Text('ID: ${record.id}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isEditing) ...[ + IconButton( + icon: const Icon( + Icons.check, + color: Colors.green, + ), + onPressed: () => + _saveEdit(record.id, record.data), + tooltip: 'Save', + ), + IconButton( + icon: const Icon( + Icons.close, + color: Colors.red, + ), + onPressed: _cancelEdit, + tooltip: 'Cancel', + ), + ] else ...[ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + try { + await manager.delete(record.id); + await _refreshList(); + _refresh(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Delete failed: $e'), + ), + ); + } + }, + ), + ], + ], + ), + onTap: isEditing ? null : () => _handleEdit(record), + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _addMessage, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/pocketbase_sync/example/lib/ui/logs_page.dart b/packages/pocketbase_sync/example/lib/ui/logs_page.dart new file mode 100644 index 0000000..0fe1d67 --- /dev/null +++ b/packages/pocketbase_sync/example/lib/ui/logs_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import '../services/log_service.dart'; + +class LogsPage extends StatelessWidget { + const LogsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('App Logs'), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + LogService().clear(); + }, + ), + ], + ), + body: AnimatedBuilder( + animation: LogService(), + builder: (context, child) { + final logs = LogService().logs.reversed.toList(); + if (logs.isEmpty) { + return const Center(child: Text('No logs collected yet.')); + } + return ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + return ListTile( + dense: true, + leading: Text( + log.time.toString().split('.').first, + style: const TextStyle(fontSize: 10), + ), + title: Text(log.message), + subtitle: Text(log.level.name), + trailing: log.error != null + ? const Icon(Icons.error, color: Colors.red) + : null, + ); + }, + ); + }, + ), + ); + } +} diff --git a/packages/pocketbase_sync/example/linux/.gitignore b/packages/pocketbase_sync/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/pocketbase_sync/example/linux/CMakeLists.txt b/packages/pocketbase_sync/example/linux/CMakeLists.txt new file mode 100644 index 0000000..7a9a314 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/pocketbase_sync/example/linux/flutter/CMakeLists.txt b/packages/pocketbase_sync/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.cc b/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.h b/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/pocketbase_sync/example/linux/flutter/generated_plugins.cmake b/packages/pocketbase_sync/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/pocketbase_sync/example/linux/runner/CMakeLists.txt b/packages/pocketbase_sync/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/packages/pocketbase_sync/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/packages/pocketbase_sync/example/linux/runner/main.cc b/packages/pocketbase_sync/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/pocketbase_sync/example/linux/runner/my_application.cc b/packages/pocketbase_sync/example/linux/runner/my_application.cc new file mode 100644 index 0000000..27b4f86 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/packages/pocketbase_sync/example/linux/runner/my_application.h b/packages/pocketbase_sync/example/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/packages/pocketbase_sync/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/pocketbase_sync/example/macos/.gitignore b/packages/pocketbase_sync/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/packages/pocketbase_sync/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/pocketbase_sync/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/pocketbase_sync/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/pocketbase_sync/example/macos/Flutter/Flutter-Release.xcconfig b/packages/pocketbase_sync/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/pocketbase_sync/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/pocketbase_sync/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..cccf817 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.pbxproj b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a548a46 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_sync/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ac78810 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/pocketbase_sync/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/pocketbase_sync/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/pocketbase_sync/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/pocketbase_sync/example/macos/Runner/AppDelegate.swift b/packages/pocketbase_sync/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/packages/pocketbase_sync/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/pocketbase_sync/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/pocketbase_sync/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/pocketbase_sync/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/pocketbase_sync/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..dda9752 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright ยฉ 2025 com.example. All rights reserved. diff --git a/packages/pocketbase_sync/example/macos/Runner/Configs/Debug.xcconfig b/packages/pocketbase_sync/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/pocketbase_sync/example/macos/Runner/Configs/Release.xcconfig b/packages/pocketbase_sync/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/pocketbase_sync/example/macos/Runner/Configs/Warnings.xcconfig b/packages/pocketbase_sync/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/pocketbase_sync/example/macos/Runner/DebugProfile.entitlements b/packages/pocketbase_sync/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..3ba6c12 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/packages/pocketbase_sync/example/macos/Runner/Info.plist b/packages/pocketbase_sync/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/pocketbase_sync/example/macos/Runner/MainFlutterWindow.swift b/packages/pocketbase_sync/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/pocketbase_sync/example/macos/Runner/Release.entitlements b/packages/pocketbase_sync/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/packages/pocketbase_sync/example/macos/RunnerTests/RunnerTests.swift b/packages/pocketbase_sync/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/packages/pocketbase_sync/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/pocketbase_sync/example/pb_collections.json b/packages/pocketbase_sync/example/pb_collections.json new file mode 100644 index 0000000..d7cfb90 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_collections.json @@ -0,0 +1,1047 @@ +[ + { + "id": "pbc_3142635823", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_superusers", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey__pbc_3323866339` ON `_superusers` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email__pbc_3323866339` ON `_superusers` (`email`) WHERE `email` != ''" + ], + "system": true, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": false, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "", + "username": "", + "avatarURL": "" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 86400 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 259200 + }, + "fileToken": { + "duration": 120 + }, + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + { + "id": "POWMOh0W6IoLUAI", + "listRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id", + "createRule": "", + "updateRule": "id = @request.auth.id", + "deleteRule": null, + "name": "users", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "_pbf_text_id_", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 10, + "hidden": true, + "id": "_pbf_auth_password_", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9_]{50}", + "hidden": true, + "id": "_pbf_auth_tokenKey_", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "_pbf_auth_email_", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "hidden": false, + "id": "_pbf_auth_emailVisibility_", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "_pbf_auth_verified_", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "users[0-9]{6}", + "hidden": false, + "id": "_pbf_auth_username_", + "max": 150, + "min": 3, + "name": "username", + "pattern": "^[\\w][\\w\\.\\-]*$", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "55rprfla", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "0aozkkxk", + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [ + "NO_UPLOADS_ALLOWED" + ], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "9acb2zxa", + "name": "website", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "hidden": false, + "id": "_pbf_autodate_created_", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "_pbf_autodate_updated_", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `_POWMOh0W6IoLUAI_username_idx` ON `users` (username COLLATE NOCASE)", + "CREATE UNIQUE INDEX `_POWMOh0W6IoLUAI_email_idx` ON `users` (email) WHERE email != ''", + "CREATE UNIQUE INDEX `_POWMOh0W6IoLUAI_tokenKey_idx` ON `users` (tokenKey)", + "CREATE INDEX `_POWMOh0W6IoLUAI_created_idx` ON `users` (`created`)" + ], + "system": false, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "", + "username": "username", + "avatarURL": "" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 1209600 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 604800 + }, + "fileToken": { + "duration": 120 + }, + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + { + "id": "pbc_4275539003", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_authOrigins", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4228609354", + "max": 0, + "min": 0, + "name": "fingerprint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)" + ], + "system": true + }, + { + "id": "pbc_2281828961", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_externalAuths", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2462348188", + "max": 0, + "min": 0, + "name": "provider", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1044722854", + "max": 0, + "min": 0, + "name": "providerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)", + "CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)" + ], + "system": true + }, + { + "id": "pbc_2279338944", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_mfas", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)" + ], + "system": true + }, + { + "id": "pbc_1638494021", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_otps", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 8, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 0, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "autogeneratePattern": "", + "hidden": true, + "id": "text3866985172", + "max": 0, + "min": 0, + "name": "sentTo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": true, + "type": "text" + } + ], + "indexes": [ + "CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)" + ], + "system": true + }, + { + "id": "XTpascjA7jyzB88", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id = author", + "updateRule": "@request.auth.id = author &&\n// disallow author change\n@request.body.author:changed = false", + "deleteRule": "@request.auth.id = author", + "name": "messages", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "_pbf_text_id_", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "bj1celjs", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "POWMOh0W6IoLUAI", + "hidden": false, + "id": "ifh2x9mu", + "maxSelect": 1, + "minSelect": 0, + "name": "author", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "_pbf_autodate_created_", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "_pbf_autodate_updated_", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `messages_created_idx` ON `messages` (`created`)" + ], + "system": false + }, + { + "id": "BHKW36mJl3ZPt6z", + "listRule": "active = true", + "viewRule": "active = true", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "posts", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "_pbf_text_id_", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "j8rjfhnz", + "max": 100, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "c7rgev2q", + "maxSize": 0, + "name": "description", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "aviu1gpy", + "name": "active", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "2vnzwepg", + "maxSelect": 2, + "name": "options", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "optionA", + "optionB", + "optionC" + ] + }, + { + "hidden": false, + "id": "zk7j77l9", + "maxSelect": 3, + "maxSize": 5242880, + "mimeTypes": [ + "NO_UPLOADS_ALLOWED" + ], + "name": "featuredImages", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [ + "100x200" + ], + "type": "file" + }, + { + "hidden": false, + "id": "_pbf_autodate_created_", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "_pbf_autodate_updated_", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `posts_created_idx` ON `posts` (`created`)" + ], + "system": false + }, + { + "id": "52zrtrl7k3z6sbl", + "listRule": "", + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "messagesReport", + "type": "view", + "fields": [ + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3208210256", + "max": 0, + "min": 0, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "POWMOh0W6IoLUAI", + "hidden": false, + "id": "_clone_hBmP", + "maxSelect": 1, + "minSelect": 0, + "name": "author", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "number3257917790", + "max": null, + "min": null, + "name": "total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + } + ], + "indexes": [], + "system": false, + "viewQuery": "SELECT\n (ROW_NUMBER() OVER()) as id,\n messages.author,\n count(messages.id) as total\nFROM messages\nGROUP BY messages.author" + } +] \ No newline at end of file diff --git a/packages/pocketbase_sync/example/pb_migrations/1766533320_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766533320_created_notes.js new file mode 100644 index 0000000..3f3a18e --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766533320_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534376_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534376_deleted_notes.js new file mode 100644 index 0000000..a44993a --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534376_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534397_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534397_created_notes.js new file mode 100644 index 0000000..27ae845 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534397_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": null, + "name": "notes", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534422_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534422_deleted_notes.js new file mode 100644 index 0000000..a61c330 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534422_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": null, + "name": "notes", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534434_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534434_created_notes.js new file mode 100644 index 0000000..27ae845 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534434_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": null, + "name": "notes", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534460_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534460_deleted_notes.js new file mode 100644 index 0000000..a61c330 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534460_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": null, + "name": "notes", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534544_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534544_created_notes.js new file mode 100644 index 0000000..3f3a18e --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534544_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534568_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534568_deleted_notes.js new file mode 100644 index 0000000..a44993a --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534568_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534599_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534599_created_notes.js new file mode 100644 index 0000000..b8d49da --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534599_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534625_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534625_deleted_notes.js new file mode 100644 index 0000000..fed0df2 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534625_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534642_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534642_created_notes.js new file mode 100644 index 0000000..b8d49da --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534642_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534668_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534668_deleted_notes.js new file mode 100644 index 0000000..fed0df2 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534668_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534731_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534731_created_notes.js new file mode 100644 index 0000000..b8d49da --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534731_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534755_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534755_deleted_notes.js new file mode 100644 index 0000000..fed0df2 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534755_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534850_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534850_created_notes.js new file mode 100644 index 0000000..b8d49da --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534850_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534877_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534877_deleted_notes.js new file mode 100644 index 0000000..fed0df2 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534877_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534895_created_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534895_created_notes.js new file mode 100644 index 0000000..b8d49da --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534895_created_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766534920_deleted_notes.js b/packages/pocketbase_sync/example/pb_migrations/1766534920_deleted_notes.js new file mode 100644 index 0000000..fed0df2 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766534920_deleted_notes.js @@ -0,0 +1,37 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3395098727"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + } + ], + "id": "pbc_3395098727", + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "name": "notes", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766535491_updated_messages.js b/packages/pocketbase_sync/example/pb_migrations/1766535491_updated_messages.js new file mode 100644 index 0000000..5e75766 --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766535491_updated_messages.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("XTpascjA7jyzB88") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id = author", + "viewRule": "@request.auth.id = author" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("XTpascjA7jyzB88") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/packages/pocketbase_sync/example/pb_migrations/1766535892_updated_messages.js b/packages/pocketbase_sync/example/pb_migrations/1766535892_updated_messages.js new file mode 100644 index 0000000..e0946fe --- /dev/null +++ b/packages/pocketbase_sync/example/pb_migrations/1766535892_updated_messages.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("XTpascjA7jyzB88") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("XTpascjA7jyzB88") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id = author", + "viewRule": "@request.auth.id = author" + }, collection) + + return app.save(collection) +}) diff --git a/packages/pocketbase_sync/example/pocketbase b/packages/pocketbase_sync/example/pocketbase new file mode 100755 index 0000000..824eee3 Binary files /dev/null and b/packages/pocketbase_sync/example/pocketbase differ diff --git a/packages/pocketbase_sync/example/pubspec.yaml b/packages/pocketbase_sync/example/pubspec.yaml new file mode 100644 index 0000000..545e338 --- /dev/null +++ b/packages/pocketbase_sync/example/pubspec.yaml @@ -0,0 +1,98 @@ +name: example +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + pocketbase: ^0.23.0+1 + provider: ^6.0.0 + pocketbase_sync: + path: ../ + dart_mappable: ^4.6.1 + logging: ^1.3.0 + fetch_client: ^1.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + dart_mappable_builder: ^4.6.3 + build_runner: ^2.10.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/packages/pocketbase_sync/example/web/favicon.png b/packages/pocketbase_sync/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/pocketbase_sync/example/web/favicon.png differ diff --git a/packages/pocketbase_sync/example/web/icons/Icon-192.png b/packages/pocketbase_sync/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/packages/pocketbase_sync/example/web/icons/Icon-192.png differ diff --git a/packages/pocketbase_sync/example/web/icons/Icon-512.png b/packages/pocketbase_sync/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/packages/pocketbase_sync/example/web/icons/Icon-512.png differ diff --git a/packages/pocketbase_sync/example/web/icons/Icon-maskable-192.png b/packages/pocketbase_sync/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/pocketbase_sync/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/pocketbase_sync/example/web/icons/Icon-maskable-512.png b/packages/pocketbase_sync/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/pocketbase_sync/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/pocketbase_sync/example/web/index.html b/packages/pocketbase_sync/example/web/index.html new file mode 100644 index 0000000..29b5808 --- /dev/null +++ b/packages/pocketbase_sync/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + diff --git a/packages/pocketbase_sync/example/web/manifest.json b/packages/pocketbase_sync/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/packages/pocketbase_sync/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/pocketbase_sync/example/windows/.gitignore b/packages/pocketbase_sync/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/packages/pocketbase_sync/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/pocketbase_sync/example/windows/CMakeLists.txt b/packages/pocketbase_sync/example/windows/CMakeLists.txt new file mode 100644 index 0000000..d960948 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/pocketbase_sync/example/windows/flutter/CMakeLists.txt b/packages/pocketbase_sync/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.cc b/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.h b/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/pocketbase_sync/example/windows/flutter/generated_plugins.cmake b/packages/pocketbase_sync/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/pocketbase_sync/example/windows/runner/CMakeLists.txt b/packages/pocketbase_sync/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/pocketbase_sync/example/windows/runner/Runner.rc b/packages/pocketbase_sync/example/windows/runner/Runner.rc new file mode 100644 index 0000000..289fc7e --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/pocketbase_sync/example/windows/runner/flutter_window.cpp b/packages/pocketbase_sync/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/pocketbase_sync/example/windows/runner/flutter_window.h b/packages/pocketbase_sync/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/pocketbase_sync/example/windows/runner/main.cpp b/packages/pocketbase_sync/example/windows/runner/main.cpp new file mode 100644 index 0000000..a61bf80 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/pocketbase_sync/example/windows/runner/resource.h b/packages/pocketbase_sync/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/pocketbase_sync/example/windows/runner/resources/app_icon.ico b/packages/pocketbase_sync/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/pocketbase_sync/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/pocketbase_sync/example/windows/runner/runner.exe.manifest b/packages/pocketbase_sync/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/packages/pocketbase_sync/example/windows/runner/utils.cpp b/packages/pocketbase_sync/example/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/pocketbase_sync/example/windows/runner/utils.h b/packages/pocketbase_sync/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/pocketbase_sync/example/windows/runner/win32_window.cpp b/packages/pocketbase_sync/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/pocketbase_sync/example/windows/runner/win32_window.h b/packages/pocketbase_sync/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/packages/pocketbase_sync/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/pocketbase_sync/lib/core/sync_interface.dart b/packages/pocketbase_sync/lib/core/sync_interface.dart new file mode 100644 index 0000000..3c49fcf --- /dev/null +++ b/packages/pocketbase_sync/lib/core/sync_interface.dart @@ -0,0 +1,7 @@ +import 'dart:async'; + +abstract class ISyncManager { + /// Syncs data with the remote source. + /// Should handle push (local -> remote) and pull (remote -> local). + Future sync(); +} diff --git a/packages/pocketbase_sync/lib/pocketbase_sync.dart b/packages/pocketbase_sync/lib/pocketbase_sync.dart new file mode 100644 index 0000000..a75a5d7 --- /dev/null +++ b/packages/pocketbase_sync/lib/pocketbase_sync.dart @@ -0,0 +1,6 @@ +library pocketbase_sync; + +export 'core/sync_interface.dart'; +export 'sync/pb_sync_manager.dart'; +export 'sync/pb_multi_sync_manager.dart'; +export 'repo/sync_repository.dart'; diff --git a/packages/pocketbase_sync/lib/repo/drift/drift_sync_repository.dart b/packages/pocketbase_sync/lib/repo/drift/drift_sync_repository.dart new file mode 100644 index 0000000..1364272 --- /dev/null +++ b/packages/pocketbase_sync/lib/repo/drift/drift_sync_repository.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:drift/drift.dart'; +import '../sync_repository.dart'; +import 'schema.dart'; // Will export schema.g.dart artifacts via internal generation users + +/// A Drift (SQLite) implementation of SyncRepository. +/// Requires a [SyncDatabase] interface which exposes the underlying tables. +class DriftSyncRepository implements SyncRepository { + final SyncDatabase dbWrapper; + final String collectionName; + final Map Function(T) toJson; + final T Function(Map) fromJson; + + DriftSyncRepository({ + required this.dbWrapper, + required this.collectionName, + required this.toJson, + required this.fromJson, + }); + + GeneratedDatabase get db => dbWrapper.db; + + @override + Future>> getAll() async { + // Explicitly type the select to ensure 't' is 'SyncEntries' + final query = db.select(dbWrapper.syncEntries) + ..where((t) => t.collection.equals(collectionName)); + + final rows = await query.get(); + + return rows.map((row) { + return SyncRecord( + id: row.id, + data: fromJson(jsonDecode(row.data)), + baseData: + row.baseData != null ? fromJson(jsonDecode(row.baseData!)) : null, + serverUpdatedAt: row.serverUpdatedAt, + isDirty: row.isDirty, + isDeleted: row.isDeleted, + deletedAt: row.deletedAt, + ); + }).toList(); + } + + @override + Future?> get(String id) async { + final query = db.select(dbWrapper.syncEntries) + ..where((t) => t.collection.equals(collectionName) & t.id.equals(id)); + + final row = await query.getSingleOrNull(); + if (row == null) return null; + + return SyncRecord( + id: row.id, + data: fromJson(jsonDecode(row.data)), + baseData: + row.baseData != null ? fromJson(jsonDecode(row.baseData!)) : null, + serverUpdatedAt: row.serverUpdatedAt, + isDirty: row.isDirty, + isDeleted: row.isDeleted, + deletedAt: row.deletedAt, + ); + } + + @override + Future save(SyncRecord record) async { + // Note: SyncEntriesCompanion is generated in schema.g.dart + await db.into(dbWrapper.syncEntries).insertOnConflictUpdate( + SyncEntriesCompanion( + collection: Value(collectionName), + id: Value(record.id), + data: Value(jsonEncode(toJson(record.data))), + baseData: Value( + record.baseData != null + ? jsonEncode(toJson(record.baseData as T)) + : null, + ), + serverUpdatedAt: Value(record.serverUpdatedAt), + isDirty: Value(record.isDirty), + isDeleted: Value(record.isDeleted), + deletedAt: Value(record.deletedAt), + ), + ); + } + + @override + Future delete(String id) async { + await (db.delete(dbWrapper.syncEntries) + ..where((t) => t.collection.equals(collectionName) & t.id.equals(id))) + .go(); + } + + @override + Future getLastSyncTime() async { + final query = db.select(dbWrapper.syncMeta) + ..where((t) => t.collection.equals(collectionName)); + + final row = await query.getSingleOrNull(); + return row?.lastSync ?? DateTime.fromMillisecondsSinceEpoch(0); + } + + @override + Future setLastSyncTime(DateTime time) async { + await db.into(dbWrapper.syncMeta).insertOnConflictUpdate( + SyncMetaCompanion( + collection: Value(collectionName), + lastSync: Value(time), + ), + ); + } +} diff --git a/packages/pocketbase_sync/lib/repo/drift/schema.dart b/packages/pocketbase_sync/lib/repo/drift/schema.dart new file mode 100644 index 0000000..ca9b4df --- /dev/null +++ b/packages/pocketbase_sync/lib/repo/drift/schema.dart @@ -0,0 +1,55 @@ +import 'package:drift/drift.dart'; + +part 'schema.g.dart'; + +/// Stores the actual data records + sync metadata +/// We use a composite primary key of (collection, id) to store multiple collections in one table +class SyncEntries extends Table { + TextColumn get collection => text()(); + TextColumn get id => text()(); + + /// serialized JSON of the current local data + TextColumn get data => text()(); + + /// serialized JSON of the base data (snapshot) + TextColumn get baseData => text().nullable()(); + + DateTimeColumn get serverUpdatedAt => dateTime().nullable()(); + BoolColumn get isDirty => boolean().withDefault(const Constant(false))(); + BoolColumn get isDeleted => boolean().withDefault(const Constant(false))(); + DateTimeColumn get deletedAt => dateTime().nullable()(); + + @override + Set get primaryKey => {collection, id}; +} + +/// Stores global sync state per collection (e.g. last sync time) +class SyncMeta extends Table { + TextColumn get collection => text()(); + DateTimeColumn get lastSync => dateTime().withDefault( + Constant(DateTime.fromMillisecondsSinceEpoch(0)), + )(); + + @override + Set get primaryKey => {collection}; +} + +/// Dummy database class to trigger generation of Companions and Data classes +/// which are used by DriftSyncRepository. +@DriftDatabase(tables: [SyncEntries, SyncMeta]) +class PackageDatabase extends _$PackageDatabase implements SyncDatabase { + PackageDatabase(super.e); + @override + int get schemaVersion => 1; + + @override + GeneratedDatabase get db => this; +} + +/// Interface that your AppDatabase must implement to be used with DriftSyncRepository. +/// This allows the repository to be agnostic of the specific generated database class. +abstract class SyncDatabase { + GeneratedDatabase get db; + TableInfo get syncEntries; + TableInfo get syncMeta; +} diff --git a/packages/pocketbase_sync/lib/repo/drift/schema.g.dart b/packages/pocketbase_sync/lib/repo/drift/schema.g.dart new file mode 100644 index 0000000..753c16c --- /dev/null +++ b/packages/pocketbase_sync/lib/repo/drift/schema.g.dart @@ -0,0 +1,1015 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schema.dart'; + +// ignore_for_file: type=lint +class $SyncEntriesTable extends SyncEntries + with TableInfo<$SyncEntriesTable, SyncEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _collectionMeta = + const VerificationMeta('collection'); + @override + late final GeneratedColumn collection = GeneratedColumn( + 'collection', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @override + late final GeneratedColumn data = GeneratedColumn( + 'data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _baseDataMeta = + const VerificationMeta('baseData'); + @override + late final GeneratedColumn baseData = GeneratedColumn( + 'base_data', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _serverUpdatedAtMeta = + const VerificationMeta('serverUpdatedAt'); + @override + late final GeneratedColumn serverUpdatedAt = + GeneratedColumn('server_updated_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _isDirtyMeta = + const VerificationMeta('isDirty'); + @override + late final GeneratedColumn isDirty = GeneratedColumn( + 'is_dirty', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_dirty" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _isDeletedMeta = + const VerificationMeta('isDeleted'); + @override + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _deletedAtMeta = + const VerificationMeta('deletedAt'); + @override + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + collection, + id, + data, + baseData, + serverUpdatedAt, + isDirty, + isDeleted, + deletedAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_entries'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('collection')) { + context.handle( + _collectionMeta, + collection.isAcceptableOrUnknown( + data['collection']!, _collectionMeta)); + } else if (isInserting) { + context.missing(_collectionMeta); + } + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('data')) { + context.handle( + _dataMeta, this.data.isAcceptableOrUnknown(data['data']!, _dataMeta)); + } else if (isInserting) { + context.missing(_dataMeta); + } + if (data.containsKey('base_data')) { + context.handle(_baseDataMeta, + baseData.isAcceptableOrUnknown(data['base_data']!, _baseDataMeta)); + } + if (data.containsKey('server_updated_at')) { + context.handle( + _serverUpdatedAtMeta, + serverUpdatedAt.isAcceptableOrUnknown( + data['server_updated_at']!, _serverUpdatedAtMeta)); + } + if (data.containsKey('is_dirty')) { + context.handle(_isDirtyMeta, + isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta)); + } + if (data.containsKey('is_deleted')) { + context.handle(_isDeletedMeta, + isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta)); + } + if (data.containsKey('deleted_at')) { + context.handle(_deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {collection, id}; + @override + SyncEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncEntry( + collection: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}collection'])!, + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + data: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!, + baseData: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}base_data']), + serverUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}server_updated_at']), + isDirty: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_dirty'])!, + isDeleted: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!, + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + ); + } + + @override + $SyncEntriesTable createAlias(String alias) { + return $SyncEntriesTable(attachedDatabase, alias); + } +} + +class SyncEntry extends DataClass implements Insertable { + final String collection; + final String id; + + /// serialized JSON of the current local data + final String data; + + /// serialized JSON of the base data (snapshot) + final String? baseData; + final DateTime? serverUpdatedAt; + final bool isDirty; + final bool isDeleted; + final DateTime? deletedAt; + const SyncEntry( + {required this.collection, + required this.id, + required this.data, + this.baseData, + this.serverUpdatedAt, + required this.isDirty, + required this.isDeleted, + this.deletedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['collection'] = Variable(collection); + map['id'] = Variable(id); + map['data'] = Variable(data); + if (!nullToAbsent || baseData != null) { + map['base_data'] = Variable(baseData); + } + if (!nullToAbsent || serverUpdatedAt != null) { + map['server_updated_at'] = Variable(serverUpdatedAt); + } + map['is_dirty'] = Variable(isDirty); + map['is_deleted'] = Variable(isDeleted); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + return map; + } + + SyncEntriesCompanion toCompanion(bool nullToAbsent) { + return SyncEntriesCompanion( + collection: Value(collection), + id: Value(id), + data: Value(data), + baseData: baseData == null && nullToAbsent + ? const Value.absent() + : Value(baseData), + serverUpdatedAt: serverUpdatedAt == null && nullToAbsent + ? const Value.absent() + : Value(serverUpdatedAt), + isDirty: Value(isDirty), + isDeleted: Value(isDeleted), + deletedAt: deletedAt == null && nullToAbsent + ? const Value.absent() + : Value(deletedAt), + ); + } + + factory SyncEntry.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncEntry( + collection: serializer.fromJson(json['collection']), + id: serializer.fromJson(json['id']), + data: serializer.fromJson(json['data']), + baseData: serializer.fromJson(json['baseData']), + serverUpdatedAt: serializer.fromJson(json['serverUpdatedAt']), + isDirty: serializer.fromJson(json['isDirty']), + isDeleted: serializer.fromJson(json['isDeleted']), + deletedAt: serializer.fromJson(json['deletedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'collection': serializer.toJson(collection), + 'id': serializer.toJson(id), + 'data': serializer.toJson(data), + 'baseData': serializer.toJson(baseData), + 'serverUpdatedAt': serializer.toJson(serverUpdatedAt), + 'isDirty': serializer.toJson(isDirty), + 'isDeleted': serializer.toJson(isDeleted), + 'deletedAt': serializer.toJson(deletedAt), + }; + } + + SyncEntry copyWith( + {String? collection, + String? id, + String? data, + Value baseData = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + bool? isDirty, + bool? isDeleted, + Value deletedAt = const Value.absent()}) => + SyncEntry( + collection: collection ?? this.collection, + id: id ?? this.id, + data: data ?? this.data, + baseData: baseData.present ? baseData.value : this.baseData, + serverUpdatedAt: serverUpdatedAt.present + ? serverUpdatedAt.value + : this.serverUpdatedAt, + isDirty: isDirty ?? this.isDirty, + isDeleted: isDeleted ?? this.isDeleted, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ); + SyncEntry copyWithCompanion(SyncEntriesCompanion data) { + return SyncEntry( + collection: + data.collection.present ? data.collection.value : this.collection, + id: data.id.present ? data.id.value : this.id, + data: data.data.present ? data.data.value : this.data, + baseData: data.baseData.present ? data.baseData.value : this.baseData, + serverUpdatedAt: data.serverUpdatedAt.present + ? data.serverUpdatedAt.value + : this.serverUpdatedAt, + isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty, + isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SyncEntry(') + ..write('collection: $collection, ') + ..write('id: $id, ') + ..write('data: $data, ') + ..write('baseData: $baseData, ') + ..write('serverUpdatedAt: $serverUpdatedAt, ') + ..write('isDirty: $isDirty, ') + ..write('isDeleted: $isDeleted, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(collection, id, data, baseData, + serverUpdatedAt, isDirty, isDeleted, deletedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncEntry && + other.collection == this.collection && + other.id == this.id && + other.data == this.data && + other.baseData == this.baseData && + other.serverUpdatedAt == this.serverUpdatedAt && + other.isDirty == this.isDirty && + other.isDeleted == this.isDeleted && + other.deletedAt == this.deletedAt); +} + +class SyncEntriesCompanion extends UpdateCompanion { + final Value collection; + final Value id; + final Value data; + final Value baseData; + final Value serverUpdatedAt; + final Value isDirty; + final Value isDeleted; + final Value deletedAt; + final Value rowid; + const SyncEntriesCompanion({ + this.collection = const Value.absent(), + this.id = const Value.absent(), + this.data = const Value.absent(), + this.baseData = const Value.absent(), + this.serverUpdatedAt = const Value.absent(), + this.isDirty = const Value.absent(), + this.isDeleted = const Value.absent(), + this.deletedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncEntriesCompanion.insert({ + required String collection, + required String id, + required String data, + this.baseData = const Value.absent(), + this.serverUpdatedAt = const Value.absent(), + this.isDirty = const Value.absent(), + this.isDeleted = const Value.absent(), + this.deletedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : collection = Value(collection), + id = Value(id), + data = Value(data); + static Insertable custom({ + Expression? collection, + Expression? id, + Expression? data, + Expression? baseData, + Expression? serverUpdatedAt, + Expression? isDirty, + Expression? isDeleted, + Expression? deletedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (collection != null) 'collection': collection, + if (id != null) 'id': id, + if (data != null) 'data': data, + if (baseData != null) 'base_data': baseData, + if (serverUpdatedAt != null) 'server_updated_at': serverUpdatedAt, + if (isDirty != null) 'is_dirty': isDirty, + if (isDeleted != null) 'is_deleted': isDeleted, + if (deletedAt != null) 'deleted_at': deletedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncEntriesCompanion copyWith( + {Value? collection, + Value? id, + Value? data, + Value? baseData, + Value? serverUpdatedAt, + Value? isDirty, + Value? isDeleted, + Value? deletedAt, + Value? rowid}) { + return SyncEntriesCompanion( + collection: collection ?? this.collection, + id: id ?? this.id, + data: data ?? this.data, + baseData: baseData ?? this.baseData, + serverUpdatedAt: serverUpdatedAt ?? this.serverUpdatedAt, + isDirty: isDirty ?? this.isDirty, + isDeleted: isDeleted ?? this.isDeleted, + deletedAt: deletedAt ?? this.deletedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (collection.present) { + map['collection'] = Variable(collection.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (baseData.present) { + map['base_data'] = Variable(baseData.value); + } + if (serverUpdatedAt.present) { + map['server_updated_at'] = Variable(serverUpdatedAt.value); + } + if (isDirty.present) { + map['is_dirty'] = Variable(isDirty.value); + } + if (isDeleted.present) { + map['is_deleted'] = Variable(isDeleted.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncEntriesCompanion(') + ..write('collection: $collection, ') + ..write('id: $id, ') + ..write('data: $data, ') + ..write('baseData: $baseData, ') + ..write('serverUpdatedAt: $serverUpdatedAt, ') + ..write('isDirty: $isDirty, ') + ..write('isDeleted: $isDeleted, ') + ..write('deletedAt: $deletedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SyncMetaTable extends SyncMeta + with TableInfo<$SyncMetaTable, SyncMetaData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncMetaTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _collectionMeta = + const VerificationMeta('collection'); + @override + late final GeneratedColumn collection = GeneratedColumn( + 'collection', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _lastSyncMeta = + const VerificationMeta('lastSync'); + @override + late final GeneratedColumn lastSync = GeneratedColumn( + 'last_sync', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: Constant(DateTime.fromMillisecondsSinceEpoch(0))); + @override + List get $columns => [collection, lastSync]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_meta'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('collection')) { + context.handle( + _collectionMeta, + collection.isAcceptableOrUnknown( + data['collection']!, _collectionMeta)); + } else if (isInserting) { + context.missing(_collectionMeta); + } + if (data.containsKey('last_sync')) { + context.handle(_lastSyncMeta, + lastSync.isAcceptableOrUnknown(data['last_sync']!, _lastSyncMeta)); + } + return context; + } + + @override + Set get $primaryKey => {collection}; + @override + SyncMetaData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncMetaData( + collection: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}collection'])!, + lastSync: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync'])!, + ); + } + + @override + $SyncMetaTable createAlias(String alias) { + return $SyncMetaTable(attachedDatabase, alias); + } +} + +class SyncMetaData extends DataClass implements Insertable { + final String collection; + final DateTime lastSync; + const SyncMetaData({required this.collection, required this.lastSync}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['collection'] = Variable(collection); + map['last_sync'] = Variable(lastSync); + return map; + } + + SyncMetaCompanion toCompanion(bool nullToAbsent) { + return SyncMetaCompanion( + collection: Value(collection), + lastSync: Value(lastSync), + ); + } + + factory SyncMetaData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncMetaData( + collection: serializer.fromJson(json['collection']), + lastSync: serializer.fromJson(json['lastSync']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'collection': serializer.toJson(collection), + 'lastSync': serializer.toJson(lastSync), + }; + } + + SyncMetaData copyWith({String? collection, DateTime? lastSync}) => + SyncMetaData( + collection: collection ?? this.collection, + lastSync: lastSync ?? this.lastSync, + ); + SyncMetaData copyWithCompanion(SyncMetaCompanion data) { + return SyncMetaData( + collection: + data.collection.present ? data.collection.value : this.collection, + lastSync: data.lastSync.present ? data.lastSync.value : this.lastSync, + ); + } + + @override + String toString() { + return (StringBuffer('SyncMetaData(') + ..write('collection: $collection, ') + ..write('lastSync: $lastSync') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(collection, lastSync); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncMetaData && + other.collection == this.collection && + other.lastSync == this.lastSync); +} + +class SyncMetaCompanion extends UpdateCompanion { + final Value collection; + final Value lastSync; + final Value rowid; + const SyncMetaCompanion({ + this.collection = const Value.absent(), + this.lastSync = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncMetaCompanion.insert({ + required String collection, + this.lastSync = const Value.absent(), + this.rowid = const Value.absent(), + }) : collection = Value(collection); + static Insertable custom({ + Expression? collection, + Expression? lastSync, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (collection != null) 'collection': collection, + if (lastSync != null) 'last_sync': lastSync, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncMetaCompanion copyWith( + {Value? collection, + Value? lastSync, + Value? rowid}) { + return SyncMetaCompanion( + collection: collection ?? this.collection, + lastSync: lastSync ?? this.lastSync, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (collection.present) { + map['collection'] = Variable(collection.value); + } + if (lastSync.present) { + map['last_sync'] = Variable(lastSync.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncMetaCompanion(') + ..write('collection: $collection, ') + ..write('lastSync: $lastSync, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$PackageDatabase extends GeneratedDatabase { + _$PackageDatabase(QueryExecutor e) : super(e); + $PackageDatabaseManager get managers => $PackageDatabaseManager(this); + late final $SyncEntriesTable syncEntries = $SyncEntriesTable(this); + late final $SyncMetaTable syncMeta = $SyncMetaTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [syncEntries, syncMeta]; +} + +typedef $$SyncEntriesTableCreateCompanionBuilder = SyncEntriesCompanion + Function({ + required String collection, + required String id, + required String data, + Value baseData, + Value serverUpdatedAt, + Value isDirty, + Value isDeleted, + Value deletedAt, + Value rowid, +}); +typedef $$SyncEntriesTableUpdateCompanionBuilder = SyncEntriesCompanion + Function({ + Value collection, + Value id, + Value data, + Value baseData, + Value serverUpdatedAt, + Value isDirty, + Value isDeleted, + Value deletedAt, + Value rowid, +}); + +class $$SyncEntriesTableFilterComposer + extends Composer<_$PackageDatabase, $SyncEntriesTable> { + $$SyncEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get collection => $composableBuilder( + column: $table.collection, builder: (column) => ColumnFilters(column)); + + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get data => $composableBuilder( + column: $table.data, builder: (column) => ColumnFilters(column)); + + ColumnFilters get baseData => $composableBuilder( + column: $table.baseData, builder: (column) => ColumnFilters(column)); + + ColumnFilters get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get isDirty => $composableBuilder( + column: $table.isDirty, builder: (column) => ColumnFilters(column)); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, builder: (column) => ColumnFilters(column)); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, builder: (column) => ColumnFilters(column)); +} + +class $$SyncEntriesTableOrderingComposer + extends Composer<_$PackageDatabase, $SyncEntriesTable> { + $$SyncEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get collection => $composableBuilder( + column: $table.collection, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get data => $composableBuilder( + column: $table.data, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get baseData => $composableBuilder( + column: $table.baseData, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isDirty => $composableBuilder( + column: $table.isDirty, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$SyncEntriesTableAnnotationComposer + extends Composer<_$PackageDatabase, $SyncEntriesTable> { + $$SyncEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get collection => $composableBuilder( + column: $table.collection, builder: (column) => column); + + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get data => + $composableBuilder(column: $table.data, builder: (column) => column); + + GeneratedColumn get baseData => + $composableBuilder(column: $table.baseData, builder: (column) => column); + + GeneratedColumn get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, builder: (column) => column); + + GeneratedColumn get isDirty => + $composableBuilder(column: $table.isDirty, builder: (column) => column); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); +} + +class $$SyncEntriesTableTableManager extends RootTableManager< + _$PackageDatabase, + $SyncEntriesTable, + SyncEntry, + $$SyncEntriesTableFilterComposer, + $$SyncEntriesTableOrderingComposer, + $$SyncEntriesTableAnnotationComposer, + $$SyncEntriesTableCreateCompanionBuilder, + $$SyncEntriesTableUpdateCompanionBuilder, + ( + SyncEntry, + BaseReferences<_$PackageDatabase, $SyncEntriesTable, SyncEntry> + ), + SyncEntry, + PrefetchHooks Function()> { + $$SyncEntriesTableTableManager(_$PackageDatabase db, $SyncEntriesTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncEntriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncEntriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value collection = const Value.absent(), + Value id = const Value.absent(), + Value data = const Value.absent(), + Value baseData = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + Value isDirty = const Value.absent(), + Value isDeleted = const Value.absent(), + Value deletedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SyncEntriesCompanion( + collection: collection, + id: id, + data: data, + baseData: baseData, + serverUpdatedAt: serverUpdatedAt, + isDirty: isDirty, + isDeleted: isDeleted, + deletedAt: deletedAt, + rowid: rowid, + ), + createCompanionCallback: ({ + required String collection, + required String id, + required String data, + Value baseData = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + Value isDirty = const Value.absent(), + Value isDeleted = const Value.absent(), + Value deletedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SyncEntriesCompanion.insert( + collection: collection, + id: id, + data: data, + baseData: baseData, + serverUpdatedAt: serverUpdatedAt, + isDirty: isDirty, + isDeleted: isDeleted, + deletedAt: deletedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$SyncEntriesTableProcessedTableManager = ProcessedTableManager< + _$PackageDatabase, + $SyncEntriesTable, + SyncEntry, + $$SyncEntriesTableFilterComposer, + $$SyncEntriesTableOrderingComposer, + $$SyncEntriesTableAnnotationComposer, + $$SyncEntriesTableCreateCompanionBuilder, + $$SyncEntriesTableUpdateCompanionBuilder, + ( + SyncEntry, + BaseReferences<_$PackageDatabase, $SyncEntriesTable, SyncEntry> + ), + SyncEntry, + PrefetchHooks Function()>; +typedef $$SyncMetaTableCreateCompanionBuilder = SyncMetaCompanion Function({ + required String collection, + Value lastSync, + Value rowid, +}); +typedef $$SyncMetaTableUpdateCompanionBuilder = SyncMetaCompanion Function({ + Value collection, + Value lastSync, + Value rowid, +}); + +class $$SyncMetaTableFilterComposer + extends Composer<_$PackageDatabase, $SyncMetaTable> { + $$SyncMetaTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get collection => $composableBuilder( + column: $table.collection, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastSync => $composableBuilder( + column: $table.lastSync, builder: (column) => ColumnFilters(column)); +} + +class $$SyncMetaTableOrderingComposer + extends Composer<_$PackageDatabase, $SyncMetaTable> { + $$SyncMetaTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get collection => $composableBuilder( + column: $table.collection, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastSync => $composableBuilder( + column: $table.lastSync, builder: (column) => ColumnOrderings(column)); +} + +class $$SyncMetaTableAnnotationComposer + extends Composer<_$PackageDatabase, $SyncMetaTable> { + $$SyncMetaTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get collection => $composableBuilder( + column: $table.collection, builder: (column) => column); + + GeneratedColumn get lastSync => + $composableBuilder(column: $table.lastSync, builder: (column) => column); +} + +class $$SyncMetaTableTableManager extends RootTableManager< + _$PackageDatabase, + $SyncMetaTable, + SyncMetaData, + $$SyncMetaTableFilterComposer, + $$SyncMetaTableOrderingComposer, + $$SyncMetaTableAnnotationComposer, + $$SyncMetaTableCreateCompanionBuilder, + $$SyncMetaTableUpdateCompanionBuilder, + ( + SyncMetaData, + BaseReferences<_$PackageDatabase, $SyncMetaTable, SyncMetaData> + ), + SyncMetaData, + PrefetchHooks Function()> { + $$SyncMetaTableTableManager(_$PackageDatabase db, $SyncMetaTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncMetaTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncMetaTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncMetaTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value collection = const Value.absent(), + Value lastSync = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SyncMetaCompanion( + collection: collection, + lastSync: lastSync, + rowid: rowid, + ), + createCompanionCallback: ({ + required String collection, + Value lastSync = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SyncMetaCompanion.insert( + collection: collection, + lastSync: lastSync, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$SyncMetaTableProcessedTableManager = ProcessedTableManager< + _$PackageDatabase, + $SyncMetaTable, + SyncMetaData, + $$SyncMetaTableFilterComposer, + $$SyncMetaTableOrderingComposer, + $$SyncMetaTableAnnotationComposer, + $$SyncMetaTableCreateCompanionBuilder, + $$SyncMetaTableUpdateCompanionBuilder, + ( + SyncMetaData, + BaseReferences<_$PackageDatabase, $SyncMetaTable, SyncMetaData> + ), + SyncMetaData, + PrefetchHooks Function()>; + +class $PackageDatabaseManager { + final _$PackageDatabase _db; + $PackageDatabaseManager(this._db); + $$SyncEntriesTableTableManager get syncEntries => + $$SyncEntriesTableTableManager(_db, _db.syncEntries); + $$SyncMetaTableTableManager get syncMeta => + $$SyncMetaTableTableManager(_db, _db.syncMeta); +} diff --git a/packages/pocketbase_sync/lib/repo/in_memory_repository.dart b/packages/pocketbase_sync/lib/repo/in_memory_repository.dart new file mode 100644 index 0000000..1420604 --- /dev/null +++ b/packages/pocketbase_sync/lib/repo/in_memory_repository.dart @@ -0,0 +1,20 @@ +import 'sync_repository.dart'; + +/// Default In-Memory Implementation (Good for testing/prototyping) +class InMemoryRepository implements SyncRepository { + final Map> _db = {}; + DateTime _lastSync = DateTime.fromMillisecondsSinceEpoch(0); + + @override + Future>> getAll() async => _db.values.toList(); + @override + Future?> get(String id) async => _db[id]; + @override + Future save(SyncRecord record) async => _db[record.id] = record; + @override + Future delete(String id) async => _db.remove(id); + @override + Future getLastSyncTime() async => _lastSync; + @override + Future setLastSyncTime(DateTime time) async => _lastSync = time; +} diff --git a/packages/pocketbase_sync/lib/repo/sync_repository.dart b/packages/pocketbase_sync/lib/repo/sync_repository.dart new file mode 100644 index 0000000..27554b5 --- /dev/null +++ b/packages/pocketbase_sync/lib/repo/sync_repository.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +/// Wraps your data with sync metadata (Dirty flags, tombstones, versions) +class SyncRecord { + final String id; + final T data; + + /// Server's timestamp. Null if created locally and never synced. + final DateTime? serverUpdatedAt; + + /// Snapshot of data before local edits (Critical for 3-way merge). + final T? baseData; + + /// True if local data differs from server data. + final bool isDirty; + + /// True if marked for deletion (Soft Delete). + final bool isDeleted; + + /// When the soft delete happened (For Retention Policy). + final DateTime? deletedAt; + + SyncRecord({ + required this.id, + required this.data, + this.serverUpdatedAt, + this.baseData, + this.isDirty = false, + this.isDeleted = false, + this.deletedAt, + }); + + SyncRecord copyWith({ + T? data, + DateTime? serverUpdatedAt, + T? baseData, + bool? isDirty, + bool? isDeleted, + DateTime? deletedAt, + }) { + return SyncRecord( + id: id, + data: data ?? this.data, + serverUpdatedAt: serverUpdatedAt ?? this.serverUpdatedAt, + baseData: baseData ?? this.baseData, + isDirty: isDirty ?? this.isDirty, + isDeleted: isDeleted ?? this.isDeleted, + deletedAt: deletedAt ?? this.deletedAt, + ); + } +} + +/// Abstract Storage Interface +abstract class SyncRepository { + Future>> getAll(); + Future?> get(String id); + Future save(SyncRecord record); + Future delete(String id); // Hard Delete + Future getLastSyncTime(); + Future setLastSyncTime(DateTime time); +} diff --git a/packages/pocketbase_sync/lib/sync/pb_multi_sync_manager.dart b/packages/pocketbase_sync/lib/sync/pb_multi_sync_manager.dart new file mode 100644 index 0000000..00826a2 --- /dev/null +++ b/packages/pocketbase_sync/lib/sync/pb_multi_sync_manager.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:logging/logging.dart'; +import 'pb_sync_manager.dart'; +import '../core/sync_interface.dart'; + +class PocketBaseMultiSyncManager implements ISyncManager { + final Map _managers = {}; + final _logger = Logger('PocketBaseMultiSyncManager'); + + /// Registers a manager for a specific type [T]. + void register(PocketBaseSyncManager manager) { + _managers[T] = manager; + } + + /// Retrieves the manager for type [T]. + PocketBaseSyncManager managerFor() { + final manager = _managers[T]; + if (manager == null) { + throw StateError('No SyncManager registered for type $T'); + } + return manager as PocketBaseSyncManager; + } + + @override + Future sync() async { + _logger.info('Starting multi-sync for ${_managers.length} managers'); + // Run syncs sequentially to avoid overwhelming network/device resources. + // Could be parallelized with Future.wait if needed. + for (final manager in _managers.values) { + try { + await manager.sync(); + } catch (e, stack) { + // Log error but continue syncing other collections + _logger.severe( + 'Error syncing manager for type ${manager.runtimeType}', e, stack); + } + } + _logger.info('Multi-sync completed'); + } +} diff --git a/packages/pocketbase_sync/lib/sync/pb_sync_manager.dart b/packages/pocketbase_sync/lib/sync/pb_sync_manager.dart new file mode 100644 index 0000000..c072637 --- /dev/null +++ b/packages/pocketbase_sync/lib/sync/pb_sync_manager.dart @@ -0,0 +1,514 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:diff_algorithims/hybrid_diff.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../repo/sync_repository.dart'; +import '../repo/in_memory_repository.dart'; +import '../core/sync_interface.dart'; + +class PocketBaseSyncManager implements ISyncManager { + final PocketBase pb; + final String collection; + final SyncRepository repository; + final _logger = Logger('PocketBaseSyncManager'); + + // Serialization Helpers + final Map Function(T) toJson; + final T Function(Map) fromJson; + + // Config + final Duration retentionPeriod; + final String Function() idGenerator; + Timer? _autoSyncTimer; + Duration? _autoSyncInterval; + + // Connection Status (Reactive) + final ValueNotifier isConnectedNotifier = ValueNotifier(false); + bool get _isConnected => isConnectedNotifier.value; + set _isConnected(bool value) => isConnectedNotifier.value = value; + + // Update Stream + final _updateController = StreamController.broadcast(); + Stream get onUpdate => _updateController.stream; + + PocketBaseSyncManager({ + required this.pb, + required this.collection, + required this.toJson, + required this.fromJson, + SyncRepository? repository, + this.retentionPeriod = const Duration(days: 30), + String Function()? idGenerator, + Duration? autoSyncInterval, + }) : repository = repository ?? InMemoryRepository(), + idGenerator = idGenerator ?? _defaultIdGenerator, + _autoSyncInterval = autoSyncInterval { + if (autoSyncInterval != null) { + startAutoSync(autoSyncInterval); + } + } + + static String _defaultIdGenerator() { + return List.generate( + 15, + (_) => 'abcdefghijklmnopqrstuvwxyz0123456789'[Random().nextInt(36)], + ).join(); + } + + String generateId() => idGenerator(); + + Duration? get autoSyncInterval => _autoSyncInterval; + + set autoSyncInterval(Duration? value) { + if (value == _autoSyncInterval) return; + _autoSyncInterval = value; + if (value != null) { + startAutoSync(value); + } else { + stopAutoSync(); + } + } + + void startAutoSync(Duration interval) { + _autoSyncTimer?.cancel(); + _autoSyncTimer = Timer.periodic(interval, (_) { + sync().catchError((e) { + _logger.warning('Auto-sync failed', e); + }); + }); + } + + void stopAutoSync() { + _autoSyncTimer?.cancel(); + _autoSyncTimer = null; + } + + // =========================================================================== + // --- 1. CRUD API (Local First) --- + // =========================================================================== + + Future create(String id, T item) async { + await repository.save( + SyncRecord( + id: id, + data: item, + isDirty: true, + // serverUpdatedAt null implies "New" + ), + ); + _updateController.add(null); + } + + Future update(String id, T item) async { + final record = await repository.get(id); + if (record == null) return; + + await repository.save( + record.copyWith( + data: item, + isDirty: true, + // Snapshot baseData ONLY if this is the first edit since sync + baseData: record.baseData ?? record.data, + ), + ); + _updateController.add(null); + } + + Future delete(String id) async { + final record = await repository.get(id); + if (record == null) return; + + if (record.serverUpdatedAt == null) { + // Never synced? Hard delete immediately. + await repository.delete(id); + } else { + // Synced? Soft delete (Tombstone). + await repository.save( + record.copyWith( + isDeleted: true, + isDirty: true, + deletedAt: DateTime.now(), + ), + ); + } + _updateController.add(null); + } + + // =========================================================================== + // --- 2. SYNC LOGIC --- + // =========================================================================== + + @override + Future sync() async { + if (_isConnected) { + _logger.info('Starting sync (Push Only) for collection: $collection'); + } else { + _logger.info('Starting sync (Full) for collection: $collection'); + } + + try { + await _pushChanges(); + + // smart-poll: only pull if we are NOT connected to realtime + if (!_isConnected) { + await _pullChanges(); + } else { + _logger.fine('Skipping pull (Realtime connected)'); + } + + await _performCleanup(); + _logger.info('Sync completed for collection: $collection'); + } catch (e, stack) { + _logger.severe('Sync failed for collection: $collection', e, stack); + rethrow; + } + } + + // =========================================================================== + // --- 3. REALTIME SYNC --- + // =========================================================================== + + bool _isSubscribed = false; + + Future subscribe() async { + _isSubscribed = true; + _subscribeWithRetry(0); + } + + Future _subscribeWithRetry(int attempt) async { + if (!_isSubscribed) return; + + try { + _logger.info( + 'Subscribing to realtime updates for $collection (Attempt: $attempt)'); + await pb.collection(collection).subscribe('*', _handleRealtimeEvent); + _isConnected = true; + _logger.info('Successfully subscribed to $collection'); + + // Catch up on anything we missed while disconnected + await _pullChanges(); + } catch (e) { + _isConnected = false; + if (!_isSubscribed) return; + + _logger.warning('Realtime subscription failed for $collection', e); + if (e is ClientException) { + // from package:pocketbase + _logger.warning( + ' Status: ${e.statusCode}\n Response: ${e.response}\n Original: ${e.originalError}'); + } + + // Exponential Backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap) + final delaySeconds = min(30, pow(2, attempt).toInt()); + final delay = Duration(seconds: delaySeconds); + + _logger.info('Retrying subscription in ${delay.inSeconds}s...'); + await Future.delayed(delay); + + if (_isSubscribed) { + // Recursive retry call + _subscribeWithRetry(attempt + 1); + } + } + } + + Future unsubscribe() async { + _isSubscribed = false; + _isConnected = false; + _logger.info('Unsubscribing from realtime updates for $collection'); + try { + await pb.collection(collection).unsubscribe('*'); + } catch (_) { + // Ignore unsubscribe errors + } + } + + Future _pushChanges() async { + final all = await repository.getAll(); + final dirty = all.where((r) => r.isDirty).toList(); + + for (var record in dirty) { + try { + // --- A. DELETE --- + if (record.isDeleted) { + try { + await pb.collection(collection).delete(record.id); + } catch (e) { + // Ignore 404 (already deleted) + } + await repository.save(record.copyWith(isDirty: false)); + continue; + } + + // --- B. CREATE --- + if (record.serverUpdatedAt == null) { + final body = toJson(record.data); + body['id'] = record.id; + final result = await pb.collection(collection).create(body: body); + + await repository.save( + record.copyWith( + isDirty: false, + serverUpdatedAt: DateTime.parse(result.getStringValue('updated')), + baseData: null, + // Update local ID if needed? No, ID is client-generated. + ), + ); + continue; + } + + // --- C. UPDATE (PATCHING) --- + final baseJson = record.baseData != null + ? toJson(record.baseData as T) + : {}; + final currentJson = toJson(record.data); + + // Use HybridDiffer to send MINIMAL changes + final diffs = HybridDiffer.diff( + >[baseJson], + >[currentJson], + idField: 'id', + ); + + final Map patch = {}; + if (diffs.isNotEmpty && diffs.first.children != null) { + for (var change in diffs.first.children!) { + patch[change.key] = change.newValue; + } + } + + if (patch.isEmpty) { + await repository.save( + record.copyWith(isDirty: false, baseData: null), + ); + continue; + } + + try { + final result = + await pb.collection(collection).update(record.id, body: patch); + await repository.save( + record.copyWith( + isDirty: false, + serverUpdatedAt: DateTime.parse(result.getStringValue('updated')), + baseData: null, + ), + ); + } catch (e, stack) { + _logger.warning("Push conflict/error for ${record.id}", e, stack); + // Leave dirty to force merge on next pull + } + } catch (e, stack) { + _logger.warning("Sync Error for record ${record.id}", e, stack); + } + } + } + + Future _pullChanges() async { + final lastSync = await repository.getLastSyncTime(); + _logger.info('Pulling changes since: ${lastSync.toIso8601String()}'); + + // Pagination: getFullList ensures we get ALL pages on first pull (Epoch 0) + // PocketBase uses space separator for dates, not T + final isEpoch = lastSync.year <= 1970; + final dateStr = lastSync.toUtc().toIso8601String().replaceFirst('T', ' '); + final filter = isEpoch ? '' : "updated >= '$dateStr'"; + final items = await pb.collection(collection).getFullList(filter: filter); + _logger.info('Fetched ${items.length} remote items using filter: $filter'); + + for (var item in items) { + final remoteJson = item.toJson(); + final remoteUpdated = DateTime.parse(item.getStringValue('updated')); + final local = await repository.get(item.id); + + _logger + .info('Processing remote item ${item.id} (Updated: $remoteUpdated)'); + + if (local == null) { + // New Item + _logger.info('Creating new local item ${item.id}'); + await repository.save( + SyncRecord( + id: item.id, + data: fromJson(remoteJson), + serverUpdatedAt: remoteUpdated, + ), + ); + } else { + if (local.isDirty) { + // Conflict: We changed it, Server changed it. + _logger.warning('Conflict detected for ${item.id}'); + await _resolveConflict(local, remoteJson, remoteUpdated); + } else { + // Fast Forward + _logger.info('Fast-forwarding item ${item.id}'); + await repository.save( + local.copyWith( + data: fromJson(remoteJson), + serverUpdatedAt: remoteUpdated, + ), + ); + } + } + } + + if (items.isNotEmpty) { + final maxTime = items + .map((e) => DateTime.parse(e.getStringValue('updated'))) + .reduce((a, b) => a.isAfter(b) ? a : b); + _logger.info('Updating last sync time to: $maxTime'); + await repository.setLastSyncTime(maxTime); + _updateController.add(null); + } + + // --- RECONCILIATION --- + // Detect Remote Deletions + _logger.info('Starting reconciliation for remote deletions...'); + try { + // Fetch ALL IDs from server (lightweight) + final allRemoteRecords = + await pb.collection(collection).getFullList(fields: 'id'); + final remoteIds = allRemoteRecords.map((e) => e.id).toSet(); + _logger.info('Fetched ${remoteIds.length} active IDs from server'); + + final allLocal = await repository.getAll(); + for (var local in allLocal) { + // failed to sync or purely local -> skip + if (local.serverUpdatedAt == null) continue; + + // If local thinks it exists (not deleted), but server doesn't have it -> Delete it + if (!local.isDeleted && !remoteIds.contains(local.id)) { + _logger.info( + 'Detected remote deletion for ${local.id}. Deleting locally.'); + await repository.delete(local.id); + } + } + } catch (e, stack) { + _logger.warning('Reconciliation failed', e, stack); + // Non-fatal: just means we might not catch deletions this run + } + } + + Future _handleRealtimeEvent(RecordSubscriptionEvent e) async { + _logger.info('Realtime event received: ${e.action} for ${e.record?.id}'); + + if (e.record == null) return; + final id = e.record!.id; + + try { + if (e.action == 'delete') { + _logger.info("Realtime delete for $id"); + await repository.delete(id); + _updateController.add(null); + } else { + // Create or Update + final remoteJson = e.record!.toJson(); + final remoteUpdated = + DateTime.parse(e.record!.getStringValue('updated')); + final local = await repository.get(id); + + if (local == null) { + _logger.info("Realtime create for $id"); + await repository.save( + SyncRecord( + id: id, + data: fromJson(remoteJson), + serverUpdatedAt: remoteUpdated, + ), + ); + } else { + if (local.isDirty) { + _logger.info("Realtime conflict check for $id"); + await _resolveConflict(local, remoteJson, remoteUpdated); + } else { + _logger.info("Realtime update for $id"); + await repository.save( + local.copyWith( + data: fromJson(remoteJson), + serverUpdatedAt: remoteUpdated, + ), + ); + } + } + _updateController.add(null); + } + } catch (err, stack) { + _logger.warning('Error handling realtime event for $id', err, stack); + } + } + + /// 3-Way Merge Strategy (Server Wins on Collision, Client Wins on Non-Collision) + Future _resolveConflict( + SyncRecord local, + Map remoteJson, + DateTime remoteUpdated, + ) async { + final baseJson = local.baseData != null + ? toJson(local.baseData as T) + : {}; + final localJson = toJson(local.data); + + final myChanges = HybridDiffer.diff( + >[baseJson], + >[localJson], + idField: 'id', + ); + final theirChanges = HybridDiffer.diff( + >[baseJson], + >[remoteJson], + idField: 'id', + ); + + final mergedJson = Map.from(remoteJson); + + // Get keys modified by server + Set theirKeys = {}; + if (theirChanges.isNotEmpty && theirChanges.first.children != null) { + theirKeys = theirChanges.first.children!.map((c) => c.key).toSet(); + } + + // Apply my changes if no collision + if (myChanges.isNotEmpty && myChanges.first.children != null) { + for (var change in myChanges.first.children!) { + if (!theirKeys.contains(change.key)) { + mergedJson[change.key] = change.newValue; + } + } + } + + // Save Merged State (Keep Dirty so we push the merge back up) + await repository.save( + local.copyWith( + data: fromJson(mergedJson), + baseData: fromJson(remoteJson), // Rebase + serverUpdatedAt: remoteUpdated, + isDirty: true, + ), + ); + } + + Future _performCleanup() async { + final now = DateTime.now(); + final all = await repository.getAll(); + + for (var record in all) { + // Only delete if: Deleted + Synced + Expired + if (record.isDeleted && + !record.isDirty && + record.deletedAt != null && + now.difference(record.deletedAt!) > retentionPeriod) { + await repository.delete(record.id); + } + } + } + + void dispose() { + unsubscribe(); + stopAutoSync(); + _updateController.close(); + isConnectedNotifier.dispose(); + } +} diff --git a/packages/pocketbase_sync/lib/sync_managers/drift.dart b/packages/pocketbase_sync/lib/sync_managers/drift.dart new file mode 100644 index 0000000..ded904d --- /dev/null +++ b/packages/pocketbase_sync/lib/sync_managers/drift.dart @@ -0,0 +1,4 @@ +library pocketbase_sync_drift; + +export '../repo/drift/drift_sync_repository.dart'; +export '../repo/drift/schema.dart'; diff --git a/packages/pocketbase_sync/lib/sync_managers/in_memory.dart b/packages/pocketbase_sync/lib/sync_managers/in_memory.dart new file mode 100644 index 0000000..c3d4eb9 --- /dev/null +++ b/packages/pocketbase_sync/lib/sync_managers/in_memory.dart @@ -0,0 +1,3 @@ +library pocketbase_sync_in_memory; + +export '../repo/in_memory_repository.dart'; diff --git a/packages/pocketbase_sync/pubspec.yaml b/packages/pocketbase_sync/pubspec.yaml new file mode 100644 index 0000000..de7aa5c --- /dev/null +++ b/packages/pocketbase_sync/pubspec.yaml @@ -0,0 +1,37 @@ +name: pocketbase_sync +description: A Flutter package for offline-first syncing with PocketBase. +version: 0.0.1 +homepage: https://github.com/fluttercommunity/flutter_whatsnew +maintainer: Rody Davis (@rodydavis) +resolution: workspace +publish_to: 'none' + +environment: + sdk: ^3.5.0 + flutter: ^3.38.5 + +dependencies: + flutter: + sdk: flutter + pocketbase: ^0.23.0+1 + drift: ^2.14.0 + collection: ^1.18.0 + uuid: ^4.0.0 + path: ^1.9.0 + json_annotation: ^4.8.0 + logging: ^1.3.0 + hlc_dart: ^1.0.0 + diff_algorithims: + path: ../diff_algorithims + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.4.0 + drift_dev: ^2.14.0 + mockito: ^5.4.0 + http: ^1.6.0 + diff_match_patch: ^0.4.1 + archive: ^4.0.7 + glados: ^1.1.7 diff --git a/packages/pocketbase_sync/research/engine.md b/packages/pocketbase_sync/research/engine.md new file mode 100644 index 0000000..810b1c6 --- /dev/null +++ b/packages/pocketbase_sync/research/engine.md @@ -0,0 +1,443 @@ +# **Architecting a Resilient Offline-First Synchronization Engine for PocketBase and Dart** + +## **1\. The Paradigm Shift: From Connected CRUD to Distributed Consistency** + +The contemporary landscape of mobile and distributed application development has outgrown the traditional "Online-Only" CRUD (Create, Read, Update, Delete) model. In the conventional architecture, the server is the single source of truth, and the client is merely a transient viewerโ€”a "dumb" terminal that renders state fetched over a reliable network connection. However, this assumption of reliability is fundamentally flawed in mobile environments where latency fluctuates, connections drop, and users expect seamless interactivity regardless of signal strength. To meet these demands, we must transition to an **Offline-First** architecture. In this paradigm, the client device becomes a primary replica of the dataset, capable of performing reads and writes against a local database, while a background synchronization engine ensures eventual consistency with the central server. + +This report articulates the comprehensive design and implementation of a custom synchronization engine tailored for a specific, high-performance stack: a **Dart (Flutter)** client leveraging the **Drift** persistence library, and a **PocketBase** backend extended via **Go**. The architectural mandate includes rigorous requirements: the use of the **Myers Diff Algorithm** for granular text synchronization, the implementation of **Hybrid Logical Clocks (HLC)** to resolve causal ordering in the absence of reliable physical time, and the adoption of **UUIDv7** to mitigate identifier collisions in distributed generation. + +### **1.1 The CAP Theorem and Local Autonomy** + +In the context of the CAP Theorem (Consistency, Availability, Partition Tolerance), an offline-first mobile application effectively operates as a distributed system that prioritizes Availability and Partition Tolerance (AP) during disconnection, striving for Strong Eventual Consistency (SEC) upon reconnection. The synchronization engine is the mechanism that bridges the gap between the divergent local state (the "partitioned" node) and the server state. + +Unlike simple caching strategies where local data is ephemeral, an offline-first architecture treats the local database (SQLite via Drift) as a persistent, authoritative store for the user's actions. This inversion of control introduces significant complexity. We are no longer simply sending HTTP POST requests; we are managing a distributed ledger of mutations that must be reconciled. This requires us to solve three fundamental problems: + +1. **Identity:** How do we generate unique identifiers on the client without coordinating with the server? +2. **Causality:** How do we order events when device clocks are unreliable, drifting, or maliciously altered? +3. **Conflict Resolution:** How do we merge concurrent edits to the same data point without losing user intent? + +The following sections dissect these challenges and propose a unified, robust solution. + +## --- + +**2\. The Crisis of Identity: Distributed ID Generation** + +One of the most immediate challenges in decoupling the client from the server is the generation of primary keys. In a centralized system, the database (e.g., PostgreSQL sequences or auto-incrementing integers) issues IDs. In a distributed system, the client must generate the ID *before* the record is sent to the server to maintain local referential integrity and allow for immediate UI updates. + +### **2.1 The Insufficiency of PocketBase Default IDs** + +PocketBase, by default, utilizes a 15-character random alphanumeric string for record IDs. The alphabet typically consists of lowercase letters and numbers (a-z0-9), yielding a character set size of 36\. The total entropy space is $36^{15}$. While this provides a massive number of combinations, it poses two distinct problems in our specific architectural context: + +1. **The Birthday Paradox in Distributed Environments:** The "Birthday Problem" dictates that the probability of a collision increases much faster than the number of records. While $36^{15}$ is large, the risk is non-zero, especially when relying on the pseudo-random number generators (PRNG) of diverse client devices (Android, iOS, Web), which may have varying degrees of entropy quality compared to a server-side crypto/rand. In a system designed for "Offline-First" robustness, a single ID collision during synchronization is catastrophicโ€”it results in a merge conflict where two distinct entities are treated as the same, potentially overwriting data. +2. **Database Page Fragmentation (The Hidden Performance Killer):** PocketBase runs on **SQLite**. SQLite tables (specifically ROWID tables or those with textual primary keys) are stored as B-Trees. When records are inserted with purely random IDs, they are distributed arbitrarily across the B-Tree leaf nodes. This random insertion pattern forces the database engine to frequently split pages and rebalance the tree, leading to high disk I/O overhead and significant fragmentation of the database file. In a high-throughput sync scenario (e.g., a client pushing 1,000 offline changes), random I/O can become a bottleneck.1 + +### **2.2 The Solution: UUIDv7** + +To address both the collision risk and the database performance constraints, we mandate the adoption of **UUIDv7** (Universally Unique Identifier Version 7), enabling a move away from purely random identifiers to **k-sortable** identifiers. + +#### **2.2.1 Mechanism and Entropy** + +UUIDv7 is a 128-bit identifier designed specifically for database locality. Its structure is composed of: + +* **Unix Timestamp (48 bits):** Encodes the millisecond precision timestamp. +* **Version & Variant (6 bits):** Metadata identifying the format. +* **Random Data (74 bits):** Entropy to ensure uniqueness within the same millisecond.3 + +This structure offers a guarantee that IDs generated at different milliseconds are strictly ordered. For IDs generated within the same millisecond, the 74 bits of entropy provide a collision resistance that far exceeds the user's current 15-character alphanumeric solution. + +#### **2.2.2 Impact on SQLite Performance** + +The most profound advantage of UUIDv7 in this architecture is its impact on the SQLite B-Tree. Because UUIDv7 is monotonic (values increase over time), new records are almost always appended to the right side of the B-Tree. This "sequential insertion" pattern drastically reduces the need for page splitting and tree rebalancing. It optimizes the Write-Ahead Log (WAL) performance and ensures that the database remains compact and performant even as the dataset grows into millions of rows. For a synchronization engine processing batches of inserts, this can result in a write throughput increase of orders of magnitude compared to random IDs.1 + +### **2.3 Implementation Strategy** + +#### **2.3.1 Client-Side (Dart)** + +The Dart client must take responsibility for generating these IDs. We utilize the uuid package, which supports the proposed RFC 9562 standard for UUIDv7.6 + +Dart + +import 'package:uuid/uuid.dart'; + +class IdGenerator { + static const Uuid \_uuid \= Uuid(); + + /// Generates a time-ordered, collision-resistant UUIDv7. + static String next() { + return \_uuid.v7(); + } +} + +This ID is assigned to the record immediately upon creation in the local Drift database. This allows the client to build relations (foreign keys) between new offline records (e.g., creating a Post and a Comment offline) without waiting for the server to assign IDs. + +#### **2.3.2 Server-Side (Go/PocketBase)** + +The server must accept these client-generated IDs but also enforce validation to prevent malformed data from corrupting the index. We extend PocketBase using its Go framework hooks, specifically OnRecordBeforeCreateRequest.7 + +We intercept the create request. If the client provides an ID, we validate it against the UUIDv7 regex. If the ID is missing (which shouldn't happen in our protocol, but might in direct API usage), we generate one. + +Go + +package main + +import ( + "log" + "regexp" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" + "github.com/gofrs/uuid/v5" // Using a Go UUID library +) + +func main() { + app := pocketbase.New() + + // Regex for UUID validation (simplified) + uuidRegex := regexp.MustCompile(\`^\[0-9a-fA-F\]{8}-\[0-9a-fA-F\]{4}-7\[0-9a-fA-F\]{3}-\[0-9a-fA-F\]{3}-\[0-9a-fA-F\]{12}$\`) + + app.OnRecordBeforeCreateRequest("todos", "notes").BindFunc(func(e \*core.RecordRequestEvent) error { + // 1\. Validation: Ensure ID is a valid UUIDv7 if provided + if e.Record.Id\!= "" { + if\!uuidRegex.MatchString(e.Record.Id) { + // Reject invalid formats to protect DB locality + return e.BadRequestError("Invalid ID format. Must be UUIDv7.", nil) + } + } else { + // 2\. Fallback: Generate UUIDv7 if missing + id, err := uuid.NewV7() + if err\!= nil { + return err + } + e.Record.SetId(id.String()) + } + return e.Next() + }) + + if err := app.Start(); err\!= nil { + log.Fatal(err) + } +} + +This implementation explicitly overrides PocketBase's default ID generator, solving the user's duplication issue and aligning the storage engine for high-performance synchronization.8 + +## --- + +**3\. Temporal Truth: The Hybrid Logical Clock (HLC)** + +In distributed systems, physical time (wall-clock time) is a treacherous metric. Devices drift, batteries die, and users manually change clocks to "cheat" in games or bypass software trials. If a synchronization engine relies solely on updated\_at timestamps derived from the system clock, it falls prey to anomalies: a change made "tomorrow" (due to a bad clock) could become immutable, locking out all legitimate subsequent edits. + +To maintain causalityโ€”the property that if event A causes event B, A is ordered before Bโ€”we require **Logical Clocks**. However, pure logical clocks (like Lamport counters) lose the relationship to physical time, making it hard to query "changes since 5 minutes ago." The **Hybrid Logical Clock (HLC)** is the synthesis of these two concepts, providing the best of both worlds: causal strictness and physical proximity.9 + +### **3.1 The HLC Algorithm** + +The HLC is a tuple (l, c, node), where: + +* l (logical time): The maximum physical time the node has seen (either from its own clock or incoming messages). +* c (counter): A strictly increasing counter used to order events that occur within the same millisecond or when the physical clock regresses. +* node: A unique tie-breaker (e.g., a hash of the device ID). + +The core rules for the HLC are: + +1. **Monotonicity:** The clock never moves backward. If the physical clock rewinds, the HLC continues forward using the logical component. +2. **Causality:** When a message is received with timestamp T\_remote, the local clock updates to max(T\_local, T\_remote, T\_physical). This ensures the local event appears to happen *after* the remote event that triggered it. +3. **Bounded Drift:** The logical time l tracks closely with the physical time pt. It does not grow unboundedly into the future unless the physical clock itself is wrong or the message frequency exceeds the counter capacity (which is rare).12 + +### **3.2 HLC Serialization for SQLite** + +PocketBase and SQLite do not have a native "HLC" data type. To perform efficient synchronization queries (e.g., "Give me all records changed since HLC\_X"), we must store the HLC in a format that preserves its sort order when compared as a primitive type. + +We will serialize the HLC as a **lexically sortable string**. This allows us to use standard SQL comparison operators (\>, \<) and leverage B-Tree indexes for range scans.14 + +**Format:** \-\-\ + +* **Physical:** 48-bit integer (milliseconds), formatted as a 12-char hexadecimal string (zero-padded). +* **Logical:** 16-bit integer (counter), formatted as a 4-char hexadecimal string. +* **NodeID:** Fixed-length hexadecimal string (e.g., 10 chars). + +**Example:** + +* Timestamp: 1678886400000 $\\rightarrow$ 0186E5B64800 +* Counter: 42 $\\rightarrow$ 002A +* Node: ClientA +* **Serialized:** 0186E5B64800-002A-ClientA + +This string format guarantees that HLC\_A \> HLC\_B in a SQL query corresponds exactly to the causal ordering of the clocks. + +### **3.3 Implementation in Dart and Go** + +Dart Implementation: +We implement a singleton HlcProvider that maintains the local clock state. Every time a local write occurs (Create/Update/Delete), the provider increments the clock. Every time a sync payload is received from the server, the provider merges the remote HLC into the local state. + +Dart + +class Hlc implements Comparable\ { + final int millis; + final int counter; + final String nodeId; + + Hlc(this.millis, this.counter, this.nodeId); + + // Lexical serialization for SQLite + @override + String toString() \=\> + '${millis.toRadixString(16).padLeft(12, "0")}\-' + '${counter.toRadixString(16).padLeft(4, "0")}\-' + '$nodeId'; + + // The 'Receive' Logic (Merge) + static Hlc receive(Hlc local, Hlc remote) { + final now \= DateTime.now().millisecondsSinceEpoch; + + // The new time is the max of physical, local logical, and remote logical + final newMillis \= \[local.millis, remote.millis, now\].reduce(max); + + int newCounter; + if (newMillis \== local.millis && newMillis \== remote.millis) { + newCounter \= max(local.counter, remote.counter) \+ 1; + } else if (newMillis \== local.millis) { + newCounter \= local.counter \+ 1; + } else if (newMillis \== remote.millis) { + newCounter \= remote.counter \+ 1; + } else { + newCounter \= 0; + } + + return Hlc(newMillis, newCounter, local.nodeId); + } +} + +Ref: 9 + +Server Storage: +In PocketBase, we define a custom field hlc (type: Text) for every synced collection. Crucially, we add a database index on this field. +CREATE INDEX idx\_collection\_hlc ON collection(hlc); +This index is the cornerstone of the sync performance, allowing the server to calculate the "delta" of changes for a client in $O(\\log N)$ time rather than scanning the entire table.17 + +## --- + +**4\. Algorithmic Resolution: Myers Diff and Differential Sync** + +The requirement to use the **Myers Diff Algorithm** indicates a need for high-fidelity text synchronization. Standard "Last Write Wins" (LWW) is acceptable for scalar values (like a "Status" dropdown), but it is destructive for text. If User A fixes a typo in the first paragraph and User B adds a sentence to the second paragraph while offline, LWW would discard one of these changes. Myers Diff allows us to merge them. + +### **4.1 The Theoretical Basis: 3-Way Merge** + +To correctly merge concurrent edits, we cannot simply compare the Client's version and the Server's version. We need a reference point: the **Base** version (also known as the Common Ancestor). This creates a 3-way merge scenario 19: + +1. **Base:** The state of the record when the client *last* synced. +2. **Theirs (Server):** The current state on the server (potentially modified by other clients). +3. **Yours (Client):** The current state on the client (with offline edits). + +The Myers algorithm operates by finding the Shortest Edit Script (SES) that transforms Base into Yours. This script is a sequence of insertions and deletions. + +### **4.2 The Differential Synchronization Protocol** + +We adopt a protocol similar to the one used by Google Docs (specifically the Google Diff-Match-Patch library implementation).21 + +1. **Client Diff:** The client calculates Diff(Base, Yours). This generates a patch. +2. **Transmission:** The client sends this patch to the server. +3. **Server Patch:** The server attempts to apply this patch to Theirs. + * NewServerState \= PatchApply(Patch, Theirs) + * The PatchApply function is robust; it uses the context (surrounding text) to locate the correct insertion point even if the text has shifted due to other edits. This is "Fuzzy Patching." +4. **Confirmation:** If successful, the server saves NewServerState and updates the HLC. + +### **4.3 Why Myers?** + +The Myers algorithm is an $O(ND)$ greedy algorithm that optimizes for the "longest common subsequence".24 Its strength lies in its human-centric output. It tends to group changes into logical blocks (e.g., deleting a whole word) rather than fracturing them into atomized character edits, which makes the resulting patches more likely to apply cleanly against a modified target. Furthermore, diff-match-patch includes a semantic cleanup phase that realigns diffs to boundaries (like newlines or words), preventing the "wrong end" problem where identical characters are mismatched.26 + +## --- + +**5\. Client-Side Implementation: The Drift Architecture** + +The client side is responsible for persistence, queueing, and UI reactivity. **Drift** is the chosen ORM for Flutter because of its compile-time safety and deep SQLite integration.27 + +### **5.1 The Shadow Table Pattern** + +To support the 3-way merge required by Myers diff, the client must store *two* copies of every record: + +1. **The Application Table (todos):** This is the live data the user sees and edits. +2. **The Shadow Table (todos\_shadow):** This serves as the "Base" for the diff. It represents the exact state of the record as it was last confirmed by the server. + +**Schema Design (Drift):** + +Dart + +// The main table visible to the UI +class Todos extends Table { + TextColumn get id \=\> text().withLength(min: 36, max: 36)(); // UUIDv7 + TextColumn get content \=\> text()(); + TextColumn get hlc \=\> text()(); // The HLC of the last write + BoolColumn get deleted \=\> boolean().withDefault(const Constant(false))(); + + @override + Set\ get primaryKey \=\> {id}; +} + +// The Shadow Table +class TodosShadow extends Table { + TextColumn get id \=\> text().withLength(min: 36, max: 36)(); + TextColumn get content \=\> text()(); // The "Base" text + TextColumn get hlc \=\> text()(); // The server HLC at last sync + + @override + Set\ get primaryKey \=\> {id}; +} + +// The Sync Queue +class SyncQueue extends Table { + IntColumn get id \=\> integer().autoIncrement()(); + TextColumn get recordId \=\> text()(); + TextColumn get collection \=\> text()(); + TextColumn get operation \=\> text()(); // INSERT, UPDATE, DELETE + TextColumn get payload \=\> text()(); // JSON: { "patches": "...", "hlc": "..." } + IntColumn get status \=\> integer()(); // 0: Pending, 1: In-Flight +} + +### **5.2 The Write Lifecycle** + +1. **User Edit:** The user types in a text field. +2. **Local Persist:** The app writes the new text to Todos. The local HLC is incremented. +3. **Queue Logic:** + * The sync engine (running in a background Isolate) detects the change. + * It fetches Todos.content (Yours) and TodosShadow.content (Base). + * It computes diff \= myers\_diff(Base, Yours). + * It writes a SyncQueue entry containing the patch and the *Base HLC*. + * *Optimization:* If a queue entry already exists for this record (user typed twice while offline), the engine squashes the updates by re-computing the diff against the immutable Shadow. + +### **5.3 Offline Tolerance** + +This architecture is inherently offline-tolerant. The SyncQueue simply accumulates patches. The UI remains responsive because it binds to the Todos table, which reflects local state immediately ("Optimistic UI").29 When the network restores, the engine processes the queue FIFO (First-In, First-Out) or batched, attempting to push changes to the server. + +## --- + +**6\. Server-Side Implementation: Extending PocketBase** + +PocketBase's standard API is insufficient for this logic. The standard update endpoint performs a generic UPDATE SET..., which would overwrite concurrent changes. We must implement a custom sync endpoint using Go. + +### **6.1 The Custom Sync Endpoint** + +We register a route POST /api/sync that accepts a batch of operations. This entire batch processing must happen inside a database transaction to ensure atomicityโ€”either all patches apply, or we rollback (in case of critical system failure, though typically we handle per-record errors gracefully). + +**Route Registration:** + +Go + +app.OnServe().BindFunc(func(se \*core.ServeEvent) error { + se.Router.POST("/api/sync", func(e \*core.RequestEvent) error { + return handleSync(app, e) + }) + return se.Next() +}) + +Ref: 30 + +### **6.2 Transactional Logic and Conflict Resolution** + +The handleSync function performs the heavy lifting. + +**Algorithm:** + +1. **Batch Start:** tx, \_ := app.Dao().DB().Begin() +2. **Iterate Operations:** For each incoming patch: + * **Lock Record:** SELECT \* FROM todos WHERE id \=? (Use appropriate locking if using Postgres, but for SQLite, the single-writer WAL mode inherently serializes this 32). + * **HLC Check:** Compare Incoming.BaseHLC with ServerRecord.HLC. + * **Case A (Idempotent):** Incoming.HLC \<= ServerRecord.HLC. The client is sending an old update. We acknowledge success but do nothing. + * **Case B (Fast-Forward):** Incoming.BaseHLC \== ServerRecord.HLC. The server hasn't changed. We apply the patch directly. + * **Case C (Conflict):** Incoming.BaseHLC \< ServerRecord.HLC. The server has moved forward. We must use dmp.PatchApply. + * **Patch Application:** + * patches, \_ := dmp.PatchFromText(Incoming.PatchString) + * newText, results := dmp.PatchApply(patches, ServerRecord.Content) + * **Check Results:** If results contains failures (the fuzzy match failed because the context was too different), we have a "Hard Conflict". + * **Resolution:** + * *Soft Conflict (Patch Succeeded):* Save newText. Update ServerRecord.HLC to New(Max(Local, Remote)). + * *Hard Conflict (Patch Failed):* We cannot merge. The standard strategy here is "Server Wins" or "Error". We return an error code to the client indicating "Rebase Required". The client must then pull the new server state, update its Shadow, and try to re-apply its local edits (or show a diff UI to the user). +3. **Commit:** If all critical operations succeed, commit the transaction. + +### **6.3 Performance Considerations: SQLite WAL** + +PocketBase uses SQLite in **Write-Ahead Log (WAL)** mode. This allows multiple readers and one writer. + +* **Implication:** The POST /sync transaction blocks other writes. It is crucial to keep the transaction logic CPU-efficient. +* **Myers Diff Cost:** Calculating diffs is expensive ($O(ND)$). However, *applying* patches is relatively cheap ($O(N)$). By offloading the diff calculation to the client (distributed computing), the server only bears the cost of the application, maximizing throughput.5 + +## --- + +**7\. The Sync Loop Protocol** + +The synchronization engine operates in a continuous loop, managed by the client. + +### **7.1 Push Phase (Client \-\> Server)** + +1. Client selects PENDING items from SyncQueue. +2. Bundles them into a JSON payload. +3. Sends POST /api/sync. +4. **Server Response:** Returns a list of { "id": "...", "server\_hlc": "..." } for successfully synced items. +5. **Client Finalization:** + * For each success, the client updates TodosShadow to match the current Todos (establishing a new Base). + * Updates Todos.hlc to the returned server\_hlc. + * Deletes the SyncQueue entry. + +### **7.2 Pull Phase (Server \-\> Client)** + +1. Client tracks a local variable last\_pull\_hlc (persisted in SharedPreferences or a meta table). +2. Client sends GET /api/sync?since=last\_pull\_hlc. +3. **Server Query:** SELECT \* FROM todos WHERE hlc \>?. Thanks to the HLC index and lexical sortability, this is a highly efficient range query.17 +4. **Client Merge:** + * For each incoming record: + * Check if there is a pending local change in SyncQueue. + * **No Pending Change:** Simply update Todos and TodosShadow. + * **Pending Change Exists:** We have a conflict locally. + * Update TodosShadow to the *new* incoming server text. + * *Rebase:* Re-calculate the diff between the *new* Shadow and the current Todos. Update the SyncQueue entry. This effectively "floats" the user's local changes on top of the incoming server changes. + +## --- + +**8\. Conclusion** + +The architecture defined in this report satisfies the rigorous requirements of a modern, offline-first application. By replacing standard PocketBase IDs with **UUIDv7**, we ensure distributed collision resistance and SQLite performance. By implementing **Hybrid Logical Clocks** serialized as sortable strings, we solve the problem of unreliable time and enable efficient delta queries. Finally, by integrating the **Myers Diff algorithm** into a **Shadow Table** synchronization pattern, we achieve a system that preserves user intent during concurrent text editing. + +This system is not merely a theoretical construct; it is built upon the specific capabilities of **Drift**, **Go**, and **SQLite's WAL mode**, leveraging the strengths of each component to create a synchronization engine that is robust, scalable, and tolerant of the chaotic nature of mobile networks. The use of a custom Go extension for PocketBase is the linchpin, moving logic from the fragile client-side layer to a transactional, authoritative server environment. + +### **Summary of Data Structures** + +| Component | Format/Type | Purpose | +| :---- | :---- | :---- | +| **ID** | UUIDv7 (36-char string) | Collision-free, k-sortable, B-Tree friendly. | +| **Clock** | HLC (HexTime-HexCount-Node) | Causal ordering, physical proximity, range queries. | +| **Sync Payload** | JSON (Patches) | Bandwidth efficiency, merge capability. | +| **Client DB** | SQLite (Drift) | Shadow tables for 3-way merge base. | +| **Server DB** | SQLite (PocketBase) | Authoritative store, indexed HLC for fast pulls. | + +#### **Works cited** + +1. UUID vs CUID vs NanoID: Choosing the Right ID Generator for Your Application \- Wisp CMS, accessed December 22, 2025, [https://www.wisp.blog/blog/uuid-vs-cuid-vs-nanoid-choosing-the-right-id-generator-for-your-application](https://www.wisp.blog/blog/uuid-vs-cuid-vs-nanoid-choosing-the-right-id-generator-for-your-application) +2. Best practices for SQLite performance | App quality \- Android Developers, accessed December 22, 2025, [https://developer.android.com/topic/performance/sqlite-performance-best-practices](https://developer.android.com/topic/performance/sqlite-performance-best-practices) +3. Understanding UUID v4, UUID v7, Snowflake ID, and Nano ID, GUID, ULID, KSUID โ€” In Simple Terms | by Dinesh Arney | Medium, accessed December 22, 2025, [https://medium.com/@dinesharney/understanding-uuid-v4-uuid-v7-snowflake-id-and-nano-id-in-simple-terms-c50acf185b00](https://medium.com/@dinesharney/understanding-uuid-v4-uuid-v7-snowflake-id-and-nano-id-in-simple-terms-c50acf185b00) +4. uuidv7 \- NPM, accessed December 22, 2025, [https://www.npmjs.com/package/uuidv7](https://www.npmjs.com/package/uuidv7) +5. SQLite Optimizations For Ultra High-Performance \- PowerSync, accessed December 22, 2025, [https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance](https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance) +6. uuidv7 | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/uuidv7](https://pub.dev/packages/uuidv7) +7. Extend with Go \- Event hooks \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-event-hooks/](https://pocketbase.io/docs/go-event-hooks/) +8. How to automatically generate UUIDv7 as record IDs \#7383 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/7383](https://github.com/pocketbase/pocketbase/discussions/7383) +9. Hybrid Logical Clock implementation in TypeScript \- typeonce.dev, accessed December 22, 2025, [https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript](https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript) +10. Hybrid Logical Clocks | Kevin Sookocheff, accessed December 22, 2025, [https://sookocheff.com/post/time/hybrid-logical-clocks/](https://sookocheff.com/post/time/hybrid-logical-clocks/) +11. Hybrid logical clock \- Andy Matuschak's notes, accessed December 22, 2025, [https://notes.andymatuschak.org/Hybrid\_logical\_clock](https://notes.andymatuschak.org/Hybrid_logical_clock) +12. Hybrid Logical Clocks \- Murat Buffalo, accessed December 22, 2025, [http://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html](http://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html) +13. Hybrid Logical Clocks \- Bartosz Sypytkowski, accessed December 22, 2025, [https://www.bartoszsypytkowski.com/hybrid-logical-clocks/](https://www.bartoszsypytkowski.com/hybrid-logical-clocks/) +14. Database File Format \- SQLite, accessed December 22, 2025, [https://www.sqlite.org/fileformat.html](https://www.sqlite.org/fileformat.html) +15. Try Case-Insensitive Unicode Sorting in SQLite with Pre-collated Strings \- Atomic Spin, accessed December 22, 2025, [https://spin.atomicobject.com/case-insensitive-unicode-sqlite/](https://spin.atomicobject.com/case-insensitive-unicode-sqlite/) +16. hlc\_dart | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/hlc\_dart](https://pub.dev/packages/hlc_dart) +17. SQLite BETWEEN Operator By Practical Examples, accessed December 22, 2025, [https://www.sqlitetutorial.net/sqlite-between/](https://www.sqlitetutorial.net/sqlite-between/) +18. Feature suggestion: Support SQLite json\_extract function ยท Issue \#423 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/issues/423](https://github.com/pocketbase/pocketbase/issues/423) +19. Three-Way Merge \- Revision Control, accessed December 22, 2025, [https://tonyg.github.io/revctrl.org/ThreeWayMerge.html](https://tonyg.github.io/revctrl.org/ThreeWayMerge.html) +20. Three-Way Merging Algorithm for Structured Data \- IEEE Xplore, accessed December 22, 2025, [https://ieeexplore.ieee.org/iel8/6287639/10820123/11045384.pdf](https://ieeexplore.ieee.org/iel8/6287639/10820123/11045384.pdf) +21. Diff Match Patch \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/diff\_match\_patch/latest/](https://pub.dev/documentation/diff_match_patch/latest/) +22. GerHobbelt/google-diff-match-patch: Diff, Match and Patch Library (original at http://google.com/p/google-diff-match-patch) \- GitHub, accessed December 22, 2025, [https://github.com/GerHobbelt/google-diff-match-patch](https://github.com/GerHobbelt/google-diff-match-patch) +23. Differential Synchronization \- Google Research, accessed December 22, 2025, [https://research.google.com/pubs/archive/35605.pdf](https://research.google.com/pubs/archive/35605.pdf) +24. Implementation of the Myers diff algorithm with O(ND) complexity, multiple output formats, and benchmarking suite. \- GitHub, accessed December 22, 2025, [https://github.com/NeaByteLab/Myers-Diff](https://github.com/NeaByteLab/Myers-Diff) +25. The Myers diff algorithm: part 1 \- The If Works, accessed December 22, 2025, [https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/](https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/) +26. Avoiding the Myers Diff Algorithm "Wrong-End" Problem \- Stack Overflow, accessed December 22, 2025, [https://stackoverflow.com/questions/79322411/avoiding-the-myers-diff-algorithm-wrong-end-problem](https://stackoverflow.com/questions/79322411/avoiding-the-myers-diff-algorithm-wrong-end-problem) +27. pocketbase\_drift \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/pocketbase\_drift/latest/](https://pub.dev/documentation/pocketbase_drift/latest/) +28. drift | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/drift](https://pub.dev/packages/drift) +29. Offline-First Flutter: Implementation Blueprint for Real-World Apps \- GeekyAnts, accessed December 22, 2025, [https://geekyants.com/blog/offline-first-flutter-implementation-blueprint-for-real-world-apps](https://geekyants.com/blog/offline-first-flutter-implementation-blueprint-for-real-world-apps) +30. Extend with Go \- Routing \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-routing/](https://pocketbase.io/docs/go-routing/) +31. Introduction \- Extending PocketBase \- Docs, accessed December 22, 2025, [https://pocketbase.io/docs/use-as-framework/](https://pocketbase.io/docs/use-as-framework/) +32. Extend with Go \- Overview \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-overview/](https://pocketbase.io/docs/go-overview/) \ No newline at end of file diff --git a/packages/pocketbase_sync/research/package.md b/packages/pocketbase_sync/research/package.md new file mode 100644 index 0000000..ffb45f9 --- /dev/null +++ b/packages/pocketbase_sync/research/package.md @@ -0,0 +1,469 @@ +# **Architectural Specification for a Generic Offline-First Synchronization Engine on PocketBase** + +## **Executive Summary** + +The transition from connected, state-dependent client-server architectures to offline-first distributed systems represents a fundamental shift in application reliability and user experience. While PocketBase provides a highly portable, SQLite-backed backend solution, its default interaction model is predicated on synchronous RESTful communication, rendering it susceptible to network partitioning. This report provides an exhaustive architectural specification for bridging this gap through a generic synchronization engine. The proposed solution is comprised of a server-side Go plugin and a client-side Flutter package, designed to operate agnostically across any PocketBase instance and schema. + +The architecture necessitates a move from physical time to causal ordering via Hybrid Logical Clocks (HLC), the implementation of a distributed deletion strategy using a global tombstone registry, and the adoption of differential synchronization for text fields using the Myers Diff algorithm. By leveraging PocketBaseโ€™s extensible hook systemโ€”specifically OnRecordBeforeUpsert, OnRecordAfterDelete, and OnServeโ€”this system injects statefulness into the mutation lifecycle without requiring invasive schema modifications. This document serves as a definitive guide for implementing this protocol, prioritizing data consistency, conflict resolution, and high availability in constrained network environments. + +## **1\. Architectural Foundations of the Generic Synchronization Engine** + +### **1.1 The Distributed Consistency Challenge** + +In the domain of mobile computing, the network must be treated as a hostile environment. The fallacy that the network is reliable, with zero latency and infinite bandwidth, often leads to brittle application designs that fail catastrophically when a user enters an elevator or a tunnel. The CAP theorem dictates that in the presence of a network partition (P)โ€”an unavoidable reality for mobile devicesโ€”a system must choose between Consistency (C) and Availability (A). For a user-facing mobile application, Availability is non-negotiable; the user must be able to read and write data regardless of connectivity status. Consequently, this architecture embraces Eventual Consistency. + +The core challenge in adapting PocketBase for this paradigm lies in its default "Realtime" nature. PocketBaseโ€™s realtime subscriptions broadcast state changes as they happen, assuming the client is present to receive them. If a client is offline, it misses these ephemeral messages. An offline-first architecture must therefore shift from an ephemeral event stream to a persistent replication log. The synchronization engine must guarantee that every mutation generated on the Edge (the mobile device) eventually reaches the Origin (the server), and conversely, that the Origin's state converges with the Edge, regardless of the duration of the disconnection.1 + +### **1.2 The Dual-Store Repository Pattern** + +To achieve high availability, the architecture enforces a strict decoupling of the UI from the network. The generic Flutter package acts as a Repository layer that mediates between the application logic and two distinct data stores: + +1. **The Local Replica:** An embedded SQLite database on the device (managed via drift or sqflite in the Dart ecosystem). This store serves as the single source of truth for the UI, enabling instantaneous reads and writes (Optimistic UI).3 +2. **The Remote Authority:** The PocketBase server, which acts as the convergence point for all distributed clients. + +The critical requirement of "generic application" means this Repository cannot be hardcoded for specific collections (e.g., "Users" or "Tasks"). Instead, it must dynamically inspect the PocketBase schema (fetched via the API) and provision corresponding local tables on the fly. This dynamic mapping capability allows the Flutter package to be dropped into any project, instantly providing offline capabilities for whatever collections exist on the backend.4 + +### **1.3 Leveraging PocketBaseโ€™s Extensibility** + +PocketBase distinguishes itself from other BaaS providers through its "framework-as-a-library" model. Written in Go, it allows developers to compile their own binary that embeds the core PocketBase logic while injecting custom code. This capability is pivotal for our synchronization engine. We cannot rely solely on the external REST API because we need to intercept database transactions to inject synchronization metadata (Logical Clocks) and capture deletions (Tombstones) that would otherwise be lost to an offline client. + +The Go plugin mechanism allows us to register hooks such as OnRecordBeforeCreateRequest and OnRecordBeforeUpdateRequest. These hooks provide access to the core.Record object *before* it is persisted to SQLite, allowing the plugin to validate causal ordering constraints and reject out-of-order updates before they corrupt the database state. Furthermore, the OnServe hook enables the registration of custom "Sync" endpoints that can perform batch operationsโ€”reading tombstones and active records in a single transactionโ€”thereby reducing network round-trips and database lock contention.6 + +| Feature | Standard PocketBase | Offline-First PocketBase | +| :---- | :---- | :---- | +| **Primary Data Source** | Remote Server (API) | Local SQLite Replica | +| **Consistency Model** | Immediate (on Request) | Eventual (on Sync) | +| **Time Source** | Server Wall Clock | Hybrid Logical Clock | +| **Deletion Handling** | Immediate Removal | Tombstone Retention | +| **Conflict Resolution** | Last-Request-Wins | Causal Ordering / Merge | + +## **2\. Temporal Consistency: The Hybrid Logical Clock** + +### **2.1 The Inadequacy of Physical Time** + +A naive approach to synchronization relies on updated\_at timestamps generated by the system clock. In a distributed system of mobile devices, this approach is fundamentally flawed due to clock skew. A device with a clock set 10 minutes in the future will generate records that, under a standard Last-Write-Wins (LWW) policy, will overwrite any conflicting data generated by devices with accurate clocks for the next 10 minutes. Furthermore, physical clocks lack the precision to order events occurring within the same millisecond across different nodes. + +To solve this, the generic architecture utilizes Hybrid Logical Clocks (HLC). The HLC provides a mechanism to capture the causality of events (if Event A causes Event B, the timestamp of B must be greater than A) while keeping the timestamp close to physical time to remain human-readable and useful for querying. + +### **2.2 Mathematical Definition of HLC** + +An HLC timestamp consists of three components: (l, c, id), where: + +* $l$ is the physical component, representing the maximum wall-clock time observed by the node. +* $c$ is the logical component, a counter incremented to distinguish events that occur at the same $l$. +* $id$ is a unique node identifier (e.g., a UUID or hash of the device ID) used to break ties. + +The generic Flutter package must maintain a local HLC state. Upon a local mutation (Create/Update), the package calculates the new timestamp $hlc\_{new}$ as follows: + +$$l' \= \\max(l\_{old}, pt\_{now})$$ + +$$c' \= \\begin{cases} c\_{old} \+ 1 & \\text{if } l' \= l\_{old} \\\\ 0 & \\text{if } l' \> l\_{old} \\end{cases}$$ +Where $pt\_{now}$ is the device's current physical time. This algorithm ensures that the logical clock never moves backward, even if the device's physical clock is adjusted backward by the user or the OS.8 + +### **2.3 Serialization and Storage** + +While HLCs are conceptually tuples, they must be stored in PocketBase's standard fields. PocketBase supports text, number, and date types. Storing the HLC as a strict, lexically sortable string is the most robust approach for a generic plugin. + +**Format:** YYYY-MM-DDTHH:mm:ss.sssZ-CCCC-NODEID + +* YYYY...Z: The physical component ($l$), ISO-8601 formatted. +* CCCC: The logical counter ($c$), padded to 4 digits (hex or decimal). +* NODEID: The truncated node ID. + +This string format allows standard lexicographical comparison (string sorting) to be equivalent to chronological ordering. The Go plugin creates a custom field, nominally named \_hlc, on all synchronized collections. Since the architecture is "generic," the plugin automatically checks for this field's existence on OnBootstrap and creates it if missing, ensuring no manual setup is required by the user. + +### **2.4 Drift Management and Security** + +The Go server plugin acts as the guardian of time. When a client pushes a mutation with a specific HLC, the server must validate two conditions: + +1. **Monotonicity:** The incoming HLC must be greater than the HLC currently stored on the record (if updating). +2. **Bounded Drift:** The physical component $l$ of the incoming HLC cannot be significantly further in the future than the server's wall clock (e.g., MaxDrift \= 60000ms). + +If a client sends a timestamp from the year 2050, the server rejects the generic sync request with a 400 Bad Request. This prevents a malicious or malfunctioning client from "poisoning" the timeline and making a record immutable to other clients.9 + +## **3\. Data Identification and Structural Integrity** + +### **3.1 Distributed ID Generation** + +In traditional web development, the database (e.g., via AUTO\_INCREMENT) assigns IDs. In an offline-first architecture, the client must generate the ID immediately upon creation to establish relationships between records (e.g., creating a Project and adding Tasks to it before syncing). + +PocketBase uses 15-character random alphanumeric strings by default.11 While UUIDs (36 characters) are the industry standard for distributed ID generation, PocketBase's ecosystem is optimized for the shorter format. To maintain the requirement of working with "any PocketBase instance," the generic Flutter package should utilize NanoID (specifically a Dart implementation like nanoid) configured to match PocketBase's alphabet and length. + +**Configuration:** + +* **Alphabet:** 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ +* **Length:** 15 + +The collision probability for a 15-character NanoID with this alphabet is extremely low (requiring millions of IDs per second to reach a 1% risk). However, the generic sync protocol must account for the theoretical possibility of a collision. + +### **3.2 Collision Handling Protocol** + +A collision occurs if Client A generates ID X offline, and Client B generates ID X and syncs it to the server before Client A comes online. When Client A attempts to push its record, the generic server plugin will return a unique constraint violation error. + +The Flutter package must implement a **Provisional ID Mapping** strategy: + +1. **Detection:** Catch the specific "Duplicate ID" error from the batch transaction. +2. **Remediation:** Generate a new ID Y. +3. **Refactoring:** Scan the local transaction queue and the local database for any Foreign Key references to X. Update them to point to Y. +4. **Retry:** Resubmit the batch with the new ID. + +This logic is encapsulated within the generic client's SyncManager class, ensuring the application code consuming the package does not need to handle ID remapping logic.13 + +### **3.3 The Schema Handshake** + +Since the solution is generic, the client package does not know the server's schema at compile time. Upon initialization, the Flutter package performs a "Schema Handshake": + +1. Client requests /api/collections from the server. +2. Server returns the JSON description of all collections and fields. +3. Client compares this schema hash against its local cached schema. +4. **Divergence:** If the schema has changed (e.g., a developer renamed a field on the backend), the client enters a "Migration Mode." + +In Migration Mode, the client must reconcile pending local mutations against the new schema. If a field description was renamed to bio, pending writes to description would fail. The generic package handles this by exposing a callback onSchemaChange(oldSchema, newSchema) where the developer can provide migration logic. If no callback is provided, the safe default is to drop mutations for fields that no longer exist to prevent the sync queue from becoming permanently stuck.15 + +## **4\. The Generic Synchronization Protocol** + +The synchronization protocol is a bidirectional exchange of state, orchestrated by the generic Flutter package and served by the Go plugin. It operates in two phases: Push (Upstream) and Pull (Downstream). + +### **4.1 Phase 1: The Push (Upstream) Strategy** + +The client accumulates mutations in a persistent MutationQueue table in its local SQLite database. Each entry contains the collection name, record ID, operation type (CREATE, UPDATE, DELETE), the JSON payload, and the HLC timestamp. + +When network connectivity is detected (via connectivity\_plus in Flutter), the client bundles these mutations into a **Transactional Batch**. PocketBase v0.23 introduced native support for batch requests, which is crucial for data integrity.16 Sending mutations individually would risk partial failures where a parent record creates successfully but its children fail, leaving the database in an inconsistent state. + +**Batch Structure:** + +JSON + +{ + "requests": +} + +The generic Go plugin intercepts this batch request using OnBatchRequest. It wraps the execution in a database transaction (app.RunInTransaction). It iterates through each operation, verifying that the incoming \_hlc is newer than the stored record's \_hlc. If a conflict is detected (e.g., incoming HLC \< stored HLC), the plugin resolves it (usually by rejecting the stale update or merging), ensuring that the server's state remains causally consistent.17 + +### **4.2 Phase 2: The Pull (Downstream) Strategy** + +After a successful push, the client requests updates from the server. To remain generic, the client cannot request specific endpoints like /api/collections/users. Instead, it uses a custom "Sync" endpoint exposed by the Go plugin: /api/pocketbase\_sync/pull. + +**Request Parameters:** + +* since: The HLC of the last successful sync (last\_synced\_hlc). +* limit: Pagination limit. + +**Server Logic (Go Plugin):** + +1. The plugin iterates through *all* collections registered in the app. +2. For each collection, it queries for records where updated \> last\_synced\_hlc (optimization: using updated is faster for query filtering, but the client uses \_hlc for merging). +3. It explicitly checks generic API rules (ViewRule) using app.CanAccessRecord. This step is critical; without it, the sync endpoint would become a backdoor bypassing the application's security model. +4. It constructs a response grouping changed records by collection. + +**Response Structure:** + +JSON + +{ + "todos": \[... records... \], + "users": \[... records... \], + "\_offline\_tombstones": \[... deleted record markers... \], + "new\_cursor": "2023-10-27T11:00:00Z-0000-S" +} + +The client receives this payload, updates its local SQLite replica, and advances its last\_synced\_hlc cursor. + +## **5\. Distributed Deletion Management: The Tombstone Pattern** + +### **5.1 The Deletion Visibility Problem** + +In a standard SQL database, a DELETE operation removes the row physically. For a client that is offline, this removal is invisible. When the client later asks for "changes since T," the deleted record is simply absent from the result set. The client, seeing no change, assumes its local copy is still valid, leading to "Zombie Records" that reappear or persist indefinitely on the device. + +To support "Any PocketBase Instance," we cannot mandate that users change their schema to add a deleted boolean column (Soft Delete) to every table. This violates the ease-of-use principle. Instead, the Go plugin implements a **Global Tombstone Registry**. + +### **5.2 The Global Tombstone Collection** + +Upon initialization, the Go plugin checks for a system collection named \_offline\_tombstones. If absent, it creates it programmatically using the core.Collection model. + +**Schema:** + +* collection: String (Target collection name) +* record\_id: String (Target record ID) +* \_hlc: String (Timestamp of deletion) + +### **5.3 Hook-Based Interception** + +The plugin registers a global OnRecordAfterDeleteRequest hook. This hook fires whenever a record is deleted via the API (including from the Admin UI). + +Go + +app.OnRecordAfterDeleteRequest().Add(func(e \*core.RecordDeleteEvent) error { + // Avoid recursion + if e.Collection.Name \== "\_offline\_tombstones" { + return nil + } + + tombstone := core.NewRecord(app.FindCollectionByNameOrId("\_offline\_tombstones")) + tombstone.Set("collection", e.Collection.Name) + tombstone.Set("record\_id", e.Record.Id) + tombstone.Set("\_hlc", NewHLC()) // Generate current HLC + + // Persist tombstone in the same transaction context if possible, + // or as a separate save. + return app.Save(tombstone) +}) + +This ensures that every deletion leaves a trace. During the Pull phase of synchronization, the generic client explicitly requests records from \_offline\_tombstones created after last\_synced\_hlc. It iterates through these tombstones and performs corresponding DELETE operations on its local SQLite database.19 + +### **5.4 The Reaper: Garbage Collection** + +Tombstones cannot accumulate indefinitely. The Go plugin must register a cron job (app.OnCron) to "reap" old tombstones. + +* **Schedule:** Daily. +* **Policy:** Delete tombstones older than SYNC\_retention\_period (default: 30 days). + +**Implication:** If a device remains offline for longer than the retention period (e.g., 31 days), it will miss the tombstone. Upon reconnection, it might re-upload the deleted record (Resurrection). To mitigate this, the client package checks the age of its last\_synced\_hlc. If it exceeds the retention period, the client declares "Bankruptcy": it wipes its local database and performs a fresh full sync from the server.21 + +## **6\. Conflict Resolution and Differential Synchronization** + +### **6.1 Last-Write-Wins (LWW) via HLC** + +For scalar data types (booleans, numbers, dates), the LWW strategy is the industry standard for generic synchronization. However, "Last" is defined by the HLC, not the wall clock. + +When the generic server plugin processes a batch update: + +1. It fetches the current record from the DB. +2. It compares incoming\_batch\_record.\_hlc vs db\_record.\_hlc. +3. **If Incoming \> DB:** The update is applied. +4. **If Incoming \< DB:** The update is discarded (the client is trying to overwrite newer data with older data). The server returns a success response (idempotency) but does not apply the change. The client will eventually receive the newer server state in the next Pull phase. + +### **6.2 Differential Synchronization: Myers Diff** + +For text and editor (HTML) fields, LWW is destructive. If User A corrects a typo in paragraph 1, and User B adds a sentence to paragraph 2, LWW will blindly overwrite one user's contribution. To solve this, the Generic Architecture implements Differential Synchronization using the Myers Diff Algorithm. + +This feature requires the Flutter package to use the diff\_match\_patch library (Dart) and the Go plugin to use the corresponding Go port.23 + +#### **6.2.1 The Diff Protocol** + +1. **Snapshotting:** The client stores a "Shadow Copy" of the text field as it was at the time of the last sync. +2. **Diff Generation:** When the client modifies the text, it computes the delta (patches) between the Shadow Copy and the Current Text using diff\_match\_patch.patch\_make(). +3. **Transmission:** The client sends the patches (serialized as string) instead of the full text, along with the \_hlc of the Shadow Copy (the Base Version). +4. **Server Application:** + * The Go plugin detects that a patch payload is being sent. + * It retrieves the current text from the database. + * It applies the patch using diff\_match\_patch.patch\_apply(). + * **Fuzzy Patching:** The Myers algorithm allows "fuzzy" application. Even if the server text has changed slightly (someone else edited a different paragraph), the algorithm attempts to locate the context for the patch and apply it non-destructively.25 +5. **Rejection:** If the text has diverged so significantly that the patch cannot be applied (fuzziness threshold exceeded), the server rejects the specific field update, forcing the client to pull the latest version and manually resolve. + +This strategy allows high-concurrency collaboration on text fields without the complexity of Operational Transformation (OT) or CRDTs, which would require specialized data structures incompatible with PocketBase's standard SQLite columns. + +## **7\. Server-Side Implementation Detail (Go Plugin)** + +### **7.1 Plugin Initialization and Routes** + +The generic plugin is designed to be imported into main.go. It encapsulates all logic to avoid polluting the main application scope. + +Go + +package offline\_sync + +import ( + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/cron" +) + +type SyncPlugin struct { + app core.App +} + +func New(app core.App) \*SyncPlugin { + return \&SyncPlugin{app: app} +} + +func (p \*SyncPlugin) Register() { + p.registerHooks() + p.registerRoutes() + p.registerCron() +} + +func (p \*SyncPlugin) registerRoutes() { + // Generic Pull Endpoint + p.app.OnServe().BindFunc(func(e \*core.ServeEvent) error { + e.Router.GET("/api/sync/pull", p.handlePull) + return e.Next() + }) +} + +### **7.2 Dynamic Schema Handling** + +The handlePull function demonstrates the "generic" capability. It does not use hardcoded struct definitions. Instead, it uses PocketBase's dynamic models.Record and dao methods. + +Go + +func (p \*SyncPlugin) handlePull(c \*core.RequestEvent) error { + since := c.Request.URL.Query().Get("since") + // 1\. Get all collections + collections, \_ := p.app.Dao().FindCollections() + + response := make(map\[string\]map\[string\]any) + + for \_, col := range collections { + // 2\. Query dynamically + records, \_ := p.app.Dao().FindRecordsByExpr(col.Name, + dbx.NewExp("\_hlc \> {:since}", dbx.Params{"since": since})) + + // 3\. Filter by ACL (Security) + visibleRecords :=map\[string\]any{} + for \_, rec := range records { + if p.app.CanAccessRecord(rec, c.RequestInfo(), rule.ViewRule) { + visibleRecords \= append(visibleRecords, rec.PublicExport()) + } + } + response\[col.Name\] \= visibleRecords + } + return c.JSON(200, response) +} + +*Note: This code snippet simplifies error handling for brevity. Real implementation must handle errors robustly.* + +This implementation ensures that if a user adds a Projects collection to their PocketBase instance, the sync engine immediately supports it without code changes. + +### **7.3 Batch Transaction Processing** + +The most critical server-side component is the transaction wrapper. When OnBatchRequest is triggered (or a custom batch endpoint is used), the plugin must ensure atomicity. + +Go + +func (p \*SyncPlugin) handleBatchPush(c \*core.RequestEvent) error { + // Parse batch payload... + + return p.app.Dao().RunInTransaction(func(txDao \*dao.Dao) error { + for \_, op := range operations { + // Validate HLC ordering + existing, err := txDao.FindRecordById(op.Collection, op.Id) + if err \== nil { + if op.HLC \< existing.GetString("\_hlc") { + continue // Ignore stale update (LWW) + } + } + + // Apply diffs or updates + record := models.NewRecord(op.Collection) + record.Load(op.Data) + if err := txDao.SaveRecord(record); err\!= nil { + return err // Rollback entire batch + } + } + return nil + }) +} + +This ensures that the client's view of the data remains consistent. Either all offline changes are accepted, or none are (triggering a retry), preventing partial sync states.27 + +## **8\. Client-Side Implementation Detail (Flutter Package)** + +### **8.1 The Generic Repository** + +The Flutter package (pocketbase\_offline) exposes a PocketBaseOffline client that wraps the standard SDK. + +Dart + +class PocketBaseOffline { + final PocketBase client; + final Database localDb; // Drift database + + Future\ init() async { + // 1\. Fetch remote schema + final collections \= await client.collections.getFullList(); + // 2\. Migrate local SQLite to match remote schema dynamically + await \_schemaManager.migrate(localDb, collections); + } + + // Generic Save + Future\ save(String collectionName, Map\ body) async { + // 1\. Assign HLC + body\['\_hlc'\] \= \_hlcManager.now().toString(); + + // 2\. Save to Local DB (Optimistic) + await localDb.table(collectionName).insertOnConflictUpdate(body); + + // 3\. Queue for Sync + await \_syncQueue.add(Mutation(collection: collectionName, body: body)); + + // 4\. Trigger Background Sync + \_syncService.trigger(); + + return RecordModel(data: body); + } +} + +### **8.2 Background Sync with Isolates** + +Flutter runs on a single thread (Main Isolate). Heavy JSON parsing and diffing during sync can cause UI jank. The generic package must perform the synchronization logic in a separate Isolate or utilize compute. + +The package should leverage workmanager for Android/iOS background execution, allowing sync to happen even if the app is closed. This background worker initializes its own generic PocketBase client, connects to the local SQLite (which must be concurrency-safe, e.g., using WAL mode in SQLite via drift), and performs the Push/Pull cycle.1 + +### **8.3 Handling Large Binaries** + +Synchronization of large files (images/videos) via the batch JSON payload is inefficient and prone to timeouts. The generic architecture uses a **Reference-Based Sync**. + +1. **Upload:** Binary files are uploaded immediately to the file storage endpoint (or queued in a separate UploadQueue if offline). +2. **Reference:** The file upload returns a filename (string). +3. **Sync:** The JSON record containing the filename string is synced via the standard protocol. +4. **Retrieval:** The generic client intercepts record.getList calls. For fields of type file, it checks a local cache (flutter\_cache\_manager). If missing, it downloads the file from the server using the filename reference. + +## **9\. Insights and Future Implications** + +### **9.1 Second-Order Insight: The Soft Delete Ripple Effect** + +Implementing a global tombstone collection (\_offline\_tombstones) introduces complexity regarding **Cascading Deletes**. In standard PocketBase, deleting a User may cascade to delete their Posts. + +* *Observation:* If the Go plugin captures the User deletion via hook, the Posts might be deleted by the database engine's internal foreign key triggers. These internal SQL deletes often **do not fire** application-level hooks. +* *Implication:* The generic plugin would generate a tombstone for the User, but *not* for the Posts. The offline client would delete the User but keep the orphaned Posts. +* *Solution:* The generic Go plugin must enforce "Application-Level Cascades." It should disable DB-level ON DELETE CASCADE and instead recursively find and delete child records using the API/DAO. This ensures OnRecordAfterDelete fires for every single deleted record, generating the necessary tombstones for the client to maintain referential integrity. + +### **9.2 Third-Order Insight: Schema Evolution and Client Versioning** + +The "Generic" requirement creates a vulnerability regarding schema changes. If a developer renames a field status to state on the server, offline clients will still have mutations targeting status in their queue. + +* *Observation:* When the client pushes, the server will reject the unknown field status (or ignore it, leading to data loss). +* *Implication:* The protocol requires versioning. The server plugin should hash the current schema configuration. +* *Solution:* The sync handshake must exchange this SchemaHash. If they mismatch, the client package must trigger a MigrationStrategy. Since the package is generic, it cannot know how to migrate specific data. It should expose a callback onMigrationNeeded(localData) to the Flutter developer, allowing them to map status \-\> state before the generic sync queue processes the pending mutations. + +### **9.3 Performance at Scale** + +While Go and SQLite are performant, the OnServe hook interception adds overhead. The system's bottleneck is the SQLite write lock. + +* *Recommendation:* The generic plugin should aggressively use WAL mode (PRAGMA journal\_mode=WAL).27 +* *Optimization:* The "Pull" endpoint logic involves iterating all collections. As the number of collections grows, this becomes slow. The plugin should maintain a \_sync\_metadata table that indexes the \_hlc of all records across all collections, effectively creating a unified oplog. This trades write performance (updating the index on every save) for significantly faster read performance during sync (querying one table instead of N tables). + +## **Conclusion** + +This report defines a comprehensive, generic architecture for enabling offline-first capabilities in PocketBase. By decoupling the synchronization logic into a Go server plugin (handling HLCs, Tombstones, and Batching) and a Flutter client package (handling Local Replica, Queues, and Optimistic UI), developers can retrofit any PocketBase instance with robust offline support. The architecture prioritizes data integrity through the use of Hybrid Logical Clocks and Myers Diff, ensuring that even in the most hostile network environments, the system converges to a consistent, correct state without manual intervention in the database schema. This transforms PocketBase from a realtime-only backend into a versatile engine capable of powering mission-critical field applications. + +#### **Works cited** + +1. Offline-first support \- Flutter documentation, accessed December 22, 2025, [https://docs.flutter.dev/app-architecture/design-patterns/offline-first](https://docs.flutter.dev/app-architecture/design-patterns/offline-first) +2. Implementing Efficient Data Synchronization for Offline-First Mobile Applications, accessed December 22, 2025, [https://dev.to/dowerdev/implementing-efficient-data-synchronization-for-offline-first-mobile-applications-525c](https://dev.to/dowerdev/implementing-efficient-data-synchronization-for-offline-first-mobile-applications-525c) +3. pocketbase\_drift \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/pocketbase\_drift/latest/](https://pub.dev/documentation/pocketbase_drift/latest/) +4. pocketbase library \- Dart API \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/pocketbase/latest/pocketbase](https://pub.dev/documentation/pocketbase/latest/pocketbase) +5. PocketBase Dart SDK \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk) +6. Extend with Go \- Routing \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-routing/](https://pocketbase.io/docs/go-routing/) +7. Extend with Go \- Record operations \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-records/](https://pocketbase.io/docs/go-records/) +8. Hybrid logical clock \- Andy Matuschak's notes, accessed December 22, 2025, [https://notes.andymatuschak.org/Hybrid\_logical\_clock](https://notes.andymatuschak.org/Hybrid_logical_clock) +9. Hybrid Logical Clock implementation in TypeScript \- typeonce.dev, accessed December 22, 2025, [https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript](https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript) +10. @dldc/hybrid-logical-clock \- JSR, accessed December 22, 2025, [https://jsr.io/@dldc/hybrid-logical-clock](https://jsr.io/@dldc/hybrid-logical-clock) +11. Will there be an option for a custom ID? \#2518 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/2518](https://github.com/pocketbase/pocketbase/discussions/2518) +12. Feature request: Customizable id field ยท Issue \#2727 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/issues/2727](https://github.com/pocketbase/pocketbase/issues/2727) +13. Introduction \- How to use PocketBase \- Docs, accessed December 22, 2025, [https://pocketbase.io/docs/how-to-use/](https://pocketbase.io/docs/how-to-use/) +14. Custom own Record ID from client side \#3173 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/3173](https://github.com/pocketbase/pocketbase/discussions/3173) +15. Id generation in migrations ยท pocketbase pocketbase ยท Discussion \#3912 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/3912](https://github.com/pocketbase/pocketbase/discussions/3912) +16. Creating records with relationships in a single PocketBase operation? \- Reddit, accessed December 22, 2025, [https://www.reddit.com/r/pocketbase/comments/1gywun9/creating\_records\_with\_relationships\_in\_a\_single/](https://www.reddit.com/r/pocketbase/comments/1gywun9/creating_records_with_relationships_in_a_single/) +17. Atomically insert records inside transactions (extend with go)? \#7322 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/7322](https://github.com/pocketbase/pocketbase/discussions/7322) +18. Creating a related row inside a transaction in a JS hook \#6292 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/6292](https://github.com/pocketbase/pocketbase/discussions/6292) +19. Extend with Go \- Event hooks \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-event-hooks/](https://pocketbase.io/docs/go-event-hooks/) +20. An alternative approach to soft deletion using hooks \#2694 \- GitHub, accessed December 22, 2025, [https://github.com/pocketbase/pocketbase/discussions/2694](https://github.com/pocketbase/pocketbase/discussions/2694) +21. Removing Documents \- Ditto, accessed December 22, 2025, [https://docs.ditto.live/sdk/v5/crud/delete](https://docs.ditto.live/sdk/v5/crud/delete) +22. Kafka not deleting key with tombstone \- Stack Overflow, accessed December 22, 2025, [https://stackoverflow.com/questions/46632713/kafka-not-deleting-key-with-tombstone](https://stackoverflow.com/questions/46632713/kafka-not-deleting-key-with-tombstone) +23. Diff Match Patch \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/diff\_match\_patch/latest/](https://pub.dev/documentation/diff_match_patch/latest/) +24. sergi/go-diff: Diff, match and patch text in Go \- GitHub, accessed December 22, 2025, [https://github.com/sergi/go-diff](https://github.com/sergi/go-diff) +25. Myers diff โ€” MoonBit v0.6.33 documentation, accessed December 22, 2025, [https://docs.moonbitlang.com/en/latest/example/myers-diff/myers-diff.html](https://docs.moonbitlang.com/en/latest/example/myers-diff/myers-diff.html) +26. Myers diff in linear space: theory \- The If Works \- James Coglan, accessed December 22, 2025, [https://blog.jcoglan.com/2017/03/22/myers-diff-in-linear-space-theory/](https://blog.jcoglan.com/2017/03/22/myers-diff-in-linear-space-theory/) +27. Extend with Go \- Overview \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-overview/](https://pocketbase.io/docs/go-overview/) +28. Offline-First Mobile App Architecture: Syncing, Caching, and Conflict Resolution, accessed December 22, 2025, [https://dev.to/odunayo\_dada/offline-first-mobile-app-architecture-syncing-caching-and-conflict-resolution-518n](https://dev.to/odunayo_dada/offline-first-mobile-app-architecture-syncing-caching-and-conflict-resolution-518n) \ No newline at end of file diff --git a/packages/pocketbase_sync/research/simulation.md b/packages/pocketbase_sync/research/simulation.md new file mode 100644 index 0000000..f15d56f --- /dev/null +++ b/packages/pocketbase_sync/research/simulation.md @@ -0,0 +1,383 @@ +# **The Simulation of Reality: Architecting Robust Test Harnesses in Dart** + +## **1\. Introduction: The Epistemology of Software Simulation** + +The verification of modern software systems, particularly those operating in distributed or mobile environments, faces an existential crisis. Traditional testing methodologiesโ€”unit testing, which isolates components in a sterile vacuum, and integration testing, which typically verifies the "happy path" of component interactionโ€”are increasingly insufficient. They operate on a map of the system that assumes a level of stability and determinism that the territory of the real world simply does not possess. "Reality," in the context of a deployed application, is a hostile, high-entropy environment defined by stochastic failures: network packets are dropped, latencies jitter unpredictably, clocks drift between devices, and data structures diverge in unexpected ways due to concurrent modifications. + +To bridge the gap between the sanitized lab environment of standard CI/CD pipelines and the chaotic reality of production, engineering teams must move beyond simple verification and towards **Simulation Testing**. This paradigm shifts the goal from checking if a function returns value $X$ given input $Y$, to proving system-level propertiesโ€”such as "data is never lost," "eventual consistency is always reached," or "the application recovers gracefully from a subway tunnel signal loss"โ€”under adversarial conditions that rigorously mimic the chaotic nature of the physical world. + +This report serves as an exhaustive architectural blueprint for implementing such a "Harness for Reality," specifically tailored for the Dart and Flutter ecosystem. It synthesizes research into network fault injection, property-based testing, hybrid logical clocks, and deterministic seeding to propose a cohesive strategy for building a robust simulation environment. While the user's query requests a solution written in Dart "if possible," the research indicates that a purely Dart-based solution for all layers (especially network transport) is insufficient for high-fidelity simulation. Therefore, this report advocates for a hybrid architecture: a Dart-based control plane and application layer that orchestrates battle-tested Open Source Software (OSS) infrastructure tools like Toxiproxy and PocketBase to provide the necessary realism. + +The architecture of reality can be deconstructed into four fundamental dimensions, each requiring a specific simulation strategy: + +1. **Transport (Network):** The medium is unreliable. It subjects messages to delay, corruption, reordering, and loss. Simulation here requires transparent TCP proxies rather than simple client wrappers. +2. **Time (Causality):** Physical time is a shared illusion. In distributed systems, clocks drift, and event ordering is relative. "Happens-before" relationships must be preserved using logical clocks. +3. **Data (Entropy):** Input is rarely clean. Users and hostile actors introduce edge cases (empty strings, Unicode control characters, massive integers) that shatter fragile assumptions. Property-based testing provides the generator for this entropy. +4. **Service (Availability):** Dependencies are transient. Backends crash, restart, and return errors that require sophisticated retry policies and state recovery mechanisms. + +The following sections rigorously explore the implementation of each layer, culminating in a unified design for a Dart-based Simulation Harness. + +## --- + +**2\. The Transport Layer: Deterministic Network Chaos** + +The most immediate and visceral manifestation of "reality" for a mobile or web application is the network interface. It is the boundary where the application loses agency over its data, surrendering it to the vagaries of routing tables, cellular congestion, and physical signal decay. Implementing a robust simulation harness requires the ability to intercept, manipulate, and observe network traffic with extreme granularity. + +### **2.1 Theoretical Underpinnings of Network Faults** + +Before selecting tools, one must understand the phenomena being simulated. A naive approach, such as simply delaying a Future in Dart to simulate latency, fails to capture the complexity of the TCP/IP stack. + +**The Failure Taxonomy:** + +* **Latency and Jitter:** Network delay is rarely constant. It follows a distribution. A constant 500ms delay is easy to handle; a delay that varies normally between 100ms and 5000ms introduces race conditions where response $B$ (requested later) arrives before response $A$ (requested earlier). This tests the application's ability to discard stale data.1 +* **Packet Loss and Fragmentation:** Mobile networks (2G/EDGE) often fragment data into small bursts. A large JSON payload does not arrive instantly; it "trickles" in. If the application's read buffer or timeout logic is not tuned for this, it may time out a connection that is technically active but slow.3 +* **Connection Reset (RST):** There is a semantic difference between a timeout (silence) and a reset (active rejection). A firewall or a crashing load balancer sends a TCP RST packet. The application must distinguish this immediate failure from a timeout to trigger immediate retries rather than waiting for a deadline.1 +* **Bandwidth Throttling:** Restricting bandwidth is distinct from adding latency. It affects the *rate* of data transfer, prolonging the duration a socket stays open and consuming system resources (file descriptors, memory buffers).1 + +### **2.2 Toxiproxy: The Architecture of Interception** + +While client-side wrappers like slow\_net\_simulator 3 exist in Dart, they operate too high in the stack. They simulate the *symptoms* (waiting) but not the *mechanics* (TCP windowing, socket closure). For a "robust and realistic" harness, the fault injection must occur at the infrastructure level. + +The research overwhelmingly points to **Toxiproxy**, developed by Shopify, as the industry standard for this task.5 Unlike tc (Traffic Control) 1, which operates at the Linux kernel level and requires root privileges (making it difficult to set up in shared CI environments), Toxiproxy runs in user space as a transparent TCP proxy. This satisfies the user's requirement for a tool that is "easy to setup" while remaining rigorous. + +#### **2.2.1 The Proxy Model** + +Toxiproxy acts as a man-in-the-middle. The test harness configures the Dart application to connect to Toxiproxy's listening port (e.g., localhost:8474) instead of the actual backend. Toxiproxy then forwards traffic to the upstream service. This architecture allows the simulation to act on the raw byte stream.2 + +| Feature | Client-Side Wrapper (e.g., slow\_net\_simulator) | Infrastructure Proxy (Toxiproxy) | +| :---- | :---- | :---- | +| **Layer** | Application (Dart http client) | Transport (TCP/IP) | +| **Latency** | Artificial delay ( Future.delayed) | Network buffering & serialization delay | +| **Bandwidth** | Not truly simulated | Token bucket rate limiting | +| **Hard Failures** | Throws generic Exception | Sends TCP RST / Fin packets | +| **Realism** | Low (Logic simulation) | High (Physics simulation) | +| **Setup** | Trivial (Dart package) | Moderate (Docker container) | + +#### **2.2.2 The Toxic Arsenal** + +Toxiproxy modules, known as "toxics," inject specific faults. A Dart-based harness would dynamically inject these toxics via Toxiproxy's HTTP API.5 + +1. **Latency & Jitter:** The latency toxic adds a time delay. Crucially, the jitter attribute adds randomness. A configuration of latency=1000ms, jitter=500ms results in delays uniformly distributed between 500ms and 1500ms. This is essential for exposing "Last Write Wins" bugs in synchronization logic.8 +2. **The Slicer (Edge Network Simulation):** The slicer toxic splits data into small chunks and adds a delay between them. This accurately models the "trickle" effect of high-packet-loss networks or extremely constrained bandwidth (like GPRS). It validates that the application's HTTP client does not prematurely close the socket while data is still flowing.4 +3. **Reset Peer:** This toxic simulates the sudden severance of a connection, effectively sending an ECONNRESET to the client. This is vital for testing the application's "Retry immediately" logic versus "Backoff and retry" logic.4 +4. **Limit Data:** This toxic closes the connection after a specific number of bytes have been transferred. It is the perfect tool for testing resumable downloads or pagination limits, ensuring the app handles partial responses gracefully.8 + +### **2.3 Constructing the Dart Control Plane** + +To control this infrastructure, the Dart test harness must act as the orchestrator. Since Toxiproxy exposes a REST API, we can implement a ToxiproxyClient in Dart to create proxies and inject toxics during test setUp and tearDown. + +#### **2.3.1 Client Implementation Strategy** + +The client should mirror the API structure: managing Proxy resources and attaching Toxic resources to them.7 + +* **Proxy Management:** The client needs methods to create, delete, enable, and disable proxies. Disabling a proxy simulates a complete network outage (e.g., entering an elevator).9 +* **Toxic Injection:** The client must serialize configuration objects (e.g., LatencyToxic, BandwidthToxic) into the JSON format expected by Toxiproxy.6 + +Conceptual Dart Implementation: +The harness communicates with the Toxiproxy daemon (usually running in Docker). + +Dart + +/// A Dart controller for the Toxiproxy chaos engine. +class ToxiproxyController { + final String host; + final int port; + + ToxiproxyController({this.host \= 'localhost', this.port \= 8474}); + + String get \_apiBase \=\> 'http://$host:$port'; + + /// Maps a local port to an upstream service. + Future\ createProxy(String name, String listen, String upstream) async { + final response \= await http.post( + Uri.parse('$\_apiBase/proxies'), + body: jsonEncode({ + 'name': name, + 'listen': listen, + 'upstream': upstream, + 'enabled': true, + }), + ); + if (response.statusCode\!= 201) throw Exception('Failed to create proxy'); + } + + /// Injects a toxic into the active stream. + Future\ addToxic(String proxyName, Toxic toxic) async { + // Serialization logic for toxic attributes (jitter, rate, etc.) + await http.post( + Uri.parse('$\_apiBase/proxies/$proxyName/toxics'), + body: jsonEncode(toxic.toJson()), + ); + } + + /// Simulates a network cut. + Future\ cutConnection(String proxyName) async { + // Disabling the proxy stops all traffic immediately + await http.post( + Uri.parse('$\_apiBase/proxies/$proxyName'), + body: jsonEncode({'enabled': false}), + ); + } +} + +This controller allows the test code to read like a narrative: await toxiproxy.cutConnection('api');. + +### **2.4 Mobile Connectivity States and OS Integration** + +Simulating the network pipe is half the battle; the other half is simulating the operating system's awareness of that pipe. In Flutter, the connectivity\_plus package 10 is the standard mechanism for checking if the device is on WiFi, Cellular, or Offline. + +#### **2.4.1 The Mocking Paradox** + +A simulation paradox arises: If Toxiproxy cuts the connection (simulates a broken cable), the OS (and thus connectivity\_plus) might still report "Connected" because the WiFi link to the router is physically intactโ€”only the internet reachability is gone. Conversely, toggling "Airplane Mode" changes the OS state. + +To build a realistic harness, one must simulate *both* scenarios: + +1. **False Positive:** OS reports "Connected," but Toxiproxy blocks traffic. This tests timeout handling. +2. **True Negative:** OS reports "Offline." This tests the app's ability to pause queues and conserve battery. + +#### **2.4.2 Implementation: The Wrapper/Adapter Pattern** + +Since connectivity\_plus interacts with platform channels, it is difficult to mock directly in integration tests without a wrapper.11 The harness should employ an Adapter pattern. + +* **Production:** RealConnectivity wraps the connectivity\_plus stream. +* **Simulation:** MockConnectivity exposes a StreamController. The harness pushes ConnectivityResult.none to this controller to simulate Airplane Mode. + +Critical Insight \- Race Conditions: +A robust harness must verify the race condition between the OS reporting "Offline" and the HTTP client throwing "SocketException." In reality, either can happen first. The application logic must be resilient to this ambiguity, perhaps by prioritizing the HTTP error as the "source of truth" for reachability while using the OS state for power management.10 + +## --- + +**3\. The Temporal Layer: Causality and Distributed Time** + +"Reality" is inherently distributed. Even a simple client-server application involves two timelines: the client's and the server's. These timelines run at different speeds (clock drift) and are synchronized only by message passing. A robust simulation cannot rely on a single, global DateTime.now(). + +### **3.1 The Illusion of Simultaneity** + +In standard testing, asserting that Event A happened before Event B often relies on checking their timestamps. However, in a distributed system, physical timestamps are unreliable. + +* **NTP Drift:** Devices may be seconds or minutes apart. +* **Resolution Limits:** Two events occurring within the same millisecond on different nodes appear simultaneous. + +Using DateTime.now() in a simulation harness introduces non-determinism (flakiness). If a test runs on a fast machine, latencies are low, and timestamps align one way. On a slow CI runner, they align differently, causing assertions to fail. + +### **3.2 Hybrid Logical Clocks (HLC)** + +To rigorously test distributed behaviors (like sync, offline-first conflict resolution), the harness should utilize **Hybrid Logical Clocks** (HLC).13 HLCs combine the intuitive nature of physical time with the mathematical rigor of logical clocks (Lamport clocks). + +#### **3.2.1 Mechanism of Action** + +An HLC timestamp is composed of three parts, typically packed into a 64-bit structure: + +1. **Physical Component (PT):** The wall-clock time (e.g., milliseconds since epoch). +2. **Logical Component (L):** A counter that increments when events happen within the same physical millisecond. +3. **Causal Update Rule:** When a node receives a message with timestamp $T\_{remote}$, it updates its local clock $T\_{local}$ such that $T\_{local} \= \\max(PT\_{now}, T\_{local}, T\_{remote}) \+ 1$ (logical increment). + +This guarantees that if Event A caused Event B, the timestamp of B is strictly greater than A, regardless of the physical clock skew between the machines.15 + +#### **3.2.2 Dart Implementation (hlc\_dart)** + +The hlc\_dart package 16 implements this standard. The simulation harness should mandate that all "Simulated Nodes" (the App and the Mock Backend) use HLC.now() instead of DateTime.now(). + +**Simulation Scenario:** + +1. **Skew Injection:** The harness sets the Mock Backend's clock to T \- 5 minutes. +2. **Interaction:** The Client (correct time) sends data to the Backend. +3. **Causal Preservation:** The Backend receives the data. Despite its physical clock being behind, the HLC algorithm forces the Backend's logical clock to jump forward to match the Client's time, preserving the causal chain. +4. **Verification:** The harness asserts that the Backend stored the data with the corrected HLC, not its local (wrong) physical time. This ensures the sync protocol is resilient to client-side or server-side clock errors.15 + +### **3.3 Virtualizing Time in the Dart Event Loop** + +To make tests deterministic and fast, the harness must detach "simulation time" from "wall-clock time." Waiting for a 10-second timeout in a real-time test takes 10 seconds. In a virtualized simulation, it takes microseconds. + +The Zone Specification: +Dart's Zone mechanism allows intercepting microtask scheduling. While complex, a robust harness can use fake\_async or custom Zone specifications to override Timer and Future.delayed. + +* **Virtual Clock:** An integer counter representing ticks. +* **Scheduler:** A priority queue of pending tasks ordered by their scheduled execution time. +* **Execution:** harness.tick(Duration(seconds: 10)) simply advances the counter and executes all tasks scheduled in that window immediately. + +This allows the harness to simulate "24 hours of flaky network usage" in under a second, with guaranteed deterministic ordering of microtasks.18 + +## --- + +**4\. The Data Layer: Entropy and Property-Based Testing** + +The network and clocks provide the environment, but the *data* flowing through them is the primary vector for entropy. Engineers are biased; they write tests with "clean" inputs (e.g., "test\_user", "password123"). Reality supplies inputs like empty strings, 10MB distinct JSON blobs, emojis, and SQL injection vectors. + +### **4.1 From Examples to Invariants** + +Example-Based Testing checks specific points: $f(2) \= 4$. +Property-Based Testing (PBT) checks the surface: $\\forall x \\in \\mathbb{Z}, f(x) \= 2x$. +For a reality harness, the properties (invariants) are high-level system truths: + +* **Conservation of Data:** "No matter how many times the network disconnects, the total number of items in the client DB plus the pending sync queue must equal the number of items created." +* **Convergence:** "After the network stabilizes and sync completes, Client State must exactly equal Server State." +* **Idempotency:** "Sending the same request 10 times results in the same server state as sending it once." + +### **4.2 Generative Strategies in Dart** + +To implement PBT, the harness requires a generator engine. Libraries like dart-check 20 and propcheck 21 provide the primitives. + +#### **4.2.1 Generators (Arbitraries)** + +The harness must define domain-specific generators. + +* **Primitives:** Gen.string (includes Unicode, whitespace), Gen.int (includes negative, overflow). +* **Models:** Gen.user combines primitive generators to create User objects with complex, messy states. +* **Actions:** Gen.action produces a sequence of operations: \[Login, CreatePost, GoOffline, EditPost, GoOnline\]. + +**Code Concept:** + +Dart + +// Generating a sequence of interactions +final actionSequence \= Gen.list(Gen.oneOf()); + +#### **4.2.2 Shrinking: The Debugging Superpower** + +When a random sequence of 50 actions causes a crash, debugging is impossible without **Shrinking**. The PBT library automatically reduces the failing input to the minimal set required to reproduce the bug.22 + +* *Original:* \[Login,... 40 actions..., GoOffline, CreatePost, Crash\] +* *Shrunk:* \[GoOffline, CreatePost\] \-\> Crash. + +This feature automatically isolates the root cause (e.g., "Creating a post while offline throws a null pointer") from the noise. + +### **4.3 State Convergence and Differential Analysis** + +The ultimate test of a distributed system is state convergence. After a chaos scenario, how do we verify the system is consistent? + +Myers Diff Algorithm: +The harness should employ the Myers Diff Algorithm 23 (implemented in diff\_match\_patch or diffutil\_dart 25\) to perform a deep structural comparison between the Client's local database (SQLite) and the Simulator's backend state. +**Verification Process:** + +1. **Freeze:** Stop all simulation activity. +2. **Extract:** Dump the table items from Client SQLite and Server Mock. +3. **Normalize:** Sort both lists by ID and serialize to JSON. +4. **Diff:** Compute the delta. +5. **Assert:** The diff must be empty. + +If the diff is \[Insert: Item \#5\], the harness knows exactly that Item \#5 failed to sync. This is far more diagnostic than a generic assert(count \== 5\) failure. + +## --- + +**5\. The Service Layer: Backend Fidelity and Fault Injection** + +Simulating the client is insufficient; the client reacts to the server. A robust harness requires a malleable backend that can simulate logic errors (HTTP 500, business rule violations) and stateful interactions. + +### **5.1 The Case for Malleable Backends (PocketBase)** + +Using a full deployed backend (e.g., AWS, Firebase) for simulation testing is slow and expensive. Mocking the HTTP client with static JSON is too rigid. The optimal middle ground is **PocketBase**.26 + +* **Portability:** It is a single Go binary with an embedded SQLite database. The harness can spawn a fresh process for each test suite, ensuring total isolation.28 +* **Speed:** It starts in milliseconds, making it suitable for integration test loops. +* **Programmability:** It supports hooks, allowing the harness to inject logic faults. + +### **5.2 Server-Side Logic Injection** + +PocketBase allows extending behavior via JavaScript or Go hooks placed in a pb\_hooks directory.29 The Dart harness can dynamically write these files to configure the backend's behavior for a specific test. + +**Fault Scenarios via Hooks:** + +1. **The "Business Logic" Failure:** + * *Scenario:* Simulate a server-side validation error that only happens sometimes. + * *Implementation:* A hook on onRecordCreate that throws a 400 error if the record content contains the word "fail". This tests the client's error parsing and UI feedback. +2. **The "Ghost" Write:** + * *Scenario:* The server accepts the request (200 OK) but fails to commit the transaction. + * *Implementation:* A hook that returns null or interrupts the transaction chain after sending the response. This tests the client's read-after-write verification logic. +3. **Throttling:** + * *Implementation:* A hook that sleeps for 5 seconds before responding. Combined with Toxiproxy, this tests the intersection of *server processing time* and *network latency*.31 + +### **5.3 Database Integrity and Connection Management** + +On the client side (Flutter), the "Harness for Reality" must also verify the robustness of the local persistence layer. SQLite corruption is a real risk in mobile apps that are killed aggressively by the OS. + +Connection Lifecycle Testing: +The harness should verify that the application correctly handles the SQLite lifecycle. + +* **Closing Connections:** While some argue for keeping connections open 32, robust apps must close them during backgrounding to prevent locking. The harness can simulate a "Force Stop" by closing the database handle abruptly and then attempting to reopen it, running an integrity check (PRAGMA integrity\_check) to ensure no corruption occurred.33 +* **Busy Handlers:** The harness can spawn a secondary isolate that holds a write lock on the database file, forcing the main app to encounter SQLITE\_BUSY. This verifies that the app implements correct retry/backoff logic at the database driver level.35 + +## --- + +**6\. Synthesis: Architecting the Harness** + +Combining these four layers results in a unified, high-fidelity Simulation Harness. + +### **6.1 The "Hypervisor" Pattern** + +The Harness acts as a hypervisor. It orchestrates the environment *around* the application. + +**Architecture Diagram:** + +1. **Orchestrator (Dart Test Runner):** + * Initializes the **Deterministic Seed** (e.g., 12345).18 + * Spawns **PocketBase** (Service Layer) on a random port (e.g., 9090). + * Spawns **Toxiproxy** (Transport Layer) via Docker, mapping localhost:8080 \-\> localhost:9090. + * Injects **Toxics** (Latency, Jitter) into the proxy via the Dart ToxiproxyController. +2. **The Application (Flutter/Dart Code):** + * Configured with a **Virtual Clock** (Time Layer) implementing HLC. + * Configured with a **Mock Connectivity** adapter. + * Points its API client to localhost:8080 (the Proxy). +3. **The Fuzz Engine (PBT):** + * Generates a sequence of 100 Actions: \`\`. + * Feeds these actions to the Application via the Flutter Driver or integration test binding. +4. **The Verifier:** + * After the sequence, it pulls the Application State (Local SQLite). + * Pulls the PocketBase State (Remote DB). + * Asserts convergence using **Myers Diff**. + +### **6.2 Implementation Roadmap** + +To implement this "easy to setup" yet "robust" harness, follow this roadmap: + +1. **Infrastructure:** Create a docker-compose.yml defining Toxiproxy. +2. **Dart Control Lib:** Write the ToxiproxyController and PocketBaseController classes in Dart to manage the external processes. +3. **Time Lib:** Import hlc\_dart and refactor the app to use a Clock service. +4. **Generator:** Use dart-check to define the input generators. +5. **Test Runner:** Write a parameterized test that accepts a seed, spins up the infrastructure, runs the fuzz loop, and asserts state convergence. + +## --- + +**7\. Conclusion** + +Building a harness that mirrors reality is not a task of simple mocking; it is an exercise in engineering a synthetic universe. By layering **Toxiproxy** for transport fidelity, **Hybrid Logical Clocks** for temporal consistency, **Property-Based Testing** for data coverage, and **PocketBase** for service emulation, Dart engineers can construct a testing rig that exposes bugs long before they manifest in the hands of users. + +This architecture satisfies the requirement for "robustness" by simulating the physics of failure (TCP resets, clock drift) rather than just the symptoms. It satisfies the requirement for "realism" by using actual network proxies and databases rather than in-memory mocks. Finally, it satisfies the "easy to setup" constraint by leveraging containerized, single-binary tools that can be orchestrated entirely from within the Dart test runner. The result is a testing environment where chaos is not an accident, but a controlled, observable, and debuggable variable. + +#### **Works cited** + +1. How to Simulate Network Failures in Linux | by Alexander Zakharenko | Medium, accessed December 22, 2025, [https://medium.com/@zakharenko/how-to-simulate-network-failures-in-linux-b71ab585e86f](https://medium.com/@zakharenko/how-to-simulate-network-failures-in-linux-b71ab585e86f) +2. Chaos in the network โ€” using ToxiProxy for network chaos engineering | by Safeer CM | The Cloud Bulletin | Medium, accessed December 22, 2025, [https://medium.com/cloudbulletin/chaos-in-the-network-using-toxiproxy-for-network-chaos-engineering-13fb0ae2deea](https://medium.com/cloudbulletin/chaos-in-the-network-using-toxiproxy-for-network-chaos-engineering-13fb0ae2deea) +3. slow\_net\_simulator \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/slow\_net\_simulator/latest/](https://pub.dev/documentation/slow_net_simulator/latest/) +4. Resilience Testing with Toxiproxy | by Matthew Lucas | Medium, accessed December 22, 2025, [https://notmattlucas.com/resilience-testing-with-toxiproxy-f24ce7b81dba](https://notmattlucas.com/resilience-testing-with-toxiproxy-f24ce7b81dba) +5. Shopify/toxiproxy: :alarm\_clock: A TCP proxy to simulate network and system conditions for chaos and resiliency testing \- GitHub, accessed December 22, 2025, [https://github.com/Shopify/toxiproxy](https://github.com/Shopify/toxiproxy) +6. ToxiproxyEx โ€” toxiproxy\_ex v2.0.1 \- Hexdocs, accessed December 22, 2025, [https://hexdocs.pm/toxiproxy\_ex/](https://hexdocs.pm/toxiproxy_ex/) +7. toxiproxy package \- github.com/shopify/toxiproxy/client \- Go Packages, accessed December 22, 2025, [https://pkg.go.dev/github.com/shopify/toxiproxy/client](https://pkg.go.dev/github.com/shopify/toxiproxy/client) +8. Toxiproxy Module \- Testcontainers for Java, accessed December 22, 2025, [https://java.testcontainers.org/modules/toxiproxy/](https://java.testcontainers.org/modules/toxiproxy/) +9. ToxiProxy \- Chaos Toolkit \- The chaos engineering toolkit for developers, accessed December 22, 2025, [https://chaostoolkit.org/drivers/toxiproxy/](https://chaostoolkit.org/drivers/toxiproxy/) +10. connectivity\_plus | Flutter package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/connectivity\_plus](https://pub.dev/packages/connectivity_plus) +11. \[Question\]: how can I mock a connectivity change in unit tests? ยท Issue \#3029 ยท fluttercommunity/plus\_plugins \- GitHub, accessed December 22, 2025, [https://github.com/fluttercommunity/plus\_plugins/issues/3029](https://github.com/fluttercommunity/plus_plugins/issues/3029) +12. How to Build an Always Listening Network Connectivity Checker in Flutter using BLoC, accessed December 22, 2025, [https://www.freecodecamp.org/news/how-to-build-an-always-listening-network-connectivity-checker-in-flutter-using-bloc/](https://www.freecodecamp.org/news/how-to-build-an-always-listening-network-connectivity-checker-in-flutter-using-bloc/) +13. Hybrid logical clock \- Andy Matuschak's notes, accessed December 22, 2025, [https://notes.andymatuschak.org/Hybrid\_logical\_clock](https://notes.andymatuschak.org/Hybrid_logical_clock) +14. hlc \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/hlc/latest/](https://pub.dev/documentation/hlc/latest/) +15. Hybrid Logical Clock implementation in TypeScript \- typeonce.dev, accessed December 22, 2025, [https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript](https://www.typeonce.dev/snippet/hybrid-logical-clock-implementation-typescript) +16. hlc\_dart | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/hlc\_dart](https://pub.dev/packages/hlc_dart) +17. hlc\_dart package \- All Versions \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/hlc\_dart/versions](https://pub.dev/packages/hlc_dart/versions) +18. test | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/test](https://pub.dev/packages/test) +19. Property-based testing \- Antithesis, accessed December 22, 2025, [https://antithesis.com/resources/property\_based\_testing/](https://antithesis.com/resources/property_based_testing/) +20. wigahluk/dart-check: A monadic QuickCheck inspired library for Dart \- GitHub, accessed December 22, 2025, [https://github.com/wigahluk/dart-check](https://github.com/wigahluk/dart-check) +21. polux/propcheck: Exhaustive and randomized testing of Dart properties \- GitHub, accessed December 22, 2025, [https://github.com/polux/propcheck](https://github.com/polux/propcheck) +22. The sad state of property-based testing libraries : r/programming \- Reddit, accessed December 22, 2025, [https://www.reddit.com/r/programming/comments/1duamq2/the\_sad\_state\_of\_propertybased\_testing\_libraries/](https://www.reddit.com/r/programming/comments/1duamq2/the_sad_state_of_propertybased_testing_libraries/) +23. Diff Match Patch \- Dart API docs \- Pub.dev, accessed December 22, 2025, [https://pub.dev/documentation/diff\_match\_patch/latest/](https://pub.dev/documentation/diff_match_patch/latest/) +24. myers-diff \- NPM, accessed December 22, 2025, [https://www.npmjs.com/package/myers-diff](https://www.npmjs.com/package/myers-diff) +25. diffutil\_dart | Dart package \- Pub.dev, accessed December 22, 2025, [https://pub.dev/packages/diffutil\_dart](https://pub.dev/packages/diffutil_dart) +26. Extend with Go \- Overview \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-overview/](https://pocketbase.io/docs/go-overview/) +27. pocketbase package \- github.com/pocketbase/pocketbase \- Go Packages, accessed December 22, 2025, [https://pkg.go.dev/github.com/pocketbase/pocketbase](https://pkg.go.dev/github.com/pocketbase/pocketbase) +28. Extend with Go \- Testing \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-testing/](https://pocketbase.io/docs/go-testing/) +29. Extend with JavaScript \- Event hooks \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/js-event-hooks/](https://pocketbase.io/docs/js-event-hooks/) +30. \[Web\]PocketBase Hooks Collection | B4X Programming Forum, accessed December 22, 2025, [https://www.b4x.com/android/forum/threads/web-pocketbase-hooks-collection.159299/](https://www.b4x.com/android/forum/threads/web-pocketbase-hooks-collection.159299/) +31. Extend with Go \- Event hooks \- Docs \- PocketBase, accessed December 22, 2025, [https://pocketbase.io/docs/go-event-hooks/](https://pocketbase.io/docs/go-event-hooks/) +32. In a web app when should you close the connection? : r/sqlite \- Reddit, accessed December 22, 2025, [https://www.reddit.com/r/sqlite/comments/1gojiol/in\_a\_web\_app\_when\_should\_you\_close\_the\_connection/](https://www.reddit.com/r/sqlite/comments/1gojiol/in_a_web_app_when_should_you_close_the_connection/) +33. How to ensure sqlite db connections get closed during debugging? \- Stack Overflow, accessed December 22, 2025, [https://stackoverflow.com/questions/15551323/how-to-ensure-sqlite-db-connections-get-closed-during-debugging](https://stackoverflow.com/questions/15551323/how-to-ensure-sqlite-db-connections-get-closed-during-debugging) +34. SQLite, keep connection open or close every time?, accessed December 22, 2025, [https://use-livecode.runrev.narkive.com/eSQa4A2Q/sqlite-keep-connection-open-or-close-every-time](https://use-livecode.runrev.narkive.com/eSQa4A2Q/sqlite-keep-connection-open-or-close-every-time) +35. Closing A Database Connection \- SQLite, accessed December 22, 2025, [https://sqlite.org/c3ref/close.html](https://sqlite.org/c3ref/close.html) \ No newline at end of file diff --git a/packages/pocketbase_sync/test/drift_sync_repository_test.dart b/packages/pocketbase_sync/test/drift_sync_repository_test.dart new file mode 100644 index 0000000..712663e --- /dev/null +++ b/packages/pocketbase_sync/test/drift_sync_repository_test.dart @@ -0,0 +1,120 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; +import 'package:pocketbase_sync/sync_managers/drift.dart'; + +// part 'drift_sync_repository_test.g.dart'; + +// Test Model +class Note { + final String id; + final String content; + Note({required this.id, required this.content}); + + Map toJson() => {'id': id, 'content': content}; + static Note fromJson(Map json) => + Note(id: json['id'], content: json['content']); + + @override + bool operator ==(Object other) => + other is Note && other.id == id && other.content == content; + + @override + int get hashCode => Object.hash(id, content); +} + +void main() { + late PackageDatabase database; + late DriftSyncRepository repository; + + setUp(() { + // In-memory database for testing + database = PackageDatabase(NativeDatabase.memory()); + repository = DriftSyncRepository( + dbWrapper: database, + collectionName: 'notes', + toJson: (n) => n.toJson(), + fromJson: (j) => Note.fromJson(j), + ); + }); + + tearDown(() async { + await database.close(); + }); + + test('Save and Get', () async { + final note = Note(id: '1', content: 'Test'); + final record = SyncRecord(id: '1', data: note, isDirty: true); + + await repository.save(record); + final fetched = await repository.get('1'); + + expect(fetched, isNotNull); + expect(fetched!.data, equals(note)); + expect(fetched.isDirty, isTrue); + }); + + test('Update updates existing record', () async { + final note1 = Note(id: '1', content: 'Test 1'); + await repository.save(SyncRecord(id: '1', data: note1)); + + final note2 = Note(id: '1', content: 'Test 2'); + await repository.save(SyncRecord(id: '1', data: note2, isDirty: true)); + + final fetched = await repository.get('1'); + expect(fetched!.data.content, equals('Test 2')); + expect(fetched.isDirty, isTrue); + }); + + test('GetAll returns filtered by collection', () async { + // Add note to correct collection + await repository.save( + SyncRecord( + id: '1', + data: Note(id: '1', content: 'A'), + ), + ); + + // Add note to OTHER collection (using a different repo instance) + final otherRepo = DriftSyncRepository( + dbWrapper: database, + collectionName: 'other_notes', + toJson: (n) => n.toJson(), + fromJson: (j) => Note.fromJson(j), + ); + await otherRepo.save( + SyncRecord( + id: '2', + data: Note(id: '2', content: 'B'), + ), + ); + + final all = await repository.getAll(); + expect(all.length, equals(1)); + expect(all.first.id, equals('1')); + }); + + test('LastSyncTime persistence', () async { + final time = DateTime.now(); + await repository.setLastSyncTime(time); + + final fetched = await repository.getLastSyncTime(); + // Precision might be lost in DB, check difference + expect( + fetched.difference(time).inMilliseconds.abs(), + lessThan(1000), + ); // allow small diff + }); + + test('Delete removes record', () async { + await repository.save( + SyncRecord( + id: '1', + data: Note(id: '1', content: 'A'), + ), + ); + await repository.delete('1'); + + expect(await repository.get('1'), isNull); + }); +} diff --git a/packages/pocketbase_sync/test/hybrid_differ_test.dart b/packages/pocketbase_sync/test/hybrid_differ_test.dart new file mode 100644 index 0000000..505dbc0 --- /dev/null +++ b/packages/pocketbase_sync/test/hybrid_differ_test.dart @@ -0,0 +1,225 @@ +import 'package:diff_algorithims/hybrid_diff.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HybridDiffer Structural Tests (Myers)', () { + test('Identical lists return no changes', () { + final list = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + final changes = HybridDiffer.diff(list, list, idField: 'id'); + + // We expect equal nodes or no nodes depending on filtering. + // The implementation returns Equal nodes for matches. + expect(changes.every((c) => c.op == ChangeOp.equal), isTrue); + expect(changes.length, equals(2)); + }); + + test('Detects simple insertion', () { + final oldList = [ + {'id': 1, 'val': 'a'}, + ]; + final newList = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(2)); + expect(changes[0].op, equals(ChangeOp.equal)); + expect(changes[1].op, equals(ChangeOp.insert)); + expect(changes[1].key, equals('2')); + }); + + test('Detects simple deletion', () { + final oldList = [ + {'id': 1, 'val': 'a'}, + {'id': 2, 'val': 'b'}, + ]; + final newList = [ + {'id': 1, 'val': 'a'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(2)); + expect(changes[0].op, equals(ChangeOp.equal)); + expect(changes[1].op, equals(ChangeOp.delete)); + expect(changes[1].key, equals('2')); + }); + + test('Detects moves (Delete + Insert)', () { + // Myers detects moves as a delete of the old position and insert at new + final oldList = [ + {'id': 1}, + {'id': 2}, + {'id': 3}, + ]; + final newList = [ + {'id': 1}, + {'id': 3}, + {'id': 2}, + ]; // 2 moved to end + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + // Expected: Equal(1), Equal(3), Insert(2), Delete(2) OR Equal(1), Delete(2), Equal(3), Insert(2) + // Myers usually prefers deletes first if weights are equal, but let's check operations. + final ops = changes.map((c) => c.op).toList(); + + expect(ops, contains(ChangeOp.insert)); + expect(ops, contains(ChangeOp.delete)); + + final insertNode = changes.firstWhere((c) => c.op == ChangeOp.insert); + final deleteNode = changes.firstWhere((c) => c.op == ChangeOp.delete); + + expect(insertNode.key, equals(deleteNode.key)); + expect(['2', '3'], contains(insertNode.key)); + }); + }); + + group('HybridDiffer Content Tests (Deep Diff)', () { + test('Detects modification in primitive fields', () { + final oldList = [ + {'id': 1, 'name': 'Alice'}, + ]; + final newList = [ + {'id': 1, 'name': 'Bob'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + + expect(changes.length, equals(1)); + final mod = changes.first; + + expect(mod.op, equals(ChangeOp.modify)); + expect(mod.children, isNotNull); + expect(mod.children!.length, equals(1)); + + final fieldChange = mod.children!.first; + expect(fieldChange.key, equals('name')); + expect(fieldChange.oldValue, equals('Alice')); + expect(fieldChange.newValue, equals('Bob')); + }); + + test('Detects nested map changes recursively', () { + final oldList = [ + { + 'id': 1, + 'meta': {'ver': 1, 'author': 'me'}, + }, + ]; + final newList = [ + { + 'id': 1, + 'meta': {'ver': 2, 'author': 'me'}, + }, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final rootMod = changes.first; + + // Look inside the 'meta' field + final metaMod = rootMod.children!.firstWhere((c) => c.key == 'meta'); + expect(metaMod.op, equals(ChangeOp.modify)); + + // Look inside 'ver' field + final verMod = metaMod.children!.firstWhere((c) => c.key == 'ver'); + expect(verMod.oldValue, equals(1)); + expect(verMod.newValue, equals(2)); + }); + + test('Handles null values correctly', () { + final oldList = [ + {'id': 1, 'val': null}, + ]; + final newList = [ + {'id': 1, 'val': 'not null'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.key, equals('val')); + expect(fieldChange.oldValue, isNull); + expect(fieldChange.newValue, equals('not null')); + }); + }); + + group('String Splice Optimization', () { + test('Generates TextSplice for string changes', () { + final oldList = [ + {'id': 1, 'text': 'Hello World'}, + ]; + final newList = [ + {'id': 1, 'text': 'Hello Flutter World'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.key, equals('text')); + expect(fieldChange.splice, isNotNull); + + // "Hello " (len 6) match. Insert "Flutter ". + expect(fieldChange.splice!.index, equals(6)); + expect(fieldChange.splice!.deleteCount, equals(0)); + expect(fieldChange.splice!.insertText, equals('Flutter ')); + }); + + test('Handles Unicode Surrogate Pairs (Emoji Safety)', () { + // ๐Ÿคš is \uD83E\uDD1A (2 code units) + final oldList = [ + {'id': 1, 'text': 'Hi ๐Ÿคš'}, + ]; + final newList = [ + {'id': 1, 'text': 'Hi ๐Ÿคš there'}, + ]; + + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + final fieldChange = changes.first.children!.first; + + expect(fieldChange.splice, isNotNull); + // Ensure we didn't split the emoji. The index should differ by proper length. + // 'Hi ' is 3. Emoji is 2. Total 5. + expect(fieldChange.splice!.index, greaterThanOrEqualTo(5)); + expect(fieldChange.splice!.insertText, contains('there')); + }); + }); + + group('Performance / Stress Test', () { + test('Handles large lists efficiently', () { + // Generate 5000 items + final oldList = List>.generate( + 5000, + (i) => {'id': i, 'val': 'item $i'}, + ); + final newList = List>.from(oldList); + + // Make 3 changes + newList.removeAt(100); // Delete + newList.insert(4000, {'id': 9999, 'val': 'new'}); // Insert + // Index 2500 shifted to 2499 + newList[2499] = {'id': 2500, 'val': 'CHANGED'}; // Modify + + final stopwatch = Stopwatch()..start(); + final changes = HybridDiffer.diff(oldList, newList, idField: 'id'); + stopwatch.stop(); + + print('Diff 5000 items took: ${stopwatch.elapsedMilliseconds}ms'); + + final deletes = changes.where((c) => c.op == ChangeOp.delete); + final inserts = changes.where((c) => c.op == ChangeOp.insert); + final modifies = changes.where((c) => c.op == ChangeOp.modify); + + expect(deletes.length, equals(1)); + expect(inserts.length, equals(1)); + expect(modifies.length, equals(1)); + + // Ensure it's reasonably fast + expect(stopwatch.elapsedMilliseconds, lessThan(1000)); + }); + }); +} diff --git a/packages/pocketbase_sync/test/multi_sync_test.dart b/packages/pocketbase_sync/test/multi_sync_test.dart new file mode 100644 index 0000000..9932366 --- /dev/null +++ b/packages/pocketbase_sync/test/multi_sync_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; + +// Generate Mocks +@GenerateNiceMocks([MockSpec()]) +import 'multi_sync_test.mocks.dart'; + +// Test Models +class ModelA {} + +class ModelB {} + +void main() { + late PocketBaseMultiSyncManager multiManager; + late MockPocketBaseSyncManager mockManagerA; + late MockPocketBaseSyncManager mockManagerB; + + setUp(() { + multiManager = PocketBaseMultiSyncManager(); + mockManagerA = MockPocketBaseSyncManager(); + mockManagerB = MockPocketBaseSyncManager(); + }); + + test('Register and Retrieve Manager', () { + multiManager.register(mockManagerA); + + expect(multiManager.managerFor(), equals(mockManagerA)); + expect(() => multiManager.managerFor(), throwsStateError); + }); + + test('SyncAll calls sync on all managers', () async { + multiManager.register(mockManagerA); + multiManager.register(mockManagerB); + + await multiManager.sync(); + + verify(mockManagerA.sync()).called(1); + verify(mockManagerB.sync()).called(1); + }); + + test('SyncAll continues even if one manager fails', () async { + multiManager.register(mockManagerA); + multiManager.register(mockManagerB); + + when(mockManagerA.sync()).thenThrow(Exception('Sync Failed')); + + await multiManager.sync(); + + verify(mockManagerA.sync()).called(1); + verify(mockManagerB.sync()).called(1); // Should still execute + }); +} diff --git a/packages/pocketbase_sync/test/multi_sync_test.mocks.dart b/packages/pocketbase_sync/test/multi_sync_test.mocks.dart new file mode 100644 index 0000000..386b004 --- /dev/null +++ b/packages/pocketbase_sync/test/multi_sync_test.mocks.dart @@ -0,0 +1,314 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in pocketbase_sync/test/multi_sync_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i7; + +import 'package:flutter/foundation.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i6; +import 'package:pocketbase/pocketbase.dart' as _i2; +import 'package:pocketbase_sync/repo/sync_repository.dart' as _i3; +import 'package:pocketbase_sync/sync/pb_sync_manager.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakePocketBase_0 extends _i1.SmartFake implements _i2.PocketBase { + _FakePocketBase_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSyncRepository_1 extends _i1.SmartFake + implements _i3.SyncRepository { + _FakeSyncRepository_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDuration_2 extends _i1.SmartFake implements Duration { + _FakeDuration_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeValueNotifier_3 extends _i1.SmartFake + implements _i4.ValueNotifier { + _FakeValueNotifier_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [PocketBaseSyncManager]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPocketBaseSyncManager extends _i1.Mock + implements _i5.PocketBaseSyncManager { + @override + _i2.PocketBase get pb => (super.noSuchMethod( + Invocation.getter(#pb), + returnValue: _FakePocketBase_0( + this, + Invocation.getter(#pb), + ), + returnValueForMissingStub: _FakePocketBase_0( + this, + Invocation.getter(#pb), + ), + ) as _i2.PocketBase); + + @override + String get collection => (super.noSuchMethod( + Invocation.getter(#collection), + returnValue: _i6.dummyValue( + this, + Invocation.getter(#collection), + ), + returnValueForMissingStub: _i6.dummyValue( + this, + Invocation.getter(#collection), + ), + ) as String); + + @override + _i3.SyncRepository get repository => (super.noSuchMethod( + Invocation.getter(#repository), + returnValue: _FakeSyncRepository_1( + this, + Invocation.getter(#repository), + ), + returnValueForMissingStub: _FakeSyncRepository_1( + this, + Invocation.getter(#repository), + ), + ) as _i3.SyncRepository); + + @override + Map Function(T) get toJson => (super.noSuchMethod( + Invocation.getter(#toJson), + returnValue: (T __p0) => {}, + returnValueForMissingStub: (T __p0) => {}, + ) as Map Function(T)); + + @override + T Function(Map) get fromJson => (super.noSuchMethod( + Invocation.getter(#fromJson), + returnValue: (Map __p0) => _i6.dummyValue( + this, + Invocation.getter(#fromJson), + ), + returnValueForMissingStub: (Map __p0) => + _i6.dummyValue( + this, + Invocation.getter(#fromJson), + ), + ) as T Function(Map)); + + @override + Duration get retentionPeriod => (super.noSuchMethod( + Invocation.getter(#retentionPeriod), + returnValue: _FakeDuration_2( + this, + Invocation.getter(#retentionPeriod), + ), + returnValueForMissingStub: _FakeDuration_2( + this, + Invocation.getter(#retentionPeriod), + ), + ) as Duration); + + @override + String Function() get idGenerator => (super.noSuchMethod( + Invocation.getter(#idGenerator), + returnValue: () => _i6.dummyValue( + this, + Invocation.getter(#idGenerator), + ), + returnValueForMissingStub: () => _i6.dummyValue( + this, + Invocation.getter(#idGenerator), + ), + ) as String Function()); + + @override + _i4.ValueNotifier get isConnectedNotifier => (super.noSuchMethod( + Invocation.getter(#isConnectedNotifier), + returnValue: _FakeValueNotifier_3( + this, + Invocation.getter(#isConnectedNotifier), + ), + returnValueForMissingStub: _FakeValueNotifier_3( + this, + Invocation.getter(#isConnectedNotifier), + ), + ) as _i4.ValueNotifier); + + @override + _i7.Stream get onUpdate => (super.noSuchMethod( + Invocation.getter(#onUpdate), + returnValue: _i7.Stream.empty(), + returnValueForMissingStub: _i7.Stream.empty(), + ) as _i7.Stream); + + @override + set autoSyncInterval(Duration? value) => super.noSuchMethod( + Invocation.setter( + #autoSyncInterval, + value, + ), + returnValueForMissingStub: null, + ); + + @override + String generateId() => (super.noSuchMethod( + Invocation.method( + #generateId, + [], + ), + returnValue: _i6.dummyValue( + this, + Invocation.method( + #generateId, + [], + ), + ), + returnValueForMissingStub: _i6.dummyValue( + this, + Invocation.method( + #generateId, + [], + ), + ), + ) as String); + + @override + void startAutoSync(Duration? interval) => super.noSuchMethod( + Invocation.method( + #startAutoSync, + [interval], + ), + returnValueForMissingStub: null, + ); + + @override + void stopAutoSync() => super.noSuchMethod( + Invocation.method( + #stopAutoSync, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i7.Future create( + String? id, + T? item, + ) => + (super.noSuchMethod( + Invocation.method( + #create, + [ + id, + item, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future update( + String? id, + T? item, + ) => + (super.noSuchMethod( + Invocation.method( + #update, + [ + id, + item, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future delete(String? id) => (super.noSuchMethod( + Invocation.method( + #delete, + [id], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future sync() => (super.noSuchMethod( + Invocation.method( + #sync, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future subscribe() => (super.noSuchMethod( + Invocation.method( + #subscribe, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future unsubscribe() => (super.noSuchMethod( + Invocation.method( + #unsubscribe, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/packages/pocketbase_sync/test/pb_sync_manager_test.dart b/packages/pocketbase_sync/test/pb_sync_manager_test.dart new file mode 100644 index 0000000..937f23b --- /dev/null +++ b/packages/pocketbase_sync/test/pb_sync_manager_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; +import 'package:pocketbase_sync/sync_managers/in_memory.dart'; + +// Generate Mocks +@GenerateNiceMocks([MockSpec(), MockSpec()]) +import 'pb_sync_manager_test.mocks.dart'; + +// Test Model +class Note { + final String id; + final String content; + final String category; + Note({required this.id, required this.content, required this.category}); + + Map toJson() => { + 'id': id, + 'content': content, + 'category': category, + }; + static Note fromJson(Map json) => Note( + id: json['id'], + content: json['content'], + category: json['category'] ?? 'General', + ); +} + +void main() { + late MockPocketBase mockPb; + late MockRecordService mockCollection; + late InMemoryRepository repository; + late PocketBaseSyncManager manager; + + setUp(() { + mockPb = MockPocketBase(); + mockCollection = MockRecordService(); + repository = InMemoryRepository(); + + when(mockPb.collection('notes')).thenReturn(mockCollection); + + manager = PocketBaseSyncManager( + pb: mockPb, + collection: 'notes', + toJson: (n) => n.toJson(), + fromJson: (j) => Note.fromJson(j), + repository: repository, + retentionPeriod: const Duration(days: 30), + ); + }); + + group('Sync Logic', () { + test('Push Update: Sends minimal PATCH using HybridDiffer', () async { + // 1. Setup Synced State + final base = Note(id: 'n1', content: 'Base Content', category: 'Work'); + await repository.save( + SyncRecord( + id: 'n1', + data: base, + baseData: base, + serverUpdatedAt: DateTime.parse('2023-01-01 10:00:00'), + isDirty: false, + ), + ); + + // 2. Local Update (Only change category) + await manager.update( + 'n1', + Note(id: 'n1', content: 'Base Content', category: 'Home'), + ); + + // 3. Mock Response + when(mockCollection.update(any, body: anyNamed('body'))).thenAnswer( + (_) async => RecordModel.fromJson( + {'id': 'n1', 'updated': '2023-01-01 10:05:00'}), + ); + + // Mock ID List (Server has n1) + when(mockCollection.getFullList(fields: 'id')).thenAnswer( + (_) async => [ + RecordModel.fromJson({'id': 'n1'}) + ], + ); + + // 4. Sync + await manager.sync(); + + // 5. Verify Payload + final captured = verify( + mockCollection.update('n1', body: captureAnyNamed('body')), + ).captured; + final patch = captured.first as Map; + + expect(patch.containsKey('category'), isTrue); + expect( + patch.containsKey('content'), + isFalse, + reason: "Content didn't change", + ); + }); + + test('First Pull: Fetches ALL records (getFullList)', () async { + // 1. Mock Server returning list + final remoteItems = [ + RecordModel.fromJson({ + 'id': 'n1', + 'updated': '2023-01-01 10:00:00', + 'content': 'A', + }), + ]; + + when( + mockCollection.getFullList(filter: anyNamed('filter')), + ).thenAnswer((_) async => remoteItems); + + // Mock ID List (Server has n1) + when(mockCollection.getFullList(fields: 'id')).thenAnswer( + (_) async => [ + RecordModel.fromJson({'id': 'n1'}) + ], + ); + + // 2. Sync + await manager.sync(); + + // 3. Verify Repository Populated + final all = await repository.getAll(); + expect(all.length, equals(1)); + expect(all.first.data.content, equals('A')); + }); + }); + + group('Retention Policy', () { + test('Cleanup: Removes expired tombstones', () async { + // Expired Tombstone + await repository.save( + SyncRecord( + id: 'del', + data: Note(id: 'del', content: '', category: ''), + serverUpdatedAt: DateTime.now(), + isDeleted: true, + isDirty: false, + deletedAt: DateTime.now().subtract(const Duration(days: 31)), + ), + ); + + // Active Tombstone + await repository.save( + SyncRecord( + id: 'active', + data: Note(id: 'active', content: '', category: ''), + serverUpdatedAt: DateTime.now(), + isDeleted: true, + isDirty: false, + deletedAt: DateTime.now().subtract(const Duration(days: 1)), + ), + ); + + await manager.sync(); // Triggers cleanup at end + + expect( + await repository.get('del'), + isNull, + reason: "Should be hard deleted", + ); + expect( + await repository.get('active'), + isNotNull, + reason: "Should be kept", + ); + }); + }); +} diff --git a/packages/pocketbase_sync/test/pb_sync_manager_test.mocks.dart b/packages/pocketbase_sync/test/pb_sync_manager_test.mocks.dart new file mode 100644 index 0000000..59c3d9c --- /dev/null +++ b/packages/pocketbase_sync/test/pb_sync_manager_test.mocks.dart @@ -0,0 +1,1889 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in pocketbase_sync/test/pb_sync_manager_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i14; + +import 'package:http/http.dart' as _i12; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i22; +import 'package:pocketbase/src/auth_store.dart' as _i2; +import 'package:pocketbase/src/client.dart' as _i15; +import 'package:pocketbase/src/dtos/auth_methods_list.dart' as _i17; +import 'package:pocketbase/src/dtos/jsonable.dart' as _i20; +import 'package:pocketbase/src/dtos/otp_response.dart' as _i19; +import 'package:pocketbase/src/dtos/record_auth.dart' as _i18; +import 'package:pocketbase/src/dtos/record_model.dart' as _i16; +import 'package:pocketbase/src/dtos/result_list.dart' as _i21; +import 'package:pocketbase/src/services/backup_service.dart' as _i10; +import 'package:pocketbase/src/services/batch_service.dart' as _i13; +import 'package:pocketbase/src/services/collection_service.dart' as _i4; +import 'package:pocketbase/src/services/cron_service.dart' as _i11; +import 'package:pocketbase/src/services/file_service.dart' as _i5; +import 'package:pocketbase/src/services/health_service.dart' as _i9; +import 'package:pocketbase/src/services/log_service.dart' as _i8; +import 'package:pocketbase/src/services/realtime_service.dart' as _i6; +import 'package:pocketbase/src/services/record_service.dart' as _i3; +import 'package:pocketbase/src/services/settings_service.dart' as _i7; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeAuthStore_0 extends _i1.SmartFake implements _i2.AuthStore { + _FakeAuthStore_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRecordService_1 extends _i1.SmartFake implements _i3.RecordService { + _FakeRecordService_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeCollectionService_2 extends _i1.SmartFake + implements _i4.CollectionService { + _FakeCollectionService_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFileService_3 extends _i1.SmartFake implements _i5.FileService { + _FakeFileService_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRealtimeService_4 extends _i1.SmartFake + implements _i6.RealtimeService { + _FakeRealtimeService_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSettingsService_5 extends _i1.SmartFake + implements _i7.SettingsService { + _FakeSettingsService_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeLogService_6 extends _i1.SmartFake implements _i8.LogService { + _FakeLogService_6( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeHealthService_7 extends _i1.SmartFake implements _i9.HealthService { + _FakeHealthService_7( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBackupService_8 extends _i1.SmartFake implements _i10.BackupService { + _FakeBackupService_8( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeCronService_9 extends _i1.SmartFake implements _i11.CronService { + _FakeCronService_9( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeClient_10 extends _i1.SmartFake implements _i12.Client { + _FakeClient_10( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeUri_11 extends _i1.SmartFake implements Uri { + _FakeUri_11( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBatchService_12 extends _i1.SmartFake implements _i13.BatchService { + _FakeBatchService_12( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFuture_13 extends _i1.SmartFake implements _i14.Future { + _FakeFuture_13( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePocketBase_14 extends _i1.SmartFake implements _i15.PocketBase { + _FakePocketBase_14( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRecordModel_15 extends _i1.SmartFake implements _i16.RecordModel { + _FakeRecordModel_15( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAuthMethodsList_16 extends _i1.SmartFake + implements _i17.AuthMethodsList { + _FakeAuthMethodsList_16( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRecordAuth_17 extends _i1.SmartFake implements _i18.RecordAuth { + _FakeRecordAuth_17( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeOTPResponse_18 extends _i1.SmartFake implements _i19.OTPResponse { + _FakeOTPResponse_18( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeResultList_19 extends _i1.SmartFake + implements _i21.ResultList { + _FakeResultList_19( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [PocketBase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPocketBase extends _i1.Mock implements _i15.PocketBase { + @override + String get baseUrl => (super.noSuchMethod( + Invocation.getter(#baseUrl), + returnValue: _i22.dummyValue( + this, + Invocation.getter(#baseUrl), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.getter(#baseUrl), + ), + ) as String); + + @override + String get baseURL => (super.noSuchMethod( + Invocation.getter(#baseURL), + returnValue: _i22.dummyValue( + this, + Invocation.getter(#baseURL), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.getter(#baseURL), + ), + ) as String); + + @override + String get lang => (super.noSuchMethod( + Invocation.getter(#lang), + returnValue: _i22.dummyValue( + this, + Invocation.getter(#lang), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.getter(#lang), + ), + ) as String); + + @override + _i2.AuthStore get authStore => (super.noSuchMethod( + Invocation.getter(#authStore), + returnValue: _FakeAuthStore_0( + this, + Invocation.getter(#authStore), + ), + returnValueForMissingStub: _FakeAuthStore_0( + this, + Invocation.getter(#authStore), + ), + ) as _i2.AuthStore); + + @override + _i3.RecordService get admins => (super.noSuchMethod( + Invocation.getter(#admins), + returnValue: _FakeRecordService_1( + this, + Invocation.getter(#admins), + ), + returnValueForMissingStub: _FakeRecordService_1( + this, + Invocation.getter(#admins), + ), + ) as _i3.RecordService); + + @override + _i4.CollectionService get collections => (super.noSuchMethod( + Invocation.getter(#collections), + returnValue: _FakeCollectionService_2( + this, + Invocation.getter(#collections), + ), + returnValueForMissingStub: _FakeCollectionService_2( + this, + Invocation.getter(#collections), + ), + ) as _i4.CollectionService); + + @override + _i5.FileService get files => (super.noSuchMethod( + Invocation.getter(#files), + returnValue: _FakeFileService_3( + this, + Invocation.getter(#files), + ), + returnValueForMissingStub: _FakeFileService_3( + this, + Invocation.getter(#files), + ), + ) as _i5.FileService); + + @override + _i6.RealtimeService get realtime => (super.noSuchMethod( + Invocation.getter(#realtime), + returnValue: _FakeRealtimeService_4( + this, + Invocation.getter(#realtime), + ), + returnValueForMissingStub: _FakeRealtimeService_4( + this, + Invocation.getter(#realtime), + ), + ) as _i6.RealtimeService); + + @override + _i7.SettingsService get settings => (super.noSuchMethod( + Invocation.getter(#settings), + returnValue: _FakeSettingsService_5( + this, + Invocation.getter(#settings), + ), + returnValueForMissingStub: _FakeSettingsService_5( + this, + Invocation.getter(#settings), + ), + ) as _i7.SettingsService); + + @override + _i8.LogService get logs => (super.noSuchMethod( + Invocation.getter(#logs), + returnValue: _FakeLogService_6( + this, + Invocation.getter(#logs), + ), + returnValueForMissingStub: _FakeLogService_6( + this, + Invocation.getter(#logs), + ), + ) as _i8.LogService); + + @override + _i9.HealthService get health => (super.noSuchMethod( + Invocation.getter(#health), + returnValue: _FakeHealthService_7( + this, + Invocation.getter(#health), + ), + returnValueForMissingStub: _FakeHealthService_7( + this, + Invocation.getter(#health), + ), + ) as _i9.HealthService); + + @override + _i10.BackupService get backups => (super.noSuchMethod( + Invocation.getter(#backups), + returnValue: _FakeBackupService_8( + this, + Invocation.getter(#backups), + ), + returnValueForMissingStub: _FakeBackupService_8( + this, + Invocation.getter(#backups), + ), + ) as _i10.BackupService); + + @override + _i11.CronService get crons => (super.noSuchMethod( + Invocation.getter(#crons), + returnValue: _FakeCronService_9( + this, + Invocation.getter(#crons), + ), + returnValueForMissingStub: _FakeCronService_9( + this, + Invocation.getter(#crons), + ), + ) as _i11.CronService); + + @override + _i12.Client Function() get httpClientFactory => (super.noSuchMethod( + Invocation.getter(#httpClientFactory), + returnValue: () => _FakeClient_10( + this, + Invocation.getter(#httpClientFactory), + ), + returnValueForMissingStub: () => _FakeClient_10( + this, + Invocation.getter(#httpClientFactory), + ), + ) as _i12.Client Function()); + + @override + set baseUrl(String? v) => super.noSuchMethod( + Invocation.setter( + #baseUrl, + v, + ), + returnValueForMissingStub: null, + ); + + @override + set baseURL(String? value) => super.noSuchMethod( + Invocation.setter( + #baseURL, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set lang(String? value) => super.noSuchMethod( + Invocation.setter( + #lang, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set authStore(_i2.AuthStore? value) => super.noSuchMethod( + Invocation.setter( + #authStore, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set collections(_i4.CollectionService? value) => super.noSuchMethod( + Invocation.setter( + #collections, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set files(_i5.FileService? value) => super.noSuchMethod( + Invocation.setter( + #files, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set realtime(_i6.RealtimeService? value) => super.noSuchMethod( + Invocation.setter( + #realtime, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set settings(_i7.SettingsService? value) => super.noSuchMethod( + Invocation.setter( + #settings, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set logs(_i8.LogService? value) => super.noSuchMethod( + Invocation.setter( + #logs, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set health(_i9.HealthService? value) => super.noSuchMethod( + Invocation.setter( + #health, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set backups(_i10.BackupService? value) => super.noSuchMethod( + Invocation.setter( + #backups, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set crons(_i11.CronService? value) => super.noSuchMethod( + Invocation.setter( + #crons, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set httpClientFactory(_i12.Client Function()? value) => super.noSuchMethod( + Invocation.setter( + #httpClientFactory, + value, + ), + returnValueForMissingStub: null, + ); + + @override + _i3.RecordService collection(String? collectionIdOrName) => + (super.noSuchMethod( + Invocation.method( + #collection, + [collectionIdOrName], + ), + returnValue: _FakeRecordService_1( + this, + Invocation.method( + #collection, + [collectionIdOrName], + ), + ), + returnValueForMissingStub: _FakeRecordService_1( + this, + Invocation.method( + #collection, + [collectionIdOrName], + ), + ), + ) as _i3.RecordService); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); + + @override + String filter( + String? expr, [ + Map? query = const {}, + ]) => + (super.noSuchMethod( + Invocation.method( + #filter, + [ + expr, + query, + ], + ), + returnValue: _i22.dummyValue( + this, + Invocation.method( + #filter, + [ + expr, + query, + ], + ), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.method( + #filter, + [ + expr, + query, + ], + ), + ), + ) as String); + + @override + Uri getFileUrl( + _i16.RecordModel? record, + String? filename, { + String? thumb, + String? token, + Map? query = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #getFileUrl, + [ + record, + filename, + ], + { + #thumb: thumb, + #token: token, + #query: query, + }, + ), + returnValue: _FakeUri_11( + this, + Invocation.method( + #getFileUrl, + [ + record, + filename, + ], + { + #thumb: thumb, + #token: token, + #query: query, + }, + ), + ), + returnValueForMissingStub: _FakeUri_11( + this, + Invocation.method( + #getFileUrl, + [ + record, + filename, + ], + { + #thumb: thumb, + #token: token, + #query: query, + }, + ), + ), + ) as Uri); + + @override + Uri buildUrl( + String? path, [ + Map? queryParameters = const {}, + ]) => + (super.noSuchMethod( + Invocation.method( + #buildUrl, + [ + path, + queryParameters, + ], + ), + returnValue: _FakeUri_11( + this, + Invocation.method( + #buildUrl, + [ + path, + queryParameters, + ], + ), + ), + returnValueForMissingStub: _FakeUri_11( + this, + Invocation.method( + #buildUrl, + [ + path, + queryParameters, + ], + ), + ), + ) as Uri); + + @override + Uri buildURL( + String? path, [ + Map? queryParameters = const {}, + ]) => + (super.noSuchMethod( + Invocation.method( + #buildURL, + [ + path, + queryParameters, + ], + ), + returnValue: _FakeUri_11( + this, + Invocation.method( + #buildURL, + [ + path, + queryParameters, + ], + ), + ), + returnValueForMissingStub: _FakeUri_11( + this, + Invocation.method( + #buildURL, + [ + path, + queryParameters, + ], + ), + ), + ) as Uri); + + @override + _i13.BatchService createBatch() => (super.noSuchMethod( + Invocation.method( + #createBatch, + [], + ), + returnValue: _FakeBatchService_12( + this, + Invocation.method( + #createBatch, + [], + ), + ), + returnValueForMissingStub: _FakeBatchService_12( + this, + Invocation.method( + #createBatch, + [], + ), + ), + ) as _i13.BatchService); + + @override + _i14.Future send( + String? path, { + String? method = 'GET', + Map? headers = const {}, + Map? query = const {}, + Map? body = const {}, + List<_i12.MultipartFile>? files = const [], + }) => + (super.noSuchMethod( + Invocation.method( + #send, + [path], + { + #method: method, + #headers: headers, + #query: query, + #body: body, + #files: files, + }, + ), + returnValue: _i22.ifNotNull( + _i22.dummyValueOrNull( + this, + Invocation.method( + #send, + [path], + { + #method: method, + #headers: headers, + #query: query, + #body: body, + #files: files, + }, + ), + ), + (T v) => _i14.Future.value(v), + ) ?? + _FakeFuture_13( + this, + Invocation.method( + #send, + [path], + { + #method: method, + #headers: headers, + #query: query, + #body: body, + #files: files, + }, + ), + ), + returnValueForMissingStub: _i22.ifNotNull( + _i22.dummyValueOrNull( + this, + Invocation.method( + #send, + [path], + { + #method: method, + #headers: headers, + #query: query, + #body: body, + #files: files, + }, + ), + ), + (T v) => _i14.Future.value(v), + ) ?? + _FakeFuture_13( + this, + Invocation.method( + #send, + [path], + { + #method: method, + #headers: headers, + #query: query, + #body: body, + #files: files, + }, + ), + ), + ) as _i14.Future); +} + +/// A class which mocks [RecordService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRecordService extends _i1.Mock implements _i3.RecordService { + @override + String get baseCollectionPath => (super.noSuchMethod( + Invocation.getter(#baseCollectionPath), + returnValue: _i22.dummyValue( + this, + Invocation.getter(#baseCollectionPath), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.getter(#baseCollectionPath), + ), + ) as String); + + @override + String get baseCrudPath => (super.noSuchMethod( + Invocation.getter(#baseCrudPath), + returnValue: _i22.dummyValue( + this, + Invocation.getter(#baseCrudPath), + ), + returnValueForMissingStub: _i22.dummyValue( + this, + Invocation.getter(#baseCrudPath), + ), + ) as String); + + @override + _i15.PocketBase get client => (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakePocketBase_14( + this, + Invocation.getter(#client), + ), + returnValueForMissingStub: _FakePocketBase_14( + this, + Invocation.getter(#client), + ), + ) as _i15.PocketBase); + + @override + _i16.RecordModel itemFactoryFunc(Map? json) => + (super.noSuchMethod( + Invocation.method( + #itemFactoryFunc, + [json], + ), + returnValue: _FakeRecordModel_15( + this, + Invocation.method( + #itemFactoryFunc, + [json], + ), + ), + returnValueForMissingStub: _FakeRecordModel_15( + this, + Invocation.method( + #itemFactoryFunc, + [json], + ), + ), + ) as _i16.RecordModel); + + @override + _i14.Future<_i6.UnsubscribeFunc> subscribe( + String? topic, + _i3.RecordSubscriptionFunc? callback, { + String? expand, + String? filter, + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #subscribe, + [ + topic, + callback, + ], + { + #expand: expand, + #filter: filter, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i6.UnsubscribeFunc>.value( + () => _i14.Future.value()), + returnValueForMissingStub: _i14.Future<_i6.UnsubscribeFunc>.value( + () => _i14.Future.value()), + ) as _i14.Future<_i6.UnsubscribeFunc>); + + @override + _i14.Future unsubscribe([String? topic = '']) => (super.noSuchMethod( + Invocation.method( + #unsubscribe, + [topic], + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future<_i16.RecordModel> update( + String? id, { + Map? body = const {}, + Map? query = const {}, + List<_i12.MultipartFile>? files = const [], + Map? headers = const {}, + String? expand, + String? fields, + }) => + (super.noSuchMethod( + Invocation.method( + #update, + [id], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + returnValue: _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #update, + [id], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #update, + [id], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + ) as _i14.Future<_i16.RecordModel>); + + @override + _i14.Future delete( + String? id, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [id], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future<_i17.AuthMethodsList> listAuthMethods({ + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #listAuthMethods, + [], + { + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: + _i14.Future<_i17.AuthMethodsList>.value(_FakeAuthMethodsList_16( + this, + Invocation.method( + #listAuthMethods, + [], + { + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i17.AuthMethodsList>.value(_FakeAuthMethodsList_16( + this, + Invocation.method( + #listAuthMethods, + [], + { + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i17.AuthMethodsList>); + + @override + _i14.Future<_i18.RecordAuth> authWithPassword( + String? usernameOrEmail, + String? password, { + String? expand, + String? fields, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #authWithPassword, + [ + usernameOrEmail, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithPassword, + [ + usernameOrEmail, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithPassword, + [ + usernameOrEmail, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i18.RecordAuth>); + + @override + _i14.Future<_i18.RecordAuth> authWithOAuth2Code( + String? provider, + String? code, + String? codeVerifier, + String? redirectURL, { + Map? createData = const {}, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + String? expand, + String? fields, + }) => + (super.noSuchMethod( + Invocation.method( + #authWithOAuth2Code, + [ + provider, + code, + codeVerifier, + redirectURL, + ], + { + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + returnValue: _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOAuth2Code, + [ + provider, + code, + codeVerifier, + redirectURL, + ], + { + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOAuth2Code, + [ + provider, + code, + codeVerifier, + redirectURL, + ], + { + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + ) as _i14.Future<_i18.RecordAuth>); + + @override + _i14.Future<_i18.RecordAuth> authWithOAuth2( + String? providerName, + _i3.OAuth2URLCallbackFunc? urlCallback, { + List? scopes = const [], + Map? createData = const {}, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + String? expand, + String? fields, + }) => + (super.noSuchMethod( + Invocation.method( + #authWithOAuth2, + [ + providerName, + urlCallback, + ], + { + #scopes: scopes, + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + returnValue: _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOAuth2, + [ + providerName, + urlCallback, + ], + { + #scopes: scopes, + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOAuth2, + [ + providerName, + urlCallback, + ], + { + #scopes: scopes, + #createData: createData, + #body: body, + #query: query, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + ) as _i14.Future<_i18.RecordAuth>); + + @override + _i14.Future<_i18.RecordAuth> authRefresh({ + String? expand, + String? fields, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #authRefresh, + [], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authRefresh, + [], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authRefresh, + [], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i18.RecordAuth>); + + @override + _i14.Future requestPasswordReset( + String? email, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #requestPasswordReset, + [email], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future confirmPasswordReset( + String? passwordResetToken, + String? password, + String? passwordConfirm, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #confirmPasswordReset, + [ + passwordResetToken, + password, + passwordConfirm, + ], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future requestVerification( + String? email, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #requestVerification, + [email], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future confirmVerification( + String? verificationToken, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #confirmVerification, + [verificationToken], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future requestEmailChange( + String? newEmail, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #requestEmailChange, + [newEmail], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future confirmEmailChange( + String? emailChangeToken, + String? userPassword, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #confirmEmailChange, + [ + emailChangeToken, + userPassword, + ], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + + @override + _i14.Future<_i19.OTPResponse> requestOTP( + String? email, { + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #requestOTP, + [email], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i19.OTPResponse>.value(_FakeOTPResponse_18( + this, + Invocation.method( + #requestOTP, + [email], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i19.OTPResponse>.value(_FakeOTPResponse_18( + this, + Invocation.method( + #requestOTP, + [email], + { + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i19.OTPResponse>); + + @override + _i14.Future<_i18.RecordAuth> authWithOTP( + String? otpId, + String? password, { + String? expand, + String? fields, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #authWithOTP, + [ + otpId, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOTP, + [ + otpId, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i18.RecordAuth>.value(_FakeRecordAuth_17( + this, + Invocation.method( + #authWithOTP, + [ + otpId, + password, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i18.RecordAuth>); + + @override + _i14.Future<_i15.PocketBase> impersonate( + String? recordId, + num? duration, { + String? expand, + String? fields, + Map? body = const {}, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #impersonate, + [ + recordId, + duration, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i15.PocketBase>.value(_FakePocketBase_14( + this, + Invocation.method( + #impersonate, + [ + recordId, + duration, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i15.PocketBase>.value(_FakePocketBase_14( + this, + Invocation.method( + #impersonate, + [ + recordId, + duration, + ], + { + #expand: expand, + #fields: fields, + #body: body, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i15.PocketBase>); + + @override + _i14.Future> getFullList({ + int? batch = 500, + String? expand, + String? filter, + String? sort, + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #getFullList, + [], + { + #batch: batch, + #expand: expand, + #filter: filter, + #sort: sort, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: + _i14.Future>.value(<_i16.RecordModel>[]), + returnValueForMissingStub: + _i14.Future>.value(<_i16.RecordModel>[]), + ) as _i14.Future>); + + @override + _i14.Future<_i21.ResultList<_i16.RecordModel>> getList({ + int? page = 1, + int? perPage = 30, + bool? skipTotal = false, + String? expand, + String? filter, + String? sort, + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #getList, + [], + { + #page: page, + #perPage: perPage, + #skipTotal: skipTotal, + #expand: expand, + #filter: filter, + #sort: sort, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i21.ResultList<_i16.RecordModel>>.value( + _FakeResultList_19<_i16.RecordModel>( + this, + Invocation.method( + #getList, + [], + { + #page: page, + #perPage: perPage, + #skipTotal: skipTotal, + #expand: expand, + #filter: filter, + #sort: sort, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i21.ResultList<_i16.RecordModel>>.value( + _FakeResultList_19<_i16.RecordModel>( + this, + Invocation.method( + #getList, + [], + { + #page: page, + #perPage: perPage, + #skipTotal: skipTotal, + #expand: expand, + #filter: filter, + #sort: sort, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i21.ResultList<_i16.RecordModel>>); + + @override + _i14.Future<_i16.RecordModel> getOne( + String? id, { + String? expand, + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #getOne, + [id], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #getOne, + [id], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #getOne, + [id], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i16.RecordModel>); + + @override + _i14.Future<_i16.RecordModel> getFirstListItem( + String? filter, { + String? expand, + String? fields, + Map? query = const {}, + Map? headers = const {}, + }) => + (super.noSuchMethod( + Invocation.method( + #getFirstListItem, + [filter], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + returnValue: _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #getFirstListItem, + [filter], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #getFirstListItem, + [filter], + { + #expand: expand, + #fields: fields, + #query: query, + #headers: headers, + }, + ), + )), + ) as _i14.Future<_i16.RecordModel>); + + @override + _i14.Future<_i16.RecordModel> create({ + Map? body = const {}, + Map? query = const {}, + List<_i12.MultipartFile>? files = const [], + Map? headers = const {}, + String? expand, + String? fields, + }) => + (super.noSuchMethod( + Invocation.method( + #create, + [], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + returnValue: _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #create, + [], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + returnValueForMissingStub: + _i14.Future<_i16.RecordModel>.value(_FakeRecordModel_15( + this, + Invocation.method( + #create, + [], + { + #body: body, + #query: query, + #files: files, + #headers: headers, + #expand: expand, + #fields: fields, + }, + ), + )), + ) as _i14.Future<_i16.RecordModel>); +} diff --git a/packages/pocketbase_sync/test/simulation/mock_connectivity.dart b/packages/pocketbase_sync/test/simulation/mock_connectivity.dart new file mode 100644 index 0000000..6c0ccc8 --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/mock_connectivity.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +// Mocking the result enum to avoid depending on connectivity_plus directly in the harness +// if the package is not yet added to dependencies (it wasn't in the list I saw). +// But usually one would import 'package:connectivity_plus/connectivity_plus.dart'; +// Since I haven't added connectivity_plus to the package dependencies (I only added http, etc), +// I will define a compatible enum here. If the user app uses connectivity_plus, they can map it. + +enum ConnectivityResult { wifi, mobile, none, ethernet, bluetooth, other, vpn } + +/// A mock adapter for simulating OS connectivity changes. +class MockConnectivity { + final _controller = StreamController.broadcast(); + + Stream get onConnectivityChanged => _controller.stream; + + ConnectivityResult _current = ConnectivityResult.wifi; + ConnectivityResult get current => _current; + + /// Simulates going offline (Airplane Mode). + void goOffline() { + _current = ConnectivityResult.none; + _controller.add(_current); + } + + /// Simulates connecting to WiFi. + void goWifi() { + _current = ConnectivityResult.wifi; + _controller.add(_current); + } + + /// Simulates connecting to Mobile Data. + void goMobile() { + _current = ConnectivityResult.mobile; + _controller.add(_current); + } + + void dispose() { + _controller.close(); + } +} diff --git a/packages/pocketbase_sync/test/simulation/pocketbase_controller.dart b/packages/pocketbase_sync/test/simulation/pocketbase_controller.dart new file mode 100644 index 0000000..6f39c36 --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/pocketbase_controller.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +/// Manages a local PocketBase process for testing. +class PocketBaseController { + final Logger _logger = + Logger('PocketBaseController'); // Added logger instance + Process? _process; + final int port; + final String host; + final String executablePath; + Directory? _dataDir; + + final bool managed; + + PocketBaseController({ + this.port = 8090, + this.host = '127.0.0.1', + this.executablePath = 'pocketbase', // Assumes in PATH + this.managed = true, + String? dataDir, + }) : _fixedDataDir = dataDir; + + final String? _fixedDataDir; + + String get baseUrl => 'http://$host:$port'; + + /// Starts the PocketBase server. + Future start({bool verbose = false}) async { + if (!managed) { + _logger + .info('PocketBase is unmanaged. Assuming it is running at $baseUrl'); + return; + } + + if (_process != null) { + throw StateError('PocketBase is already running'); + } + + // Determine data directory + if (_fixedDataDir != null) { + _dataDir = Directory(_fixedDataDir); + if (!await _dataDir!.exists()) { + await _dataDir!.create(recursive: true); + } + _logger.info('Using fixed data directory: ${_dataDir!.path}'); + } else { + // Create a temporary directory for data + _dataDir = await Directory.systemTemp.createTemp('pb_test_data_'); + _logger.info('Created temp data directory: ${_dataDir!.path}'); + } + + final args = [ + 'serve', + '--http=$host:$port', + '--dir=${_dataDir!.path}', + ]; + + // Using simple process start + _process = await Process.start( + executablePath, + args, + mode: verbose ? ProcessStartMode.inheritStdio : ProcessStartMode.normal, + ); + + if (!verbose) { + // Drain stdout/stderr so buffer doesn't fill up + _process!.stdout.listen((_) {}); + _process!.stderr.listen((_) {}); + } + + // Wait for it to be ready? + // A simple poll loop or just wait a second. + // PocketBase starts very fast. + await Future.delayed(const Duration(seconds: 1)); + + // Verify it's running by hitting health check? + // PocketBase doesn't have a standardized /health endpoint in default setup but the root /api/ works. + } + + /// Stops the server and cleans up data. + Future stop() async { + if (!managed) return; + + _process?.kill(ProcessSignal.sigterm); + await _process?.exitCode; + _process = null; + + // Only clean up if we created a TEMP directory (i.e. no fixed dir provided) + if (_fixedDataDir == null && _dataDir != null && await _dataDir!.exists()) { + await _dataDir!.delete(recursive: true); + _dataDir = null; + } + } + + /// Initializes the server with an admin account and schema. + Future initialize({ + required String adminEmail, + required String adminPass, + String? schemaPath, + }) async { + if (!managed) { + _logger.info( + 'Unmanaged mode: Skipping initialization (superuser/schema). Assuming pre-configured.'); + return; + } + + if (managed) { + // 1. Create Superuser using CLI (Only works if we own the dir) + _logger.info('Creating superuser: $adminEmail'); + final args = [ + 'superuser', + 'create', + adminEmail, + adminPass, + '--dir=${_dataDir!.path}', + ]; + + final p = await Process.run(executablePath, args); + if (p.exitCode != 0) { + _logger.warning('Failed to creating superuser via CLI: ${p.stderr}'); + } else { + _logger.info('Superuser created.'); + } + } else { + _logger.info( + 'Skipping CLI superuser creation (unmanaged mode). Assuming user exists.'); + } + + // 2. Import Collections (Works via API regardless of managing process) + if (schemaPath != null) { + _logger.info('Importing schema from $schemaPath'); + final schemaFile = File(schemaPath); + if (await schemaFile.exists()) { + try { + // Auth endpoint for superusers (v0.23+) + // Fallback to old admins if needed, but let's try the new one first or assuming v0.23 based on schema. + var authUrl = + '$baseUrl/api/collections/_superusers/auth-with-password'; + + final authBody = jsonEncode({ + 'identity': adminEmail, + 'password': adminPass, + }); + + var authReq = await http.post( + Uri.parse(authUrl), + headers: {'Content-Type': 'application/json'}, + body: authBody, + ); + + // Fallback for older PB versions if 404 + if (authReq.statusCode == 404) { + _logger.info('Legacy admin auth endpoint fallback...'); + authUrl = '$baseUrl/api/admins/auth-with-password'; + authReq = await http.post( + Uri.parse(authUrl), + headers: {'Content-Type': 'application/json'}, + body: authBody, + ); + } + + if (authReq.statusCode == 200) { + final token = jsonDecode(authReq.body)['token']; + + // New API does not support bulk import. We must create collections one by one. + final schemaJson = await schemaFile.readAsString(); + final List collections = jsonDecode(schemaJson); + + _logger.info('Found ${collections.length} collections to import.'); + + for (final col in collections) { + final name = col['name']; + final type = col['type']; + // Preserve ID if possible? PB auto-gens ID usually but can accept ID. + final _ = col['id']; + + // Skip system collections that already exist (usually) + // In v0.23+, _superusers, _externalAuths, _mfas, _otps, _authOrigins are system. + if (col['system'] == true || name.startsWith('_')) { + _logger.info('Skipping system collection: $name'); + continue; + } + + _logger.info('Creating collection: $name ($type)'); + + final createUrl = '$baseUrl/api/collections'; + final createBody = jsonEncode(col); + + final createReq = await http.post( + Uri.parse(createUrl), + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + body: createBody, + ); + + if (createReq.statusCode == 200) { + _logger.info('Created collection $name'); + } else if (createReq.statusCode == 400) { + // Check if loop? or already exists? + // "Collection name ... already exists" + final msg = createReq.body; + if (msg.contains('exists')) { + _logger.info( + 'Collection $name already exists. Skipping or Updating?'); + // Ideally we update: PUT /api/collections/{id_or_name} + // But for now, skip. + } else { + _logger.warning('Failed to create collection $name: $msg'); + } + } else { + _logger.warning( + 'Failed to create collection $name: ${createReq.statusCode} ${createReq.body}'); + } + } + _logger.info('Schema import process finished.'); + } else { + _logger.warning('Failed to login as admin: ${authReq.body}'); + } + } catch (e) { + _logger.warning('Error importing schema: $e'); + } + } + } + } + + /// Restarts the server (simulating a crash/restart). + Future restart() async { + // Keep data dir! + final savedDir = _dataDir; + + _process?.kill(ProcessSignal.sigterm); + await _process?.exitCode; + _process = null; + + // Restart with SAME data dir + if (savedDir == null) throw StateError("Cannot restart, never started"); + + final args = [ + 'serve', + '--http=$host:$port', + '--dir=${savedDir.path}', + ]; + + _process = await Process.start(executablePath, args); + _process!.stdout.listen((_) {}); + _process!.stderr.listen((_) {}); + + await Future.delayed(const Duration(seconds: 1)); + } +} diff --git a/packages/pocketbase_sync/test/simulation/simulation_actions.dart b/packages/pocketbase_sync/test/simulation/simulation_actions.dart new file mode 100644 index 0000000..e604848 --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/simulation_actions.dart @@ -0,0 +1,165 @@ +/// Abstract Base Action +abstract class SimulationAction { + Future execute(dynamic context); + String describe(); +} + +/// Creates a new item in the local sync manager +class CreateAction extends SimulationAction { + final String id; + final Map data; + + CreateAction(this.id, this.data); + + @override + Future execute(context) async { + // context is TestContext { manager, logger } + await context.manager.create(id, {'id': id, ...data}); + } + + @override + String describe() => 'Create(id: $id, data: $data)'; +} + +/// Updates an item +class UpdateAction extends SimulationAction { + final String id; + final Map data; + + UpdateAction(this.id, this.data); + + @override + Future execute(context) async { + // We pass the full record data to update() usually, or partial? + // SyncManager.update(id, T item). It expects the full item T. + // But our T is Map. + // So if we pass partial, it might overwrite others with null if not careful. + // BUT, our generator should probably provide full data or we merge here? + // Let's assume the generator provides the fields intended to be updated. + // Wait, if SyncManager replaces the record, we need the OLD data to merge. + // The test context doesn't expose read access easily unless we use repository. + // Let's assume the generator tracks the "current state" of the item to produce valid full updates? + // OR: usage of update() in SyncManager: + // "await repository.save(record.copyWith(data: item...))" + // It REPLACES data. So we need to provide the merged state. + + // For simulation simplicity, we can fetch current from repo, merge, and save. + final current = await context.repository.get(id); + if (current != null) { + final merged = Map.from(current.data); + merged.addAll(data); + await context.manager.update(id, merged); + } + } + + @override + String describe() => 'Update(id: $id, changes: $data)'; +} + +/// Deletes an item +class DeleteAction extends SimulationAction { + final String id; + + DeleteAction(this.id); + + @override + Future execute(context) async { + await context.manager.delete(id); + } + + @override + String describe() => 'Delete(id: $id)'; +} + +/// Forces a Sync +class SyncAction extends SimulationAction { + @override + Future execute(context) async { + try { + await context.manager.sync(); + } catch (e) { + // Sync might fail if network is down + } + } + + @override + String describe() => 'Sync()'; +} + +/// Goes Offline (Airplane Mode + Cable Cut) +class GoOfflineAction extends SimulationAction { + @override + Future execute(context) async { + await context.harness.goOffline(); + } + + @override + String describe() => 'GoOffline()'; +} + +/// Goes Online +class GoOnlineAction extends SimulationAction { + @override + Future execute(context) async { + await context.harness.goOnline(); + } + + @override + String describe() => 'GoOnline()'; +} + +/// Adds Latency to the connection +class AddLatencyAction extends SimulationAction { + @override + Future execute(context) async { + await context.harness.injectLatency(); + } + + @override + String describe() => 'AddLatency(1000ms)'; +} + +/// Removes Latency (Clears Faults) +class RemoveLatencyAction extends SimulationAction { + @override + Future execute(context) async { + await context.harness.clearNetworkFaults(); + } + + @override + String describe() => 'RemoveLatency()'; +} + +/// Restarts the PocketBase Server +class RestartServerAction extends SimulationAction { + @override + Future execute(context) async { + await context.harness.pocketbase.restart(); + } + + @override + String describe() => 'RestartServer()'; +} + +/// Simulates a change happening on the server (Concurrent Modification) +class RemoteUpdateAction extends SimulationAction { + final String id; + final Map changes; + + RemoteUpdateAction(this.id, this.changes); + + @override + Future execute(context) async { + // We use the manager's PB instance which is authenticated. + // This simulates "User updated record on another device". + // Bypassing the sync manager to touch the server directly. + try { + await context.manager.pb.collection('notes').update(id, body: changes); + } catch (e) { + // Ignore errors (e.g. record deleted on server already, or network down) + } + } + + @override + String describe() => 'RemoteUpdate(id: $id, changes: "$changes")'; +} diff --git a/packages/pocketbase_sync/test/simulation/simulation_harness.dart b/packages/pocketbase_sync/test/simulation/simulation_harness.dart new file mode 100644 index 0000000..1cf9bd7 --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/simulation_harness.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:logging/logging.dart'; +// Import to use in harness if needed, or generic. +import 'pocketbase_controller.dart'; +import 'toxiproxy_client.dart'; +import 'mock_connectivity.dart'; + +/// The Hypervisor that orchestrates the simulation. +class SimulationHarness { + final ToxiproxyController toxiproxy; + final PocketBaseController pocketbase; + final MockConnectivity connectivity; + final Logger _logger = Logger('SimulationHarness'); + + Process? _toxiProcess; + final String? toxiproxyBinary; + + SimulationHarness({ + ToxiproxyController? toxiproxy, + PocketBaseController? pocketbase, + MockConnectivity? connectivity, + this.toxiproxyBinary, + }) : toxiproxy = toxiproxy ?? ToxiproxyController(), + pocketbase = pocketbase ?? PocketBaseController(), + connectivity = connectivity ?? MockConnectivity(); + + String get proxyUrl => + 'http://localhost:8080'; // The address the app connects to + + /// Sets up the infrastructure: Starts PB, Creates Proxy. + Future setUp() async { + _logger.info('Setting up simulation environment...'); + + // 0. Start Toxiproxy Server if binary provided + if (toxiproxyBinary != null) { + _logger.info('Starting Toxiproxy server from $toxiproxyBinary'); + _toxiProcess = await Process.start(toxiproxyBinary!, []); + + // Pipe output to see if it fails to start (e.g. port binding) + _toxiProcess!.stdout.transform(utf8.decoder).listen((data) { + // _logger.fine('Toxiproxy(out): $data'); + print('Toxiproxy: $data'); + }); + _toxiProcess!.stderr.transform(utf8.decoder).listen((data) { + _logger.warning('Toxiproxy(err): $data'); + print('Toxiproxy(err): $data'); + }); + + // Wait for it to boot + await Future.delayed(const Duration(seconds: 1)); + } + + // 1. Start PocketBase + await pocketbase.start(); + + // Initialize DB (Create Admin + Import Collections) + await pocketbase.initialize( + adminEmail: 'rody.davis.jr@gmail.com', + adminPass: 'razroq-hedne5-cafdaT', + schemaPath: './example/pb_collections.json', + ); + + _logger.info('PocketBase started at ${pocketbase.baseUrl}'); + + // 2. Setup Toxiproxy + // We want localhost:8080 (Proxy) -> localhost:PbPort (Upstream) + // If running binary locally, PB is also local (localhost). + // So upstream is 'localhost:${pocketbase.port}'. + + // Docker logic fallback + // final upstream = 'host.docker.internal:${pocketbase.port}'; + + // Local binary logic + final upstream = 'localhost:${pocketbase.port}'; + + try { + // Clean start + _logger.info('Resetting Toxiproxy...'); + await toxiproxy.reset(); + _logger.info('Toxiproxy reset. Creating proxy pb_api...'); + await toxiproxy.createProxy('pb_api', '0.0.0.0:8080', upstream); + _logger.info('Proxy setup: localhost:8080 -> $upstream'); + } catch (e) { + _logger.warning( + 'Failed to setup toxiproxy. make sure it is running at ${toxiproxy.host}:${toxiproxy.port}', + e); + // We might throw here if strict + rethrow; + } + } + + /// Tears down the infrastructure. + Future tearDown() async { + _logger.info('Tearing down simulation...'); + await pocketbase.stop(); + try { + await toxiproxy.deleteProxy('pb_api'); + await toxiproxy.reset(); // Clean up toxics + } catch (_) {} + + if (_toxiProcess != null) { + _toxiProcess!.kill(); + _toxiProcess = null; + } + + connectivity.dispose(); + } + + // --- Chaos Helpers --- + + Future goOffline() async { + _logger.info('Simulating OFFLINE'); + connectivity.goOffline(); // OS says offline + await toxiproxy.disable('pb_api'); // Cable cut + } + + Future goOnline() async { + _logger.info('Simulating ONLINE'); + await toxiproxy.enable('pb_api'); + connectivity.goWifi(); + } + + Future injectLatency({int latencyMs = 1000, int jitterMs = 500}) async { + await toxiproxy.addToxic( + 'pb_api', Toxic.latency(latency: latencyMs, jitter: jitterMs)); + } + + Future injectSlowNetwork() async { + // Edge network: High latency, low bandwidth + await toxiproxy.addToxic( + 'pb_api', Toxic.latency(latency: 2000, jitter: 1000)); + await toxiproxy.addToxic('pb_api', Toxic.bandwidth(rate: 10)); // 10KB/s + } + + Future clearNetworkFaults() async { + await toxiproxy.deleteToxics('pb_api'); + } +} diff --git a/packages/pocketbase_sync/test/simulation/state_verifier.dart b/packages/pocketbase_sync/test/simulation/state_verifier.dart new file mode 100644 index 0000000..2feb420 --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/state_verifier.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'package:diff_match_patch/diff_match_patch.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; + +/// Verifies consistency between Local DB and Remote PocketBase. +class StateVerifier { + final PocketBase pb; + final String collection; + final SyncRepository repository; // We use raw repository accessor + + StateVerifier({ + required this.pb, + required this.collection, + required this.repository, + }); + + /// Compares local and remote state and returns a list of differences. + /// Returns empty list if states are identical (converged). + Future> verifyConvergence() async { + final diffs = []; + + // 1. Fetch All Remote + // 1. Fetch All Remote + print('Verifier: Fetching remote records...'); + final remoteRecords = + await pb.collection(collection).getFullList(sort: 'id'); + print('Verifier: Fetched ${remoteRecords.length} remote records.'); + final remoteMap = {for (var r in remoteRecords) r.id: r.data}; + + // 2. Fetch All Local + // 2. Fetch All Local + print('Verifier: Fetching local records...'); + final localRecords = await repository.getAll(); + print('Verifier: Fetched ${localRecords.length} local records.'); + // Filter out deleted items that are correctly marked as deleted + // (In a converged state, if it's deleted locally and synced, it should be gone from server or match server tombstone if server keeps them? + // PB doesn't keep tombstones by default unless we use a "deleted" column. + // The sync manager deletes from PB. So remote should NOT have it. + // Local might have it as isDeleted=true. + // So: + // - If Record is in Remote: Local must have it, isDeleted=false, content match. + // - If Record is NOT in Remote: Local must NOT have it OR (Local has it AND isDeleted=true). + + final localMap = {for (var r in localRecords) r.id: r}; + + // Check Remote against Local + for (var id in remoteMap.keys) { + final _ = remoteMap[id]!; // remoteData + final localRecord = localMap[id]; + + if (localRecord == null) { + diffs + .add('Missing Local: Record $id exists on server but not locally.'); + } else if (localRecord.isDeleted) { + // If it's deleted locally but exists on server, sync failed to push delete? + // OR we haven't synced yet. + diffs.add( + 'Zombie: Record $id is marked deleted locally but exists on server.'); + } else { + // Compare Content + // We need to compare JSON. + // SyncRepository stores T data. We need to convert T to Map? + // Wait, SyncRecord stores T. PocketBase returns Map. + // We probably need a way to compare T to Map. + // The SyncManager has `toJson`. The verifier might need it too. + // But `repository` is generic. + // This verifier needs to know how to serialize local data. + // Ideally we pass `toJson` to Verifier or use `SyncManager` which has it. + // For this generic impl, let's assume T is Map or we pass a serializer. + } + } + + // Check Local against Remote + for (var id in localMap.keys) { + final local = localMap[id]!; + if (!remoteMap.containsKey(id)) { + if (!local.isDeleted) { + // Exists locally (alive) but not on server. + // Could be: Not yet pushed. + diffs.add( + 'Missing Remote: Record $id exists locally but not on server.'); + } else { + // Deleted locally and not on server. This is Good. + // (Assuming we don't keep tombstones forever) + } + } + } + + return diffs; + } + + /// Performs a deep diff of two JSON objects. + List diffJson(Map local, Map remote) { + final dmp = DiffMatchPatch(); + // Sort keys for deterministic stringify + final localStr = jsonEncode(local); // jsonEncode doesn't guarantee order? + // Actually standard jsonEncode is not canonical. + // But for simple verification, maybe enough if keys are standard. + + // Better: Compare keys and values manually. + return dmp.diff(localStr, jsonEncode(remote)); + } +} diff --git a/packages/pocketbase_sync/test/simulation/toxiproxy_client.dart b/packages/pocketbase_sync/test/simulation/toxiproxy_client.dart new file mode 100644 index 0000000..530c4ee --- /dev/null +++ b/packages/pocketbase_sync/test/simulation/toxiproxy_client.dart @@ -0,0 +1,176 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; + +/// Controls the Toxiproxy daemon via its HTTP API. +class ToxiproxyController { + final String host; + final int port; + final Duration timeout; + + ToxiproxyController({ + this.host = 'localhost', + this.port = 8474, + this.timeout = const Duration(seconds: 5), + }); + + String get _apiBase => 'http://$host:$port'; + + /// Creates a new proxy. + Future createProxy(String name, String listen, String upstream) async { + try { + final response = await http + .post( + Uri.parse('$_apiBase/proxies'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'name': name, + 'listen': listen, + 'upstream': upstream, + 'enabled': true, + }), + ) + .timeout(timeout); + + if (response.statusCode != 201 && response.statusCode != 200) { + throw HttpException('Failed to create proxy: ${response.body}'); + } + } on TimeoutException { + throw HttpException('Timeout creating proxy at $_apiBase'); + } + } + + /// Deletes a proxy. + Future deleteProxy(String name) async { + try { + final response = await http + .delete(Uri.parse('$_apiBase/proxies/$name')) + .timeout(timeout); + if (response.statusCode != 204 && response.statusCode != 404) { + throw HttpException('Failed to delete proxy: ${response.body}'); + } + } on TimeoutException { + throw HttpException('Timeout deleting proxy at $_apiBase'); + } + } + + /// Clears all proxies and toxics. + Future reset() async { + try { + final response = + await http.post(Uri.parse('$_apiBase/reset')).timeout(timeout); + if (response.statusCode != 204) { + throw HttpException('Failed to reset toxiproxy: ${response.body}'); + } + } on TimeoutException { + throw HttpException('Timeout resetting toxiproxy at $_apiBase'); + } + } + + /// Disables a proxy (Simulates connection cut). + Future disable(String name) async { + await _updateState(name, false); + } + + /// Enables a proxy (Simulates connection restore). + Future enable(String name) async { + await _updateState(name, true); + } + + Future _updateState(String name, bool enabled) async { + try { + final response = await http + .post( + Uri.parse('$_apiBase/proxies/$name'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'enabled': enabled}), + ) + .timeout(timeout); + if (response.statusCode != 200) { + throw HttpException('Failed to update proxy state: ${response.body}'); + } + } on TimeoutException { + throw HttpException('Timeout updating proxy state at $_apiBase'); + } + } + + /// Adds a toxic (latency, jitter, etc) to a proxy. + Future addToxic(String proxyName, Toxic toxic) async { + try { + final response = await http + .post( + Uri.parse('$_apiBase/proxies/$proxyName/toxics'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(toxic.toJson()), + ) + .timeout(timeout); + if (response.statusCode != 200 && + response.statusCode != 201 && + response.statusCode != 409) { + throw HttpException('Failed to add toxic: ${response.body}'); + } + } on TimeoutException { + throw HttpException('Timeout adding toxic at $_apiBase'); + } + } + + /// Removes all toxics from a proxy. + Future deleteToxics(String proxyName) async { + // ... + } +} + +class Toxic { + final String type; + final Map attributes; + final String? name; // Optional, Toxiproxy generates one if not provided. + final double toxicity; + + Toxic({ + required this.type, + this.attributes = const {}, + this.name, + this.toxicity = 1.0, + }); + + Map toJson() { + final map = { + 'type': type, + 'attributes': attributes, + 'toxicity': toxicity, + }; + if (name != null) map['name'] = name!; + return map; + } + + static Toxic latency({int latency = 1000, int jitter = 0}) { + return Toxic( + type: 'latency', + attributes: { + 'latency': latency, + 'jitter': jitter, + }, + ); + } + + static Toxic bandwidth({required int rate}) { + return Toxic( + type: 'bandwidth', + attributes: { + 'rate': rate, // KBs + }, + ); + } + + static Toxic slowClose({required int delay}) { + return Toxic( + type: 'slow_close', + attributes: { + 'delay': delay, + }, + ); + } + + // Add more as needed: limit_data, slicer, timeout. +} diff --git a/packages/pocketbase_sync/test/simulation_test.dart b/packages/pocketbase_sync/test/simulation_test.dart new file mode 100644 index 0000000..51acbcc --- /dev/null +++ b/packages/pocketbase_sync/test/simulation_test.dart @@ -0,0 +1,323 @@ +import 'dart:math'; +import 'package:logging/logging.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; +import 'package:pocketbase/pocketbase.dart' as client; +import 'package:drift/native.dart'; +import 'package:pocketbase_sync/sync_managers/drift.dart'; + +import 'simulation/simulation_harness.dart'; +import 'simulation/simulation_actions.dart'; +import 'simulation/pocketbase_controller.dart'; +import 'simulation/state_verifier.dart'; + +// --- Test Context --- +class TestContext { + final PocketBaseSyncManager> manager; + final SimulationHarness harness; + final DriftSyncRepository> repository; + + TestContext({ + required this.manager, + required this.harness, + required this.repository, + }); +} + +// --- Generator Logic (Simplified Glados) --- +// Since Glados integration might require code generation or specific test runner setup, +// we will implement a lightweight generator here for immediate execution. +// This allows us to run "flutter test" directly without extra steps. + +// Helper to generate valid 15-char IDs +String generateValidId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final rnd = Random(); + return List.generate(15, (index) => chars[rnd.nextInt(chars.length)]).join(); +} + +List generateScenario(int count) { + final actions = []; + final rnd = Random(42); // Fixed seed for reproducibility + final activeIds = []; + + // Helper inside to keep consistent + String nextId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return List.generate(15, (index) => chars[rnd.nextInt(chars.length)]) + .join(); + } + + for (var i = 0; i < count; i++) { + final type = rnd.nextDouble(); + if (type < 0.05) { + // Toggle Network (5%) + if (rnd.nextBool()) { + actions.add(GoOfflineAction()); + } else { + actions.add(GoOnlineAction()); + } + } else if (type < 0.1) { + // Latency (5%) + if (rnd.nextBool()) { + actions.add(AddLatencyAction()); + } else { + actions.add(RemoveLatencyAction()); + } + } else if (type < 0.15) { + // Restart Server (5%) + actions.add(RestartServerAction()); + } else if (type < 0.3) { + // Sync (15%) + actions.add(SyncAction()); + } else if (type < 0.5) { + // Create (20%) + final id = nextId(); + activeIds.add(id); + + actions.add(CreateAction(id, { + 'content': 'Content for $id', + 'priority': rnd.nextInt(100), + 'active': true, + })); + } else if (type < 0.7 && activeIds.isNotEmpty) { + // Update (20%) + final id = activeIds[rnd.nextInt(activeIds.length)]; + + // Randomly update one or more fields + final changes = {}; + final subType = rnd.nextDouble(); + + if (subType < 0.4) { + // Text Edit: Append or Replace + // Simple append to test diffing + changes['content'] = + 'Updated content for $id at ${i} [${rnd.nextInt(1000)}]'; + } else if (subType < 0.7) { + // Number change + changes['priority'] = rnd.nextInt(100); + } else { + // Bool change + changes['active'] = rnd.nextBool(); + } + + actions.add(UpdateAction(id, changes)); + } else if (type < 0.9 && activeIds.isNotEmpty) { + // Remote Update (Conflict) (20%) + final id = activeIds[rnd.nextInt(activeIds.length)]; + actions.add(RemoteUpdateAction(id, {'content': 'Remote Conflict $i'})); + } else if (activeIds.isNotEmpty) { + // Delete (10%) + final id = activeIds[rnd.nextInt(activeIds.length)]; + activeIds.remove(id); + actions.add(DeleteAction(id)); + } + } + + // Ensure we end online, clear faults, and sync to converge + actions.add(RemoveLatencyAction()); + actions.add(GoOnlineAction()); + + return actions; +} + +void main() { + Logger.root.level = Level.ALL; + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + if (record.error != null) { + print('ERROR: ${record.error}'); + } + if (record.stackTrace != null) { + print('STACK: ${record.stackTrace}'); + } + }); + + group('Simulation Tests', skip: true, () { + late SimulationHarness harness; + late PackageDatabase database; + late DriftSyncRepository> repository; + late PocketBaseSyncManager> manager; + + setUp(() async { + harness = SimulationHarness( + pocketbase: PocketBaseController( + executablePath: './example/pocketbase', + managed: true, + dataDir: './example/pb_data', + ), + toxiproxyBinary: './toxiproxy-server', + ); + await harness.setUp(); + + // Setup Client + final pb = client.PocketBase(harness.proxyUrl); + + // 1. Auth as Admin to create 'notes' collection if needed + try { + // Try new superuser auth + try { + await pb.collection('_superusers').authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } catch (_) { + // Fallback to old admin auth + await pb.admins.authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } + + // Proactively delete 'notes' to ensure fresh schema/rules + try { + await pb.collections.delete('notes'); + } catch (_) {} + + // Create 'notes' collection with public rules + await pb.collections.create(body: { + 'name': 'notes', + 'type': 'base', + 'schema': [ + { + 'name': 'content', + 'type': 'text', + 'required': true, // Make content required as per original plan + }, + { + 'name': 'priority', + 'type': 'number', + 'required': false, + }, + { + 'name': 'active', + 'type': 'bool', + 'required': false, + } + ], + 'listRule': '@request.auth.id != ""', + 'viewRule': '@request.auth.id != ""', + 'createRule': '@request.auth.id != ""', + 'updateRule': '@request.auth.id != ""', + 'deleteRule': '@request.auth.id != ""', + }); + print( + 'Created "notes" collection with expanded schema and public rules'); + + // Clear admin auth + pb.authStore.clear(); + } catch (e) { + print('Admin setup failed (Notes collection might be missing): $e'); + } + + // 2. Create a random user for this test run to ensure isolation/valid auth + + final email = 'test_${Random().nextInt(10000)}@example.com'; + final password = 'password123456'; + + try { + await pb.collection('users').create(body: { + 'email': email, + 'password': password, + 'passwordConfirm': password, + 'username': 'user_${Random().nextInt(10000)}', + }); + await pb.collection('users').authWithPassword(email, password); + print('Authenticated as $email'); + } catch (e) { + print('Failed to auth: $e'); + // If fail, we might proceed but sync will likely fail + } + + database = PackageDatabase(NativeDatabase.memory()); + repository = DriftSyncRepository>( + dbWrapper: database, + collectionName: 'notes', + toJson: (m) => m, + fromJson: (m) => m, + ); + + manager = PocketBaseSyncManager( + pb: pb, // Point to Proxy + collection: 'notes', + repository: repository, + toJson: (m) => m, + fromJson: (m) => m, + ); + }); + + tearDown(() async { + // Cleanup: Delete 'notes' collection (requires admin) + try { + // We need a fresh client or auth the existing one as admin + final adminPb = + client.PocketBase('http://127.0.0.1:${harness.pocketbase.port}'); + + // Try new superuser auth first + try { + await adminPb.collection('_superusers').authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } catch (_) { + // Fallback + await adminPb.admins.authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } + + await adminPb.collections.delete('notes'); + print('Cleanup: Deleted "notes" collection'); + } catch (e) { + print('Cleanup warning: Failed to delete "notes" collection: $e'); + } + + manager.dispose(); + await database.close(); + await harness.tearDown(); + }); + + test('Chaos Scenario: Random actions lead to eventual consistency', + () async { + const seed = 12345; + final actions = generateScenario(100); + + final context = TestContext( + manager: manager, + harness: harness, + repository: repository, + ); + + print('Running scenario with seed $seed (${actions.length} actions)'); + + for (var action in actions) { + print('Executing: ${action.describe()}'); + await action.execute(context); + // Add valid jitter between actions? + // await Future.delayed(Duration(milliseconds: 10)); + } + + // Allow final sync to settle + await Future.delayed(const Duration(seconds: 2)); + + // Verify + final verifyPb = client.PocketBase( + 'http://127.0.0.1:${harness.pocketbase.port}'); // Bypass proxy for verification + try { + await verifyPb.collection('_superusers').authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } catch (_) { + await verifyPb.admins.authWithPassword( + 'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT'); + } + + final verifier = StateVerifier( + pb: verifyPb, + collection: 'notes', + repository: repository, + ); + + final diffs = await verifier.verifyConvergence(); + if (diffs.isNotEmpty) { + fail('State diverged:\n${diffs.join('\n')}'); + } else { + print('โœ“ State Converged!'); + } + }, timeout: const Timeout(Duration(minutes: 5))); + }); +} diff --git a/packages/pocketbase_sync/test/sync_update_test.dart b/packages/pocketbase_sync/test/sync_update_test.dart new file mode 100644 index 0000000..cc3474b --- /dev/null +++ b/packages/pocketbase_sync/test/sync_update_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:pocketbase_sync/pocketbase_sync.dart'; +import 'package:pocketbase_sync/repo/in_memory_repository.dart'; + +import 'pb_sync_manager_test.mocks.dart'; + +// Test Model +class Note { + final String id; + final String content; + final String category; + Note({required this.id, required this.content, required this.category}); + + Map toJson() => { + 'id': id, + 'content': content, + 'category': category, + }; + static Note fromJson(Map json) => Note( + id: json['id'], + content: json['content'], + category: json['category'] ?? 'General', + ); +} + +void main() { + late MockPocketBase mockPb; + late MockRecordService mockCollection; + late InMemoryRepository repository; + late PocketBaseSyncManager manager; + + setUp(() { + mockPb = MockPocketBase(); + mockCollection = MockRecordService(); + repository = InMemoryRepository(); + + when(mockPb.collection('notes')).thenReturn(mockCollection); + + manager = PocketBaseSyncManager( + pb: mockPb, + collection: 'notes', + toJson: (n) => n.toJson(), + fromJson: (j) => Note.fromJson(j), + repository: repository, + retentionPeriod: const Duration(days: 30), + ); + }); + + test('Reproduction: Server update should reflect locally after sync', + () async { + // 1. Setup Initial State (Already Synced) + final initialTime = DateTime.parse('2023-01-01 10:00:00Z'); + final note = Note(id: 'n1', content: 'Initial Content', category: 'Work'); + + await repository.save( + SyncRecord( + id: 'n1', + data: note, + baseData: note, + serverUpdatedAt: initialTime, + isDirty: false, + ), + ); + + // Simulate we have synced up to this time + await repository.setLastSyncTime(initialTime); + + // 2. Mock Server having a newer version + final updatedTime = initialTime.add(const Duration(minutes: 5)); + final updatedNoteJson = { + 'id': 'n1', + 'content': 'Updated Content', // Changed content + 'category': 'Work', + 'updated': updatedTime.toIso8601String(), + }; + + // Expect the sync manager to ask for records updated after the last sync time + // capture the filter to verify what is being sent + when(mockCollection.getFullList(filter: anyNamed('filter'))) + .thenAnswer((invocation) async { + final filter = + invocation.namedArguments[const Symbol('filter')] as String?; + print('Filter used: $filter'); // Debug print + + // precise verification of the filter format + if (filter != null) { + if (filter.contains('T')) { + throw TestFailure('Filter should not contain T separator: $filter'); + } + if (!filter.contains('>=')) { + throw TestFailure('Filter should use >= operator: $filter'); + } + } + + // Return the updated record simulating server response + return [RecordModel.fromJson(updatedNoteJson)]; + }); + + // Mock ID List for Reconciliation (Server still has the item) + when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => [ + RecordModel.fromJson({'id': 'n1'}) + ]); + + // 3. Run Sync + await manager.sync(); + + // 4. Verify Local Update + final localRecord = await repository.get('n1'); + expect(localRecord, isNotNull); + expect(localRecord!.data.content, equals('Updated Content'), + reason: "Local content should match server content after sync"); + expect(localRecord.serverUpdatedAt, equals(updatedTime)); + }); + + test('Reproduction: Conflict (Server Wins on same field)', () async { + // 1. Setup Initial State + final initialTime = DateTime.parse('2023-01-01 10:00:00Z'); + final baseNote = Note(id: 'n1', content: 'Base', category: 'Work'); + + // 2. Local Edit (Dirty) - Content: "Local Edit" + final localNote = Note(id: 'n1', content: 'Local Edit', category: 'Work'); + await repository.save( + SyncRecord( + id: 'n1', + data: localNote, + baseData: baseNote, + serverUpdatedAt: initialTime, + isDirty: true, + ), + ); + await repository.setLastSyncTime(initialTime); + + // 3. Server Edit - Content: "Server Edit" + final updatedTime = initialTime.add(const Duration(minutes: 5)); + final remoteNoteJson = { + 'id': 'n1', + 'content': 'Server Edit', + 'category': 'Work', + 'updated': updatedTime.toIso8601String(), + }; + + when(mockCollection.getFullList(filter: anyNamed('filter'))) + .thenAnswer((_) async => [RecordModel.fromJson(remoteNoteJson)]); + + // Mock ID List (Item exists) + when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => [ + RecordModel.fromJson({'id': 'n1'}) + ]); + + // 4. Sync + await manager.sync(); + + // 5. Verify Conflict Resolution + final localRecord = await repository.get('n1'); + expect(localRecord!.data.content, equals('Server Edit'), + reason: "Server edit should win on collision"); + expect(localRecord.isDirty, isTrue, + reason: "Merged record should remain dirty to push back merged state"); + expect(localRecord.serverUpdatedAt, equals(updatedTime)); + }); + + test( + 'Reproduction: Remote Deletion (Item missing on server should delete local)', + () async { + // 1. Setup Initial State (Synced) + final initialTime = DateTime.parse('2023-01-01 10:00:00Z'); + final note = Note(id: 'n1', content: 'To Be Deleted', category: 'Work'); + + await repository.save( + SyncRecord( + id: 'n1', + data: note, + baseData: note, + serverUpdatedAt: initialTime, + isDirty: false, + ), + ); + await repository.setLastSyncTime(initialTime); + + // 2. Mock Server State + // Incremental Pull: Returns nothing (no updates) + when(mockCollection.getFullList(filter: anyNamed('filter'))) + .thenAnswer((_) async => []); + + // Full ID List Pull (Reconciliation): Returns empty list (Item n1 is GONE) + when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => []); + + // 3. Sync + await manager.sync(); + + // 4. Verify Local Deletion + final localRecord = await repository.get('n1'); + expect(localRecord, isNull, + reason: + "Record should be deleted locally because it is missing on server"); + }); +} diff --git a/packages/web_file_system/example/README.md b/packages/web_file_system/example/README.md new file mode 100644 index 0000000..5ccd2ed --- /dev/null +++ b/packages/web_file_system/example/README.md @@ -0,0 +1,7 @@ +# Example + +Run: + +```bash +flutter run --web-header=Cross-Origin-Opener-Policy=same-origin --web-header=Cross-Origin-Embedder-Policy=require-corp -d chrome +``` \ No newline at end of file diff --git a/packages/web_file_system/example/lib/main.dart b/packages/web_file_system/example/lib/main.dart new file mode 100644 index 0000000..1e0a6ba --- /dev/null +++ b/packages/web_file_system/example/lib/main.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:web_file_system/web_file_system.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Web File System Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const FileSystemDemo(), + ); + } +} + +class FileSystemDemo extends StatefulWidget { + const FileSystemDemo({super.key}); + + @override + State createState() => _FileSystemDemoState(); +} + +class _FileSystemDemoState extends State { + final WebFileSystem _fs = WebFileSystem(); + final List _logs = []; + String _currentPath = '/'; + List _files = []; + + @override + void initState() { + super.initState(); + _refreshFiles(); + } + + void _log(String message) { + setState(() { + _logs.add('${DateTime.now().toIso8601String()}: $message'); + // Keep last 50 logs + if (_logs.length > 50) { + _logs.removeAt(0); + } + }); + } + + Future _refreshFiles() async { + try { + final dir = _fs.directory(_currentPath); + if (await dir.exists()) { + final files = await dir.list().toList(); + setState(() { + _files = files; + }); + } else { + // Create root if missing (should be auto-created by service but just in case) + if (_currentPath == '/') { + // Root always exists virtually in our logic, but let's Ensure + } + } + } catch (e) { + _log('Error listing: $e'); + } + } + + Future _createFile() async { + try { + final name = 'test_${DateTime.now().millisecondsSinceEpoch}.txt'; + final file = _fs.file('$_currentPath/$name'); + await file.writeAsString('Hello Web FS at ${DateTime.now()}'); + _log('Created file: $name'); + await _refreshFiles(); + } catch (e) { + _log('Error creating file: $e'); + } + } + + Future _createDir() async { + try { + final name = 'dir_${DateTime.now().millisecondsSinceEpoch}'; + await _fs.directory('$_currentPath/$name').create(); + _log('Created dir: $name'); + await _refreshFiles(); + } catch (e) { + _log('Error creating dir: $e'); + } + } + + Future _delete(FileSystemEntity entity) async { + try { + await entity.delete(recursive: true); + _log('Deleted: ${entity.basename}'); + await _refreshFiles(); + } catch (e) { + _log('Error deleting: $e'); + } + } + + Future _getSize(FileSystemEntity entity) async { + if (entity is File) { + return await entity.length(); + } else if (entity is Directory) { + int total = 0; + try { + await for (final child in entity.list(recursive: true)) { + if (child is File) { + total += await child.length(); + } + } + } catch (e) { + // ignore errors + } + return total; + } + return 0; + } + + String _formatBytes(int bytes) { + const suffixes = ['B', 'KB', 'MB', 'GB']; + var i = 0; + double size = bytes.toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return '${size.toStringAsFixed(1)} ${suffixes[i]}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Web File System ($_currentPath)'), + actions: [ + // Refresh icon + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh), + onPressed: _refreshFiles, + ), + ], + ), + body: Column( + children: [ + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _createFile, child: const Text('New File')), + ElevatedButton( + onPressed: _createDir, child: const Text('New Directory')), + if (_currentPath != '/') + ElevatedButton( + onPressed: () { + setState(() { + _currentPath = _fs.path.dirname(_currentPath); + }); + _refreshFiles(); + }, + child: const Text('Go Up')), + ], + ), + const Divider(), + Expanded( + flex: 2, + child: ListView.builder( + itemCount: _files.length, + itemBuilder: (context, index) { + final entity = _files[index]; + return ListTile( + leading: Icon( + entity is Directory ? Icons.folder : Icons.description, + color: entity is Directory ? Colors.amber : Colors.blue, + ), + title: Text(entity.basename), + subtitle: FutureBuilder( + future: _getSize(entity), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text(_formatBytes(snapshot.data!)); + } + return const Text('Loading...'); + }), + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _delete(entity), + ), + onTap: () { + if (entity is Directory) { + setState(() { + _currentPath = entity.path; + }); + _refreshFiles(); + } else if (entity is File) { + entity.readAsString().then((content) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(entity.basename), + content: SingleChildScrollView( + child: Text(content)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close')) + ], + )); + }); + } + }, + ); + }, + ), + ), + const Divider(), + const Text('Logs:', style: TextStyle(fontWeight: FontWeight.bold)), + Expanded( + flex: 1, + child: Container( + color: Colors.black12, + child: ListView.builder( + itemCount: _logs.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.all(4.0), + child: Text(_logs[_logs.length - 1 - index], + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + )), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/web_file_system/example/pubspec.yaml b/packages/web_file_system/example/pubspec.yaml new file mode 100644 index 0000000..94ea991 --- /dev/null +++ b/packages/web_file_system/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: example +description: Demo for web_file_system +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + web_file_system: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^3.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/web_file_system/example/web/index.html b/packages/web_file_system/example/web/index.html new file mode 100644 index 0000000..90f60fb --- /dev/null +++ b/packages/web_file_system/example/web/index.html @@ -0,0 +1,15 @@ + + + + + + + Web File System Demo + + + + + + + + \ No newline at end of file diff --git a/packages/web_file_system/example/web/manifest.json b/packages/web_file_system/example/web/manifest.json new file mode 100644 index 0000000..4e12926 --- /dev/null +++ b/packages/web_file_system/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Web File System", + "short_name": "Web File System", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/packages/web_file_system/lib/src/backend/idb_inode_service.dart b/packages/web_file_system/lib/src/backend/idb_inode_service.dart new file mode 100644 index 0000000..758e007 --- /dev/null +++ b/packages/web_file_system/lib/src/backend/idb_inode_service.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'package:web/web.dart' as web; + +extension type InodeJS._(JSObject _) implements JSObject { + external String get id; + external set id(String value); + + external String get parentId; + external set parentId(String value); + + external String get name; + external set name(String value); + + external int get nodeType; + external set nodeType(int value); + + external String? get blobId; + external set blobId(String? value); + + external int get size; + external set size(int value); + + external int get modified; + external set modified(int value); + + factory InodeJS({ + required String id, + required String parentId, + required String name, + required int nodeType, + String? blobId, + int size = 0, + required int modified, + }) { + final obj = JSObject() as InodeJS; + obj.id = id; + obj.parentId = parentId; + obj.name = name; + obj.nodeType = nodeType; + obj.blobId = blobId; + obj.size = size; + obj.modified = modified; + return obj; + } +} + +class Inode { + final String id; + final String parentId; + final String name; + final int nodeType; + final String? blobId; + final int size; + final int modified; + + Inode({ + required this.id, + required this.parentId, + required this.name, + required this.nodeType, + this.blobId, + this.size = 0, + required this.modified, + }); + + InodeJS toJS() { + return InodeJS( + id: id, + parentId: parentId, + name: name, + nodeType: nodeType, + blobId: blobId, + size: size, + modified: modified, + ); + } + + static Inode fromJS(InodeJS js) { + return Inode( + id: js.id, + parentId: js.parentId, + name: js.name, + nodeType: js.nodeType, + blobId: js.blobId, + size: js.size, + modified: js.modified, + ); + } +} + +class IdbInodeService { + static const String _dbName = 'WebFileSystemDB'; + static const int _version = 1; + static const String _storeName = 'inodes'; + + web.IDBDatabase? _db; + final Completer _initCompleter = Completer(); + static const String rootId = '00000000-0000-0000-0000-000000000000'; + + Future _ensureReady() async { + if (_db != null) return; + if (_initCompleter.isCompleted) return _initCompleter.future; + + final request = web.window.indexedDB.open(_dbName, _version); + + request.onupgradeneeded = (web.IDBVersionChangeEvent event) { + final db = + (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase; + if (!db.objectStoreNames.contains(_storeName)) { + final store = db.createObjectStore( + _storeName, + web.IDBObjectStoreParameters(keyPath: 'id'.toJS), + ); + store.createIndex( + 'parentId', 'parentId'.toJS, web.IDBIndexParameters(unique: false)); + store.createIndex('parent_name', ['parentId'.toJS, 'name'.toJS].toJS, + web.IDBIndexParameters(unique: true)); + } + }.toJS; + + final completer = Completer(); + + request.onsuccess = (web.Event event) { + _db = (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase; + _ensureRootExists().then((_) { + if (!_initCompleter.isCompleted) completer.complete(); + }).catchError((e) { + if (!_initCompleter.isCompleted) completer.completeError(e); + }); + }.toJS; + + request.onerror = (web.Event event) { + if (!_initCompleter.isCompleted) + completer.completeError(Exception('Failed to open IDB')); + }.toJS; + + return completer.future; + } + + Future _ensureRootExists() async { + try { + await getInode(rootId); + } catch (_) { + await createInode(Inode( + id: rootId, + parentId: 'null', + name: '', + nodeType: 1, + modified: DateTime.now().millisecondsSinceEpoch, + )); + } + } + + Future createInode(Inode inode) async { + if (_db == null) await _ensureReady(); + final transaction = _db!.transaction( + _storeName.toJS, 'readwrite'.toJS as web.IDBTransactionMode); + final store = transaction.objectStore(_storeName); + final request = store.put(inode.toJS()); + await _requestToFuture(request); + } + + Future updateInode(Inode inode) async { + await createInode(inode); + } + + Future deleteInode(String id) async { + await _ensureReady(); + final transaction = _db!.transaction( + _storeName.toJS, 'readwrite'.toJS as web.IDBTransactionMode); + final store = transaction.objectStore(_storeName); + final request = store.delete(id.toJS); + await _requestToFuture(request); + } + + Future getInode(String id) async { + if (_db == null) await _ensureReady(); + + final transaction = _db!.transaction( + _storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode); + final store = transaction.objectStore(_storeName); + final request = store.get(id.toJS); + final result = await _requestToFuture(request); + if (result == null) throw Exception('Inode $id not found'); + + return Inode.fromJS(result as InodeJS); + } + + Future getChild(String parentId, String name) async { + await _ensureReady(); + final transaction = _db!.transaction( + _storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode); + final store = transaction.objectStore(_storeName); + final index = store.index('parent_name'); + final key = JSArray(); + key.add(parentId.toJS); + key.add(name.toJS); + + final request = index.get(key); + + try { + final result = await _requestToFuture(request); + if (result == null) return null; + return Inode.fromJS(result as InodeJS); + } catch (e) { + return null; + } + } + + Future> listChildren(String parentId) async { + await _ensureReady(); + final transaction = _db!.transaction( + _storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode); + final store = transaction.objectStore(_storeName); + final index = store.index('parentId'); + final request = index.getAll(parentId.toJS); + + final result = await _requestToFuture(request); + final list = (result as JSArray).toDart; + return list.map((item) => Inode.fromJS(item as InodeJS)).toList(); + } + + Future _requestToFuture(web.IDBRequest request) { + final completer = Completer(); + request.onsuccess = (web.Event e) { + completer.complete((e.target as web.IDBRequest).result); + }.toJS; + request.onerror = (web.Event e) { + completer.completeError(Exception('IDB Error')); + }.toJS; + return completer.future; + } +} diff --git a/packages/web_file_system/lib/src/backend/opfs_block_store.dart b/packages/web_file_system/lib/src/backend/opfs_block_store.dart new file mode 100644 index 0000000..ab262c8 --- /dev/null +++ b/packages/web_file_system/lib/src/backend/opfs_block_store.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; +import 'package:web/web.dart' as web; +import 'package:uuid/uuid.dart'; + +class OpfsBlockStore { + static const String _blocksDirName = '.blocks'; + web.FileSystemDirectoryHandle? _blocksDir; + final Uuid _uuid = Uuid(); + + Future _ensureReady() async { + if (_blocksDir != null) return; + + final web.StorageManager? storage = web.window.navigator.storage; + if (storage == null) { + throw UnsupportedError('StorageManager not supported'); + } + + final root = await storage.getDirectory().toDart; + _blocksDir = await root + .getDirectoryHandle( + _blocksDirName, + web.FileSystemGetDirectoryOptions(create: true), + ) + .toDart; + } + + Future writeBlob(Stream> stream) async { + await _ensureReady(); + final blockId = _uuid.v4(); + + final fileHandle = await _blocksDir! + .getFileHandle( + blockId, + web.FileSystemGetFileOptions(create: true), + ) + .toDart; + + final writable = await fileHandle.createWritable().toDart; + + try { + await for (final chunk in stream) { + final uint8 = Uint8List.fromList(chunk); + await writable.write(uint8.toJS).toDart; + } + await writable.close().toDart; + } catch (e) { + try { + await writable.abort().toDart; + await _blocksDir!.removeEntry(blockId).toDart; + } catch (_) {} + rethrow; + } + + return blockId; + } + + Stream> readBlob(String blockId) async* { + await _ensureReady(); + try { + final fileHandle = await _blocksDir! + .getFileHandle( + blockId, + ) + .toDart; + + final file = await fileHandle.getFile().toDart; + final web.Blob blob = file; + final reader = + blob.stream().getReader() as web.ReadableStreamDefaultReader; + + while (true) { + final result = await reader.read().toDart; + if (result.done) break; + // Cast to JSUint8Array (via package:web assumption or direct JSObject) + final chunk = result.value as JSUint8Array; + yield chunk.toDart; + } + } catch (e) { + rethrow; + } + } + + Future deleteBlob(String blockId) async { + await _ensureReady(); + try { + await _blocksDir!.removeEntry(blockId).toDart; + } catch (e) { + // Ignore if not found + } + } +} diff --git a/packages/web_file_system/lib/src/entities/web_directory.dart b/packages/web_file_system/lib/src/entities/web_directory.dart new file mode 100644 index 0000000..b4ddcc0 --- /dev/null +++ b/packages/web_file_system/lib/src/entities/web_directory.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'package:file/file.dart'; +import 'package:web_file_system/src/backend/idb_inode_service.dart'; +import '../web_file_system.dart'; +import 'web_file.dart'; + +class WebDirectory extends FileSystemEntity implements Directory { + final WebFileSystem _fs; + @override + final String path; + + WebDirectory(this._fs, this.path); + + @override + FileSystem get fileSystem => _fs; + + @override + Uri get uri => Uri.parse(path); + + @override + Future create({bool recursive = false}) async { + if (await exists()) return this; + + final parentPath = _fs.path.dirname(path); + final name = _fs.path.basename(path); + + if (recursive) { + await _createRecursiveSafe(path); + return this; + } + + // Validate parent exists (handled by resolvepath usually throwing, or we must check) + // We assume parent must exist if not recursive. + final parentInode = await _fs.resolvepath(parentPath); // throws if missing + + await _fs.idb.createInode(Inode( + id: _fs.uuid.v4(), + parentId: parentInode.id, + name: name, + nodeType: 1, // Directory + modified: DateTime.now().millisecondsSinceEpoch, + )); + + return this; + } + + Future _createRecursiveSafe(String p) async { + if (p == '/' || p == '.') return; + if (await _fs.type(p) != FileSystemEntityType.notFound) return; + + await _createRecursiveSafe(_fs.path.dirname(p)); + final parentVal = await _fs.resolvepath(_fs.path.dirname(p)); + await _fs.idb.createInode(Inode( + id: _fs.uuid.v4(), + parentId: parentVal.id, + name: _fs.path.basename(p), + nodeType: 1, + modified: DateTime.now().millisecondsSinceEpoch)); + } + + @override + void createSync({bool recursive = false}) { + throw UnsupportedError('Sync create not supported'); + } + + @override + Future createTemp([String? prefix]) async { + final name = (prefix ?? 'temp') + _fs.uuid.v4(); + final tempDir = _fs.path.join(path, name); + // Ensure path exists + if (!await exists()) { + throw FileSystemException( + 'Directory does not exist', path, const OSError('ENOENT', 2)); + } + final dir = WebDirectory(_fs, tempDir); + await dir.create(); + return dir; + } + + @override + Directory createTempSync([String? prefix]) { + throw UnsupportedError('Sync not supported'); + } + + @override + Future delete({bool recursive = false}) async { + final inode = await _fs.resolvepath(path); + + final children = await _fs.idb.listChildren(inode.id); + if (children.isNotEmpty && !recursive) { + throw FileSystemException( + 'Directory not empty', path, const OSError('ENOTEMPTY', 39)); + } + + if (recursive) { + for (final child in children) { + final childPath = _fs.path.join(path, child.name); + if (child.nodeType == 1) { + await _fs.directory(childPath).delete(recursive: true); + } else { + await _fs.file(childPath).delete(); + } + } + } + + await _fs.idb.deleteInode(inode.id); + return this; + } + + @override + void deleteSync({bool recursive = false}) => + throw UnsupportedError('Sync not supported'); + + @override + Future exists() async { + try { + final inode = await _fs.resolvepath(path); + return inode.nodeType == 1; + } catch (_) { + return false; + } + } + + @override + bool existsSync() => throw UnsupportedError('Sync not supported'); + + @override + Stream list( + {bool recursive = false, bool followLinks = true}) async* { + if (!await exists()) { + throw FileSystemException( + 'Directory not found', path, const OSError('ENOENT', 2)); + } + + final inode = await _fs.resolvepath(path); + final children = await _fs.idb.listChildren(inode.id); + + for (final child in children) { + final childPath = _fs.path.join(path, child.name); + if (child.nodeType == 1) { + final dir = WebDirectory(_fs, childPath); + yield dir; + if (recursive) { + yield* dir.list(recursive: true, followLinks: followLinks); + } + } else { + yield WebFile(_fs, childPath); + } + } + } + + @override + List listSync( + {bool recursive = false, bool followLinks = true}) { + throw UnsupportedError('Sync list not supported'); + } + + @override + Future rename(String newPath) async { + final inode = await _fs.resolvepath(path); + final newParentDir = _fs.path.dirname(newPath); + final newName = _fs.path.basename(newPath); + + final newParentInode = await _fs.resolvepath(newParentDir); + + final updated = Inode( + id: inode.id, + parentId: newParentInode.id, + name: newName, + nodeType: inode.nodeType, + blobId: inode.blobId, + size: inode.size, + modified: DateTime.now().millisecondsSinceEpoch); + + await _fs.idb.updateInode(updated); + return WebDirectory(_fs, newPath); + } + + @override + Directory renameSync(String newPath) => + throw UnsupportedError('Sync not supported'); + + @override + String get basename => _fs.path.basename(path); + + @override + String get dirname => _fs.path.dirname(path); + + @override + Directory get parent => _fs.directory(dirname); + + @override + bool get isAbsolute => _fs.path.isAbsolute(path); + + @override + Directory get absolute => WebDirectory(_fs, _fs.path.absolute(path)); + + @override + Future stat() => _fs.stat(path); + + @override + FileStat statSync() => throw UnsupportedError('Sync not supported'); + + @override + Future resolveSymbolicLinks() async => path; + + @override + String resolveSymbolicLinksSync() => + throw UnsupportedError('Sync not supported'); + + @override + Stream watch( + {int events = FileSystemEvent.all, bool recursive = false}) { + return const Stream.empty(); + } + + @override + Directory childDirectory(String basename) => + _fs.directory(_fs.path.join(path, basename)); + + @override + File childFile(String basename) => _fs.file(_fs.path.join(path, basename)); + + @override + Link childLink(String basename) => _fs.link(_fs.path.join(path, basename)); +} diff --git a/packages/web_file_system/lib/src/entities/web_file.dart b/packages/web_file_system/lib/src/entities/web_file.dart new file mode 100644 index 0000000..0e0d2ed --- /dev/null +++ b/packages/web_file_system/lib/src/entities/web_file.dart @@ -0,0 +1,442 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:file/file.dart'; +import 'package:web_file_system/src/backend/idb_inode_service.dart'; +import '../web_file_system.dart'; + +class WebFile extends FileSystemEntity implements File { + final WebFileSystem _fs; + @override + final String path; + + WebFile(this._fs, this.path); + + @override + FileSystem get fileSystem => _fs; + + @override + Future create({bool recursive = false, bool exclusive = false}) async { + if (await exists()) { + if (exclusive) { + throw FileSystemException( + 'File already exists', + path, + const OSError('EEXIST', 17), + ); + } + return this; + } + + if (recursive) { + final parentDir = _fs.path.dirname(path); + if (await _fs.type(parentDir) == FileSystemEntityType.notFound) { + await _fs.directory(parentDir).create(recursive: true); + } + } + + final parentPath = _fs.path.dirname(path); + final parentInode = await _fs.resolvepath(parentPath); + + await _fs.idb.createInode( + Inode( + id: _fs.uuid.v4(), + parentId: parentInode.id, + name: _fs.path.basename(path), + nodeType: 0, + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + + return this; + } + + @override + void createSync({bool recursive = false, bool exclusive = false}) => + throw UnsupportedError('Sync not supported'); + + @override + Future copy(String newPath) async { + final inode = await _fs.resolvepath(path); + final newParent = await _fs.resolvepath(_fs.path.dirname(newPath)); + + await _fs.idb.createInode( + Inode( + id: _fs.uuid.v4(), + parentId: newParent.id, + name: _fs.path.basename(newPath), + nodeType: 0, + blobId: inode.blobId, + size: inode.size, + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + + return WebFile(_fs, newPath); + } + + @override + File copySync(String newPath) => throw UnsupportedError('Sync not supported'); + + @override + Future length() async { + final inode = await _fs.resolvepath(path); + return inode.size; + } + + @override + int lengthSync() => throw UnsupportedError('Sync not supported'); + + @override + Future lastModified() async { + final inode = await _fs.resolvepath(path); + return DateTime.fromMillisecondsSinceEpoch(inode.modified); + } + + @override + DateTime lastModifiedSync() => throw UnsupportedError('Sync not supported'); + + @override + Future lastAccessed() async { + return lastModified(); + } + + @override + DateTime lastAccessedSync() => throw UnsupportedError('Sync not supported'); + + @override + Future setLastAccessed(DateTime time) async {} + + @override + void setLastAccessedSync(DateTime time) => + throw UnsupportedError('Sync not supported'); + + @override + Future setLastModified(DateTime time) async { + final inode = await _fs.resolvepath(path); + await _fs.idb.updateInode( + Inode( + id: inode.id, + parentId: inode.parentId, + name: inode.name, + nodeType: inode.nodeType, + blobId: inode.blobId, + size: inode.size, + modified: time.millisecondsSinceEpoch, + ), + ); + } + + @override + void setLastModifiedSync(DateTime time) => + throw UnsupportedError('Sync not supported'); + + @override + Future open({FileMode mode = FileMode.read}) async { + throw UnsupportedError( + 'RandomAccessFile not supported on web (use streams)', + ); + } + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) => + throw UnsupportedError('Sync not supported'); + + @override + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) async { + final stream = Stream.value(bytes); + final newBlobId = await _fs.opfs.writeBlob(stream); + + Inode inode; + try { + inode = await _fs.resolvepath(path); + if (mode == FileMode.append) { + throw UnsupportedError('Append not yet optimized'); + } + } catch (_) { + await create(recursive: true); + inode = await _fs.resolvepath(path); + } + + await _fs.idb.updateInode( + Inode( + id: inode.id, + parentId: inode.parentId, + name: inode.name, + nodeType: inode.nodeType, + blobId: newBlobId, + size: bytes.length, + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + + return this; + } + + @override + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) { + throw UnsupportedError('Sync not supported'); + } + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) async { + return writeAsBytes(encoding.encode(contents), mode: mode, flush: flush); + } + + @override + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + throw UnsupportedError('Sync not supported'); + } + + @override + Stream> openRead([int? start, int? end]) async* { + final inode = await _fs.resolvepath(path); + if (inode.blobId == null) return; + + yield* _fs.opfs.readBlob(inode.blobId!); + } + + @override + IOSink openWrite({FileMode mode = FileMode.write, Encoding encoding = utf8}) { + final controller = StreamController>(); + + // Start background write but keep future to await in close() + final writeFuture = _handleWrite(controller.stream, encoding, mode); + + final sink = _WebIOSink( + controller, + encoding, + onDone: () async { + await writeFuture; + }, + ); + + return sink; + } + + Future _handleWrite( + Stream> stream, + Encoding encoding, + FileMode mode, + ) async { + try { + final newId = await _fs.opfs.writeBlob(stream); + + Inode inode; + try { + inode = await _fs.resolvepath(path); + } catch (_) { + // Create if missing + await create(recursive: true); + inode = await _fs.resolvepath(path); + } + + await _fs.idb.updateInode( + Inode( + id: inode.id, + parentId: inode.parentId, + name: inode.name, + nodeType: 0, + blobId: newId, + size: + 0, // TODO: Size not returned by OPFS yet, so 0 for streamed content + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + } catch (e) { + throw FileSystemException('Write failed: $e', path); + } + } + + @override + Future readAsBytes() async { + final chunks = await openRead().toList(); + return Uint8List.fromList(chunks.expand((x) => x).toList()); + } + + @override + Uint8List readAsBytesSync() => throw UnsupportedError('Sync not supported'); + + @override + Future readAsString({Encoding encoding = utf8}) async { + final bytes = await readAsBytes(); + return encoding.decode(bytes); + } + + @override + String readAsStringSync({Encoding encoding = utf8}) => + throw UnsupportedError('Sync not supported'); + + @override + Future> readAsLines({Encoding encoding = utf8}) async { + final str = await readAsString(encoding: encoding); + return str.split('\n'); + } + + @override + List readAsLinesSync({Encoding encoding = utf8}) => + throw UnsupportedError('Sync not supported'); + + @override + Future exists() async { + try { + final inode = await _fs.resolvepath(path); + return inode.nodeType == 0; + } catch (_) { + return false; + } + } + + @override + bool existsSync() => throw UnsupportedError('Sync not supported'); + + @override + Future rename(String newPath) async { + final inode = await _fs.resolvepath(path); + final newParentDir = _fs.path.dirname(newPath); + final newName = _fs.path.basename(newPath); + final newParentInode = await _fs.resolvepath(newParentDir); + + final updated = Inode( + id: inode.id, + parentId: newParentInode.id, + name: newName, + nodeType: inode.nodeType, + blobId: inode.blobId, + size: inode.size, + modified: DateTime.now().millisecondsSinceEpoch, + ); + + await _fs.idb.updateInode(updated); + return WebFile(_fs, newPath); + } + + @override + File renameSync(String newPath) => + throw UnsupportedError('Sync not supported'); + + @override + Future delete({bool recursive = false}) async { + final inode = await _fs.resolvepath(path); + await _fs.idb.deleteInode(inode.id); + return this; + } + + @override + void deleteSync({bool recursive = false}) => + throw UnsupportedError('Sync not supported'); + + @override + Future stat() => _fs.stat(path); + + @override + FileStat statSync() => throw UnsupportedError('Sync not supported'); + + @override + Uri get uri => Uri.parse(path); + @override + String get basename => _fs.path.basename(path); + @override + String get dirname => _fs.path.dirname(path); + @override + Directory get parent => _fs.directory(dirname); + @override + bool get isAbsolute => _fs.path.isAbsolute(path); + @override + File get absolute => WebFile(_fs, _fs.path.absolute(path)); + + @override + Future resolveSymbolicLinks() async => path; + + @override + String resolveSymbolicLinksSync() => + throw UnsupportedError('Sync not supported'); + + @override + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) { + return const Stream.empty(); + } +} + +class _WebIOSink implements IOSink { + final StreamController> _controller; + final Future Function()? onDone; + Encoding _encoding; + + _WebIOSink(this._controller, this._encoding, {this.onDone}); + + @override + Encoding get encoding => _encoding; + + @override + set encoding(Encoding value) => _encoding = value; + + @override + void add(List data) { + _controller.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _controller.addError(error, stackTrace); + } + + @override + Future addStream(Stream> stream) { + return _controller.addStream(stream); + } + + @override + Future close() async { + await _controller.close(); + if (onDone != null) await onDone!(); + } + + @override + Future get done => _controller.done; + + @override + Future flush() async {} + + @override + void write(Object? object) { + add(encoding.encode(object.toString())); + } + + @override + void writeAll(Iterable objects, [String separator = ""]) { + write(objects.join(separator)); + } + + @override + void writeCharCode(int charCode) { + add([charCode]); + } + + @override + void writeln([Object? object = ""]) { + write(object); + write('\n'); + } +} diff --git a/packages/web_file_system/lib/src/entities/web_link.dart b/packages/web_file_system/lib/src/entities/web_link.dart new file mode 100644 index 0000000..a5f3df4 --- /dev/null +++ b/packages/web_file_system/lib/src/entities/web_link.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:file/file.dart'; +import 'package:web_file_system/src/backend/idb_inode_service.dart'; +import '../web_file_system.dart'; + +class WebLink extends FileSystemEntity implements Link { + final WebFileSystem _fs; + @override + final String path; + + WebLink(this._fs, this.path); + + @override + FileSystem get fileSystem => _fs; + + @override + Future create(String target, {bool recursive = false}) async { + if (await exists()) { + // Should throw if exists? Standard create throws if already exists usually unless overwrite logic applied? + // create(recursive) usually implies ensuring parent exists. + throw FileSystemException( + 'Link already exists', + path, + const OSError('EEXIST', 17), + ); + } + + if (recursive) { + final parentDir = _fs.path.dirname(path); + if (await _fs.type(parentDir) == FileSystemEntityType.notFound) { + await _fs.directory(parentDir).create(recursive: true); + } + } + + // Write target path string to OPFS blob + final stream = Stream.value(utf8.encode(target)); + final blobId = await _fs.opfs.writeBlob(stream); + + final parentPath = _fs.path.dirname(path); + final parentInode = await _fs.resolvepath(parentPath); + + await _fs.idb.createInode( + Inode( + id: _fs.uuid.v4(), + parentId: parentInode.id, + name: _fs.path.basename(path), + nodeType: 2, // Link + blobId: blobId, + size: target.length, + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + + return this; + } + + @override + void createSync(String target, {bool recursive = false}) { + throw UnsupportedError('Sync not supported'); + } + + @override + Future update(String target) async { + final inode = await _fs.resolvepath(path, followLinks: false); + + // Write new blob + final stream = Stream.value(utf8.encode(target)); + final blobId = await _fs.opfs.writeBlob(stream); + + await _fs.idb.updateInode( + Inode( + id: inode.id, + parentId: inode.parentId, + name: inode.name, + nodeType: 2, + blobId: blobId, + size: target.length, + modified: DateTime.now().millisecondsSinceEpoch, + ), + ); + + return this; + } + + @override + void updateSync(String target) { + throw UnsupportedError('Sync not supported'); + } + + @override + Future target() async { + final inode = await _fs.resolvepath(path, followLinks: false); + if (inode.nodeType != 2) { + throw FileSystemException( + 'Not a link', + path, + const OSError('EINVAL', 22), + ); + } + if (inode.blobId == null) return ''; + + final bytesList = await _fs.opfs.readBlob(inode.blobId!).toList(); + final bytes = bytesList.expand((x) => x).toList(); + return utf8.decode(bytes); + } + + @override + String targetSync() { + throw UnsupportedError('Sync not supported'); + } + + @override + Future rename(String newPath) async { + final inode = await _fs.resolvepath(path, followLinks: false); + final newParentDir = _fs.path.dirname(newPath); + final newName = _fs.path.basename(newPath); + final newParentInode = await _fs.resolvepath(newParentDir); + + final updated = Inode( + id: inode.id, + parentId: newParentInode.id, + name: newName, + nodeType: inode.nodeType, + blobId: inode.blobId, + size: inode.size, + modified: DateTime.now().millisecondsSinceEpoch, + ); + + await _fs.idb.updateInode(updated); + return WebLink(_fs, newPath); + } + + @override + Link renameSync(String newPath) => + throw UnsupportedError('Sync not supported'); + + @override + Future delete({bool recursive = false}) async { + final inode = await _fs.resolvepath(path, followLinks: false); + await _fs.idb.deleteInode(inode.id); + return this; + } + + @override + void deleteSync({bool recursive = false}) => + throw UnsupportedError('Sync not supported'); + + @override + Future exists() async { + try { + final inode = await _fs.resolvepath(path, followLinks: false); + return inode.nodeType == 2; + } catch (_) { + return false; + } + } + + @override + bool existsSync() => throw UnsupportedError('Sync not supported'); + + @override + Future stat() async => (await _fs.stat(path)); + + @override + FileStat statSync() => throw UnsupportedError('Sync not supported'); + + @override + Uri get uri => Uri.parse(path); + + @override + String get basename => _fs.path.basename(path); + + @override + String get dirname => _fs.path.dirname(path); + + @override + Directory get parent => _fs.directory(dirname); + + @override + bool get isAbsolute => _fs.path.isAbsolute(path); + + @override + Link get absolute => WebLink(_fs, _fs.path.absolute(path)); + + @override + Future resolveSymbolicLinks() async { + // If we are a link, return target? No, resolveSymbolicLinks follows all the way to canonical path. + // For now, simpler: resolve path logic. + final targetPath = await target(); + // If target is relative, resolve against directory. This gets complex. + // MVP: Return target path raw? No, contract says "path with all symbolic links resolved". + // This requires full traversal logic. + // For MVP just return the path as we stored it if it's absolute, or join if relative. + if (_fs.path.isAbsolute(targetPath)) return targetPath; + return _fs.path.normalize(_fs.path.join(dirname, targetPath)); + } + + @override + String resolveSymbolicLinksSync() => + throw UnsupportedError('Sync not supported'); + + @override + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) { + return const Stream.empty(); + } +} diff --git a/packages/web_file_system/lib/src/web_file_system.dart b/packages/web_file_system/lib/src/web_file_system.dart new file mode 100644 index 0000000..b54b441 --- /dev/null +++ b/packages/web_file_system/lib/src/web_file_system.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; +import 'package:web_file_system/src/backend/idb_inode_service.dart'; +import 'package:web_file_system/src/backend/opfs_block_store.dart'; +import 'entities/web_directory.dart'; +import 'entities/web_file.dart'; +import 'entities/web_link.dart'; + +class WebFileSystem extends FileSystem { + final IdbInodeService _idb = IdbInodeService(); + final OpfsBlockStore _opfs = OpfsBlockStore(); + final Uuid _uuid = Uuid(); + + // Public matchers for internal use + IdbInodeService get idb => _idb; + OpfsBlockStore get opfs => _opfs; + Uuid get uuid => _uuid; + + WebFileSystem(); + + @override + Directory directory(path) => WebDirectory(this, getPath(path)); + + @override + File file(path) => WebFile(this, getPath(path)); + + @override + Link link(path) => WebLink(this, getPath(path)); + + @override + p.Context get path => p.Context(style: p.Style.posix); + + @override + Directory get currentDirectory => directory('/'); + + @override + set currentDirectory(dynamic path) { + throw UnsupportedError('Changing CWD not supported on web'); + } + + @override + Directory get systemTempDirectory => directory('/tmp'); + + @override + Future type( + String path, { + bool followLinks = true, + }) async { + try { + final inode = await resolvepath(path, followLinks: followLinks); + if (inode.nodeType == 0) return FileSystemEntityType.file; + if (inode.nodeType == 1) return FileSystemEntityType.directory; + if (inode.nodeType == 2) return FileSystemEntityType.link; + return FileSystemEntityType.notFound; + } catch (e) { + return FileSystemEntityType.notFound; + } + } + + @override + FileSystemEntityType typeSync(String path, {bool followLinks = true}) { + throw UnsupportedError('Sync type not supported'); + } + + // Internal Resolution Logic + Future resolvepath(String pathStr, {bool followLinks = true}) async { + final normalized = path.normalize(pathStr); + final parts = path.split(normalized); + // Root is '/' + String currentId = IdbInodeService.rootId; + Inode currentInode = await _idb.getInode(currentId); + + // Common recursion guard + int linkDepth = 0; + const maxLinkDepth = 20; + + for (int i = 0; i < parts.length; i++) { + final part = parts[i]; + if (part.isEmpty || part == '/' || part == '.') continue; + + // Lookup child + final child = await _idb.getChild(currentId, part); + if (child == null) { + throw FileSystemException( + 'No such file or directory', + pathStr, + const OSError('ENOENT', 2), + ); + } + + // If child is Link + if (child.nodeType == 2) { + // If we are at the last part, only follow if followLinks is true + if (i == parts.length - 1 && !followLinks) { + return child; + } + + // Follow link + if (child.blobId != null) { + if (linkDepth++ > maxLinkDepth) { + throw FileSystemException( + 'Too many levels of symbolic links', + pathStr, + const OSError('ELOOP', 40), + ); + } + + // Read target + final bytesList = await _opfs.readBlob(child.blobId!).toList(); + final bytes = bytesList.expand((x) => x).toList(); + final targetPath = utf8.decode(bytes); + + // Resolve target path + // Standard: Relative to directory containing link if not absolute. + String resolvedTarget; + if (path.isAbsolute(targetPath)) { + resolvedTarget = targetPath; + } else { + // Parent path relative to root + final parentParts = parts.take(i).toList(); + // join behaves weirdly with context parts, ensure root if needed + final parentStr = path.joinAll(['/', ...parentParts]); + resolvedTarget = path.normalize(path.join(parentStr, targetPath)); + } + + // Resolve the target inode (ALWAYS follow links when resolving intermediate link targets) + final targetInode = await resolvepath( + resolvedTarget, + followLinks: true, + ); + + // If we have more parts remaining in the original path, we must continue from this target + if (i < parts.length - 1) { + if (targetInode.nodeType != 1) { + throw FileSystemException( + 'Not a directory', + pathStr, + const OSError('ENOTDIR', 20), + ); + } + currentId = targetInode.id; + currentInode = targetInode; + continue; + } else { + // End of path, return target + return targetInode; + } + } + } + + currentId = child.id; + currentInode = child; + } + return currentInode; + } + + String getPath(dynamic path) { + if (path is String) return path; + if (path is FileSystemEntity) return path.path; + if (path is Uri) return path.toFilePath(); + throw ArgumentError('Path must be a String, Uri, or FileSystemEntity'); + } + + @override + Future stat(String path) async { + try { + final inode = await resolvepath(path, followLinks: true); + return FileStatImpl(inode.modified, inode.size, _getType(inode.nodeType)); + } catch (e) { + return FileStatImpl(0, 0, FileSystemEntityType.notFound); + } + } + + @override + FileStat statSync(String path) { + throw UnsupportedError('Sync stat not supported'); + } + + FileSystemEntityType _getType(int nodeType) { + if (nodeType == 0) return FileSystemEntityType.file; + if (nodeType == 1) return FileSystemEntityType.directory; + if (nodeType == 2) return FileSystemEntityType.link; + return FileSystemEntityType.notFound; + } + + @override + bool isFileSync(String path) => + throw UnsupportedError('Sync isFile not supported'); + + @override + bool isDirectorySync(String path) => + throw UnsupportedError('Sync isDirectory not supported'); + + @override + bool isLinkSync(String path) => + throw UnsupportedError('Sync isLink not supported'); + + @override + Future isFile(String path) async => + (await type(path)) == FileSystemEntityType.file; + + @override + Future isDirectory(String path) async => + (await type(path)) == FileSystemEntityType.directory; + + @override + Future isLink(String path) async => + (await type(path, followLinks: false)) == FileSystemEntityType.link; + + bool get isWatchSupported => false; + + @override + Future identical(String path1, String path2) async { + final s1 = await stat(path1); + final s2 = await stat(path2); + if (s1.type == FileSystemEntityType.notFound || + s2.type == FileSystemEntityType.notFound) + return false; + + final i1 = await resolvepath(path1); + final i2 = await resolvepath(path2); + return i1.id == i2.id; + } + + @override + bool identicalSync(String path1, String path2) => + throw UnsupportedError('Sync not supported'); +} + +class FileStatImpl implements FileStat { + final int _modified; + final int _size; + final FileSystemEntityType _type; + + FileStatImpl(this._modified, this._size, this._type); + + @override + DateTime get accessed => DateTime.fromMillisecondsSinceEpoch(_modified); + + @override + DateTime get changed => DateTime.fromMillisecondsSinceEpoch(_modified); + + @override + int get mode => 0; + + @override + DateTime get modified => DateTime.fromMillisecondsSinceEpoch(_modified); + + @override + int get size => _size; + + @override + FileSystemEntityType get type => _type; + + @override + String modeString() => 'rwxrwxrwx'; +} diff --git a/packages/web_file_system/lib/web_file_system.dart b/packages/web_file_system/lib/web_file_system.dart new file mode 100644 index 0000000..74644a9 --- /dev/null +++ b/packages/web_file_system/lib/web_file_system.dart @@ -0,0 +1,5 @@ +/// A high-performance, asynchronous file system for the web. +library web_file_system; + +export 'package:file/file.dart'; +export 'src/web_file_system.dart'; diff --git a/packages/web_file_system/pubspec.yaml b/packages/web_file_system/pubspec.yaml new file mode 100644 index 0000000..d92be0b --- /dev/null +++ b/packages/web_file_system/pubspec.yaml @@ -0,0 +1,22 @@ +name: web_file_system +description: A high-performance, asynchronous file system for the web using IDB and OPFS. +version: 0.0.1 +publish_to: 'none' +homepage: https://github.com/fluttercommunity/flutter_whatsnew +maintainer: Rody Davis (@rodydavis) +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.38.5 + +dependencies: + file: ^7.0.0 + path: ^1.9.0 + web: ^1.1.1 + uuid: ^4.0.0 + mime: ^1.0.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.0 diff --git a/packages/web_file_system/test/web_file_system_test.dart b/packages/web_file_system/test/web_file_system_test.dart new file mode 100644 index 0000000..3862be6 --- /dev/null +++ b/packages/web_file_system/test/web_file_system_test.dart @@ -0,0 +1,165 @@ +@TestOn('browser') +import 'dart:async'; +import 'dart:typed_data'; +import 'package:test/test.dart'; +import 'package:web_file_system/web_file_system.dart'; + +void main() { + late WebFileSystem fs; + + setUp(() async { + // For unit testing in a persistent browser environment, concurrent tests might conflict. + // Ideally we would mock the backend or use unique DB names. + // For NOW, we use the default SINGLE DB implementation but try to use unique paths. + fs = WebFileSystem(); + }); + + group('Functional Correctness', () { + test('Create and read text file', () async { + final file = fs.file( + '/hello_${DateTime.now().millisecondsSinceEpoch}.txt', + ); + await file.create(recursive: true); + await file.writeAsString('Hello Hybrid FS'); + + expect(await file.exists(), isTrue); + expect(await file.readAsString(), equals('Hello Hybrid FS')); + }); + + test('Directory creation and listing', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + await fs.directory('/assets_$uniqueId/images').create(recursive: true); + await fs + .file('/assets_$uniqueId/images/logo.png') + .writeAsString('png-data'); + await fs.file('/assets_$uniqueId/readme.md').writeAsString('read me'); + + final dir = fs.directory('/assets_$uniqueId'); + final entities = await dir.list(recursive: true).toList(); + + expect(entities.length, equals(3)); // images, logo.png, readme.md + // Note: order is not guaranteed usually, but CoW VFS might map order + // Paths checking + final paths = entities.map((e) => e.path).toList(); + expect(paths, contains('/assets_$uniqueId/images')); + expect(paths, contains('/assets_$uniqueId/images/logo.png')); + expect(paths, contains('/assets_$uniqueId/readme.md')); + }); + + test('Rename directory updates child paths', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final folder = '/folder_$uniqueId'; + final renamed = '/renamed_$uniqueId'; + + await fs.directory(folder).create(); + await fs.file('$folder/file.txt').writeAsString('content'); + + await fs.directory(folder).rename(renamed); + + expect(await fs.directory(folder).exists(), isFalse); + expect(await fs.file('$folder/file.txt').exists(), isFalse); + + expect(await fs.directory(renamed).exists(), isTrue); + expect(await fs.file('$renamed/file.txt').exists(), isTrue); + + expect( + await fs.file('$renamed/file.txt').readAsString(), + equals('content'), + ); + }); + + test('Symbolic Link creation and resolution', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final targetPath = '/target_$uniqueId.txt'; + final linkPath = '/link_$uniqueId'; + + // Create target + await fs.file(targetPath).writeAsString('target-content'); + + // Create link + await fs.link(linkPath).create(targetPath); + + // Verify link exists + expect(await fs.link(linkPath).exists(), isTrue); + expect( + await fs.type(linkPath, followLinks: false), + equals(FileSystemEntityType.link), + ); + + // Verify link resolves to target content + expect(await fs.file(linkPath).readAsString(), equals('target-content')); + + // Verify target() + expect(await fs.link(linkPath).target(), equals(targetPath)); + + // Verify traversing through link (directory) + final targetDir = '/dir_$uniqueId'; + final linkDir = '/link_dir_$uniqueId'; + await fs.directory(targetDir).create(); + await fs.file('$targetDir/child.txt').writeAsString('child-content'); + await fs.link(linkDir).create(targetDir); + + expect( + await fs.file('$linkDir/child.txt').readAsString(), + equals('child-content'), + ); + }); + }); + + group('Benchmarks', () { + test('BENCHMARK: Create 100 small files (Inode Stress)', () async { + // Reduced from 1000 for CI stability in this environment + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final count = 100; + final futures = []; + + await fs.directory('/bulk_$uniqueId').create(); + + final stopwatch = Stopwatch()..start(); + for (var i = 0; i < count; i++) { + futures.add( + fs.file('/bulk_$uniqueId/file_$i.txt').writeAsString('small data $i'), + ); + } + await Future.wait(futures); + stopwatch.stop(); + + print('Created $count files in ${stopwatch.elapsedMilliseconds}ms'); + + final listWatch = Stopwatch()..start(); + final files = await fs.directory('/bulk_$uniqueId').list().length; + listWatch.stop(); + + expect(files, equals(count)); + print('Listed $count files in ${listWatch.elapsedMilliseconds}ms'); + }); + + test('BENCHMARK: Read/Write 10MB file (Streaming)', () async { + // Reduced from 50MB to 10MB for quicker test cycle + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final chunkSize = 1024 * 1024; + final chunk = Uint8List(chunkSize); + for (int i = 0; i < chunkSize; i++) chunk[i] = i % 256; + + final file = fs.file('/video_$uniqueId.mp4'); + final sink = file.openWrite(); + + final writeWatch = Stopwatch()..start(); + for (int i = 0; i < 10; i++) { + // 10MB + sink.add(chunk); + } + await sink.close(); + writeWatch.stop(); + + print('Wrote 10MB in ${writeWatch.elapsedMilliseconds}ms'); + + int totalBytes = 0; + await for (final buffer in file.openRead()) { + totalBytes += buffer.length; + } + + expect(totalBytes, equals(10 * chunkSize)); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index bd98a9e..3c88e5b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "92.0.0" + version: "91.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "8.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -41,6 +49,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 + url: "https://pub.dev" + source: hosted + version: "2.10.5" built_collection: dependency: transitive description: @@ -65,6 +105,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -97,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -105,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" dart_style: dependency: transitive description: @@ -113,6 +201,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + drift: + dependency: transitive + description: + name: drift + sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a" + url: "https://pub.dev" + source: hosted + version: "2.30.1" + drift_dev: + dependency: transitive + description: + name: drift_dev + sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f" + url: "https://pub.dev" + source: hosted + version: "2.30.1" fake_async: dependency: transitive description: @@ -121,6 +233,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "24cbd5616f3d4008c335c197bb90bfa0eb43b9e55c6de5c60d1f805092636034" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "3766964cc49ad1d007c261d7acecea61a73581e31fdbd948b446025851766bc8" + url: "https://pub.dev" + source: hosted + version: "1.2.0" ffi: dependency: transitive description: @@ -176,6 +304,22 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glados: + dependency: transitive + description: + name: glados + sha256: c39446c0c4fe83a6ee6ee4fdee5a1afa27050619c7df390bf4c3b77b6f1acb33 + url: "https://pub.dev" + source: hosted + version: "1.1.7" glob: dependency: transitive description: @@ -192,6 +336,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hlc_dart: + dependency: transitive + description: + name: hlc_dart + sha256: dfc02210151e98a42a762df00e669b7e2654614b59ccc664e50ac9573bbacc5e + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_interceptor: + dependency: transitive + description: + name: http_interceptor + sha256: "288c6ded4a2c66de2730a16b30cbd29d05d042a5e61304d9b4be0e16378f4082" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -224,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -256,6 +472,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + mockito: + dependency: transitive + description: + name: mockito + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + url: "https://pub.dev" + source: hosted + version: "5.6.3" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -320,6 +560,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pocketbase: + dependency: transitive + description: + name: pocketbase + sha256: b94e63f00ce29c5f465e8122ae63cdf4e3c37f9b3dc6d04f0dfe6625bcdb8839 + url: "https://pub.dev" + source: hosted + version: "0.23.2" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" pub_semver: dependency: transitive description: @@ -328,6 +592,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" shared_preferences: dependency: transitive description: @@ -384,11 +664,67 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "585bc140f20da42c584ece2df28f4d9ef2566955332b626f655957b3a8c8ad54" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -397,6 +733,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2 + url: "https://pub.dev" + source: hosted + version: "0.43.0" stack_trace: dependency: transitive description: @@ -413,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -429,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" test_api: dependency: transitive description: @@ -437,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" typed_data: dependency: transitive description: @@ -509,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: @@ -541,6 +925,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 66fe264..d73b745 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,4 +9,8 @@ workspace: - packages/flutter_sms - packages/flutter_whatsnew - packages/breakpoint - - packages/app_review \ No newline at end of file + - packages/app_review + - packages/pocketbase_auth + - packages/pocketbase_sync + - packages/web_file_system + - packages/diff_algorithims \ No newline at end of file