Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
293
bricks/internal_memory/hooks/post_gen.dart
Normal file
293
bricks/internal_memory/hooks/post_gen.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:mason/mason.dart';
|
||||
|
||||
/// Adds one persisted field to local storage by injecting the same
|
||||
/// getter/setter pair into all three files that have to agree:
|
||||
///
|
||||
/// ConnectInternalMemory → the contract
|
||||
/// InternalMemory → the implementation (+ its storage key)
|
||||
/// AppDataManager → the delegation
|
||||
///
|
||||
/// Doing this by hand drifts: it's easy to add the pair to two of the three and
|
||||
/// only find out at compile time, and the secure-key constant should never be
|
||||
/// hand-picked.
|
||||
Future<void> run(HookContext context) async {
|
||||
final logger = context.logger;
|
||||
final functionName = (context.vars['function_name'] ?? '') as String;
|
||||
final requestObject = (context.vars['request_object'] ?? '') as String;
|
||||
final secure = (context.vars['secure'] ?? false) as bool;
|
||||
|
||||
if (functionName.isEmpty || requestObject.isEmpty) {
|
||||
logger.err('Both a field name and a type are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
final pName = functionName.pascalCase;
|
||||
final keyConst = '_${functionName.camelCase}Key';
|
||||
final keyValue = secure
|
||||
? _secureKeyValue(functionName)
|
||||
: 'peach.${functionName.snakeCase}';
|
||||
|
||||
final isList = requestObject.startsWith('List<');
|
||||
final isString = requestObject.toLowerCase() == 'string';
|
||||
final baseType = isList
|
||||
? requestObject.substring(5, requestObject.length - 1)
|
||||
: requestObject;
|
||||
|
||||
const memoryDir = 'lib/Peach/memory';
|
||||
const connectPath = '$memoryDir/ConnectInternalMemory.dart';
|
||||
const memoryPath = '$memoryDir/InternalMemory.dart';
|
||||
const managerPath = 'lib/Peach/informatics/AppDataManager.dart';
|
||||
|
||||
// Model types need importing into all three files; String doesn't.
|
||||
String? modelImportFor(String fromFile) =>
|
||||
isString ? null : _relativeImport(baseType, fromFile);
|
||||
|
||||
final duplicate = 'get$pName()';
|
||||
|
||||
// ── 1. the contract ────────────────────────────────────────────────────────
|
||||
final contract = '''
|
||||
Future<$requestObject> get$pName();
|
||||
|
||||
Future set$pName($requestObject data);''';
|
||||
|
||||
_inject(
|
||||
connectPath,
|
||||
contract,
|
||||
duplicate: duplicate,
|
||||
importLine: modelImportFor(connectPath),
|
||||
logger: logger,
|
||||
);
|
||||
|
||||
// ── 2. the implementation ──────────────────────────────────────────────────
|
||||
final read = secure
|
||||
? "await SecretStore.read($keyConst)"
|
||||
: "(await SharedPreferences.getInstance()).getString($keyConst)";
|
||||
final write = secure
|
||||
? (String v) => "await SecretStore.write($keyConst, $v);"
|
||||
: (String v) =>
|
||||
"await (await SharedPreferences.getInstance()).setString($keyConst, $v);";
|
||||
|
||||
String getBody;
|
||||
String setBody;
|
||||
|
||||
if (isString) {
|
||||
getBody = ' return $read ?? \'\';';
|
||||
setBody = ' ${write('data')}';
|
||||
} else if (isList) {
|
||||
getBody = '''
|
||||
final stored = $read;
|
||||
if (stored == null || stored.isEmpty) return [];
|
||||
return (jsonDecode(stored) as List)
|
||||
.map((item) => $baseType.fromJson(item as Map<String, dynamic>))
|
||||
.toList();''';
|
||||
setBody = '''
|
||||
final encoded = jsonEncode(data.map((item) => item.toJson()).toList());
|
||||
${write('encoded')}''';
|
||||
} else {
|
||||
getBody = '''
|
||||
final stored = $read;
|
||||
if (stored == null || stored.isEmpty) return null;
|
||||
return $baseType.fromJson(jsonDecode(stored) as Map<String, dynamic>);''';
|
||||
setBody = '''
|
||||
${write('jsonEncode(data.toJson())')}''';
|
||||
}
|
||||
|
||||
// A single object can legitimately be absent, so its getter is nullable.
|
||||
final returnType = (isString || isList) ? requestObject : '$requestObject?';
|
||||
|
||||
final implementation = '''
|
||||
@override
|
||||
Future<$returnType> get$pName() async {
|
||||
$getBody
|
||||
}
|
||||
|
||||
@override
|
||||
Future set$pName($requestObject data) async {
|
||||
$setBody
|
||||
}''';
|
||||
|
||||
_injectImplementation(
|
||||
memoryPath,
|
||||
keyLine: " static const $keyConst = '$keyValue';",
|
||||
methods: implementation,
|
||||
duplicate: duplicate,
|
||||
importLine: modelImportFor(memoryPath),
|
||||
needsConvert: !isString,
|
||||
needsSecretStore: secure,
|
||||
needsPrefs: !secure,
|
||||
logger: logger,
|
||||
);
|
||||
|
||||
// Keep the contract's nullability in step with the implementation.
|
||||
if (!isString && !isList) {
|
||||
_replaceInFile(
|
||||
connectPath,
|
||||
'Future<$requestObject> get$pName();',
|
||||
'Future<$requestObject?> get$pName();',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. the delegation ──────────────────────────────────────────────────────
|
||||
final delegation = '''
|
||||
@override
|
||||
Future<$returnType> get$pName() => memory.get$pName();
|
||||
|
||||
@override
|
||||
Future set$pName($requestObject data) => memory.set$pName(data);''';
|
||||
|
||||
_inject(
|
||||
managerPath,
|
||||
delegation,
|
||||
duplicate: duplicate,
|
||||
importLine: modelImportFor(managerPath),
|
||||
logger: logger,
|
||||
);
|
||||
|
||||
try {
|
||||
await Process.run('dart', ['format', 'lib/Peach/'], runInShell: true);
|
||||
} catch (_) {
|
||||
// Formatting is a nicety; a missing dart on PATH shouldn't fail the run.
|
||||
}
|
||||
|
||||
logger.success(
|
||||
'✨ $pName wired through ConnectInternalMemory, InternalMemory and '
|
||||
'AppDataManager.');
|
||||
}
|
||||
|
||||
/// A random-suffixed key, so a stored secret isn't sitting under a guessable
|
||||
/// name in the OS keystore.
|
||||
String _secureKeyValue(String field) {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
|
||||
final suffix =
|
||||
base64Url.encode(bytes).replaceAll('=', '').substring(0, 16);
|
||||
return 'peach.${field.snakeCase}.$suffix';
|
||||
}
|
||||
|
||||
/// Finds [className]'s file and builds an import relative to [fromFile] — the
|
||||
/// project uses relative imports inside lib/Peach, not package: ones.
|
||||
String? _relativeImport(String className, String fromFile) {
|
||||
final dir = Directory('lib');
|
||||
if (!dir.existsSync()) return null;
|
||||
|
||||
for (final entity in dir.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final content = entity.readAsStringSync();
|
||||
// Built from raw parts: in a normal string `\s` would collapse to a plain
|
||||
// `s`, quietly turning this into a pattern that never matches.
|
||||
final declaration =
|
||||
RegExp(r'(class|enum)\s+' + RegExp.escape(className) + r'\b');
|
||||
if (!declaration.hasMatch(content)) continue;
|
||||
|
||||
final target = entity.path.replaceAll(r'\', '/');
|
||||
final from = fromFile.replaceAll(r'\', '/');
|
||||
return "import '${_relativePath(from, target)}';";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _relativePath(String fromFile, String toFile) {
|
||||
final fromParts = fromFile.split('/')..removeLast();
|
||||
final toParts = toFile.split('/');
|
||||
var shared = 0;
|
||||
while (shared < fromParts.length &&
|
||||
shared < toParts.length - 1 &&
|
||||
fromParts[shared] == toParts[shared]) {
|
||||
shared++;
|
||||
}
|
||||
final up = List.filled(fromParts.length - shared, '..');
|
||||
final down = toParts.sublist(shared);
|
||||
return [...up, ...down].join('/');
|
||||
}
|
||||
|
||||
/// Appends [content] just before the class's closing brace.
|
||||
void _inject(
|
||||
String path,
|
||||
String content, {
|
||||
required String duplicate,
|
||||
required Logger logger,
|
||||
String? importLine,
|
||||
}) {
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
logger.err('Missing $path — is this being run from the project root?');
|
||||
return;
|
||||
}
|
||||
|
||||
var source = file.readAsStringSync();
|
||||
if (source.contains(duplicate)) {
|
||||
logger.info('$path already has $duplicate — skipped.');
|
||||
return;
|
||||
}
|
||||
|
||||
source = _addImport(source, importLine);
|
||||
|
||||
final close = source.lastIndexOf('}');
|
||||
if (close == -1) return;
|
||||
file.writeAsStringSync(
|
||||
'${source.substring(0, close).trimRight()}\n\n$content\n${source.substring(close)}');
|
||||
}
|
||||
|
||||
/// Like [_inject], but also puts the storage key at the top of the class and
|
||||
/// pulls in whatever the generated body needs.
|
||||
void _injectImplementation(
|
||||
String path, {
|
||||
required String keyLine,
|
||||
required String methods,
|
||||
required String duplicate,
|
||||
required bool needsConvert,
|
||||
required bool needsSecretStore,
|
||||
required bool needsPrefs,
|
||||
required Logger logger,
|
||||
String? importLine,
|
||||
}) {
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
logger.err('Missing $path — is this being run from the project root?');
|
||||
return;
|
||||
}
|
||||
|
||||
var source = file.readAsStringSync();
|
||||
if (source.contains(duplicate)) {
|
||||
logger.info('$path already has $duplicate — skipped.');
|
||||
return;
|
||||
}
|
||||
|
||||
source = _addImport(source, importLine);
|
||||
if (needsConvert) source = _addImport(source, "import 'dart:convert';");
|
||||
if (needsPrefs) {
|
||||
source = _addImport(
|
||||
source, "import 'package:shared_preferences/shared_preferences.dart';");
|
||||
}
|
||||
if (needsSecretStore) {
|
||||
source = _addImport(source, "import 'SecretStore.dart';");
|
||||
}
|
||||
|
||||
final classOpen = source.indexOf('{', source.indexOf('class InternalMemory'));
|
||||
if (classOpen != -1) {
|
||||
source =
|
||||
'${source.substring(0, classOpen + 1)}\n$keyLine\n${source.substring(classOpen + 1)}';
|
||||
}
|
||||
|
||||
final close = source.lastIndexOf('}');
|
||||
if (close == -1) return;
|
||||
file.writeAsStringSync(
|
||||
'${source.substring(0, close).trimRight()}\n\n$methods\n${source.substring(close)}');
|
||||
}
|
||||
|
||||
String _addImport(String source, String? importLine) {
|
||||
if (importLine == null || source.contains(importLine)) return source;
|
||||
return '$importLine\n$source';
|
||||
}
|
||||
|
||||
void _replaceInFile(String path, String from, String to) {
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) return;
|
||||
final source = file.readAsStringSync();
|
||||
if (!source.contains(from)) return;
|
||||
file.writeAsStringSync(source.replaceFirst(from, to));
|
||||
}
|
||||
7
bricks/internal_memory/hooks/pubspec.yaml
Normal file
7
bricks/internal_memory/hooks/pubspec.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
name: internal_memory_hooks
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
mason: ^0.1.0-dev
|
||||
Reference in New Issue
Block a user