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 toJson() => { 'id': id, 'technique': technique, 'translation': translation, 'dx': dx, 'dy': dy, }; factory ComboNode.fromJson(Map 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 toJson() => {'from': fromId, 'to': toId}; factory ComboEdge.fromJson(Map 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 nodes; final List edges; Combo copyWith({ String? name, List? nodes, List? edges, }) => Combo( id: id, name: name ?? this.name, nodes: nodes ?? this.nodes, edges: edges ?? this.edges, ); Map toJson() => { 'id': id, 'name': name, 'nodes': nodes.map((n) => n.toJson()).toList(), 'edges': edges.map((e) => e.toJson()).toList(), }; factory Combo.fromJson(Map 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), ], edges: [ for (final e in (json['edges'] as List? ?? [])) ComboEdge.fromJson(e as Map), ], ); /// 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 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 = >{}; 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 = [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 _combos = []; bool _loaded = false; List get combos => _combos; Future _file() async { final dir = await getApplicationDocumentsDirectory(); return File(p.join(dir.path, _fileName)); } Future ensureLoaded() async { if (_loaded) return; _loaded = true; try { final file = await _file(); if (!file.existsSync()) return; final json = jsonDecode(await file.readAsString()) as List; _combos = [ for (final c in json) Combo.fromJson(c as Map), ]; 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 _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'); } } }