205 lines
5.4 KiB
Dart
205 lines
5.4 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
const _uuid = Uuid();
|
|
|
|
/// One technique node in a combo graph.
|
|
class ComboNode {
|
|
const ComboNode({
|
|
required this.id,
|
|
required this.technique,
|
|
this.translation = '',
|
|
required this.dx,
|
|
required this.dy,
|
|
});
|
|
|
|
final String id;
|
|
final String technique;
|
|
final String translation;
|
|
final double dx;
|
|
final double dy;
|
|
|
|
ComboNode copyWith({double? dx, double? dy}) => ComboNode(
|
|
id: id,
|
|
technique: technique,
|
|
translation: translation,
|
|
dx: dx ?? this.dx,
|
|
dy: dy ?? this.dy,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'technique': technique,
|
|
'translation': translation,
|
|
'dx': dx,
|
|
'dy': dy,
|
|
};
|
|
|
|
factory ComboNode.fromJson(Map<String, dynamic> json) => ComboNode(
|
|
id: json['id'] as String,
|
|
technique: json['technique'] as String,
|
|
translation: (json['translation'] ?? '') as String,
|
|
dx: (json['dx'] as num).toDouble(),
|
|
dy: (json['dy'] as num).toDouble(),
|
|
);
|
|
}
|
|
|
|
/// A directed transition between two techniques.
|
|
class ComboEdge {
|
|
const ComboEdge(this.fromId, this.toId);
|
|
|
|
final String fromId;
|
|
final String toId;
|
|
|
|
Map<String, dynamic> toJson() => {'from': fromId, 'to': toId};
|
|
|
|
factory ComboEdge.fromJson(Map<String, dynamic> json) =>
|
|
ComboEdge(json['from'] as String, json['to'] as String);
|
|
}
|
|
|
|
/// A named technique graph: nodes are techniques, edges are the transitions
|
|
/// that make sense between them.
|
|
class Combo {
|
|
const Combo({
|
|
required this.id,
|
|
required this.name,
|
|
this.nodes = const [],
|
|
this.edges = const [],
|
|
});
|
|
|
|
final String id;
|
|
final String name;
|
|
final List<ComboNode> nodes;
|
|
final List<ComboEdge> edges;
|
|
|
|
Combo copyWith({
|
|
String? name,
|
|
List<ComboNode>? nodes,
|
|
List<ComboEdge>? edges,
|
|
}) => Combo(
|
|
id: id,
|
|
name: name ?? this.name,
|
|
nodes: nodes ?? this.nodes,
|
|
edges: edges ?? this.edges,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'nodes': nodes.map((n) => n.toJson()).toList(),
|
|
'edges': edges.map((e) => e.toJson()).toList(),
|
|
};
|
|
|
|
factory Combo.fromJson(Map<String, dynamic> json) => Combo(
|
|
id: json['id'] as String,
|
|
name: json['name'] as String,
|
|
nodes: [
|
|
for (final n in (json['nodes'] as List? ?? []))
|
|
ComboNode.fromJson(n as Map<String, dynamic>),
|
|
],
|
|
edges: [
|
|
for (final e in (json['edges'] as List? ?? []))
|
|
ComboEdge.fromJson(e as Map<String, dynamic>),
|
|
],
|
|
);
|
|
|
|
/// Random walk over the graph — the generated combination to drill.
|
|
/// Follows outgoing edges; if a node is a dead end the walk stops there.
|
|
List<ComboNode> generateSequence({int length = 4, Random? random}) {
|
|
if (nodes.isEmpty) return [];
|
|
final rnd = random ?? Random();
|
|
final byId = {for (final n in nodes) n.id: n};
|
|
final outgoing = <String, List<String>>{};
|
|
for (final e in edges) {
|
|
outgoing.putIfAbsent(e.fromId, () => []).add(e.toId);
|
|
}
|
|
|
|
// Prefer starting somewhere that actually leads on.
|
|
final starts = nodes.where((n) => outgoing.containsKey(n.id)).toList();
|
|
var current = (starts.isEmpty
|
|
? nodes
|
|
: starts)[rnd.nextInt((starts.isEmpty ? nodes : starts).length)];
|
|
|
|
final sequence = <ComboNode>[current];
|
|
while (sequence.length < length) {
|
|
final next = outgoing[current.id];
|
|
if (next == null || next.isEmpty) break;
|
|
current = byId[next[rnd.nextInt(next.length)]]!;
|
|
sequence.add(current);
|
|
}
|
|
return sequence;
|
|
}
|
|
}
|
|
|
|
/// Loads and persists combos as a JSON file in the app documents dir.
|
|
class ComboService extends ChangeNotifier {
|
|
static const _fileName = 'trainhub_combos.json';
|
|
|
|
List<Combo> _combos = [];
|
|
bool _loaded = false;
|
|
|
|
List<Combo> get combos => _combos;
|
|
|
|
Future<File> _file() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return File(p.join(dir.path, _fileName));
|
|
}
|
|
|
|
Future<void> ensureLoaded() async {
|
|
if (_loaded) return;
|
|
_loaded = true;
|
|
try {
|
|
final file = await _file();
|
|
if (!file.existsSync()) return;
|
|
final json = jsonDecode(await file.readAsString()) as List<dynamic>;
|
|
_combos = [
|
|
for (final c in json) Combo.fromJson(c as Map<String, dynamic>),
|
|
];
|
|
notifyListeners();
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('Failed to load combos: $e');
|
|
}
|
|
}
|
|
|
|
Combo create(String name) {
|
|
final combo = Combo(id: _uuid.v4(), name: name);
|
|
_combos = [..._combos, combo];
|
|
notifyListeners();
|
|
_persist();
|
|
return combo;
|
|
}
|
|
|
|
void delete(String id) {
|
|
_combos = _combos.where((c) => c.id != id).toList();
|
|
notifyListeners();
|
|
_persist();
|
|
}
|
|
|
|
/// Replaces a combo. Set [persist] false for high-frequency updates
|
|
/// (node dragging) and call [persistNow] once afterwards.
|
|
void update(Combo combo, {bool persist = true}) {
|
|
_combos = [for (final c in _combos) c.id == combo.id ? combo : c];
|
|
notifyListeners();
|
|
if (persist) _persist();
|
|
}
|
|
|
|
void persistNow() => _persist();
|
|
|
|
Future<void> _persist() async {
|
|
try {
|
|
final file = await _file();
|
|
await file.writeAsString(
|
|
jsonEncode(_combos.map((c) => c.toJson()).toList()),
|
|
);
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('Failed to save combos: $e');
|
|
}
|
|
}
|
|
}
|