|
| 1 | +// SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +// SPDX-FileCopyrightText: 2025-2026 gotnull (developer@socialmesh.app) |
| 3 | + |
| 4 | +// Node Groups state — reactive layer over NodeGroupsStore (nodedex.db). |
| 5 | +// |
| 6 | +// Exposes the list of user-defined groups plus the node->group membership |
| 7 | +// map, and mutation methods (create / update / delete / assign). Mirrors the |
| 8 | +// AsyncNotifier-with-persistence pattern used by nodeFavoritesProvider. |
| 9 | + |
| 10 | +import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 11 | + |
| 12 | +import '../../../core/logging.dart'; |
| 13 | +import '../models/node_group.dart'; |
| 14 | +import '../services/nodedex_groups_store.dart'; |
| 15 | +import 'nodedex_providers.dart'; |
| 16 | + |
| 17 | +/// Immutable snapshot of all groups and current membership. |
| 18 | +class NodeGroupsState { |
| 19 | + final List<NodeGroup> groups; |
| 20 | + |
| 21 | + /// nodeNum -> set of groupIds the node belongs to. |
| 22 | + final Map<int, Set<String>> membership; |
| 23 | + |
| 24 | + const NodeGroupsState({this.groups = const [], this.membership = const {}}); |
| 25 | + |
| 26 | + /// The set of group ids a given node belongs to (never null). |
| 27 | + Set<String> groupsForNode(int nodeNum) => |
| 28 | + membership[nodeNum] ?? const <String>{}; |
| 29 | + |
| 30 | + /// How many nodes are assigned to [groupId]. |
| 31 | + int nodeCount(String groupId) => |
| 32 | + membership.values.where((ids) => ids.contains(groupId)).length; |
| 33 | + |
| 34 | + /// Whether any group has been created. |
| 35 | + bool get isEmpty => groups.isEmpty; |
| 36 | +} |
| 37 | + |
| 38 | +/// Builds a [NodeGroupsStore] over the shared NodeDex database, ensuring the |
| 39 | +/// database is initialized first (via [nodeDexStoreProvider]). |
| 40 | +final nodeGroupsStoreProvider = FutureProvider<NodeGroupsStore>((ref) async { |
| 41 | + // Awaiting the entry store guarantees the shared NodeDexDatabase is open. |
| 42 | + await ref.watch(nodeDexStoreProvider.future); |
| 43 | + final db = ref.watch(nodeDexDatabaseProvider); |
| 44 | + return NodeGroupsStore(db); |
| 45 | +}); |
| 46 | + |
| 47 | +/// Reactive node-groups state with mutation methods. |
| 48 | +class NodeGroupsNotifier extends AsyncNotifier<NodeGroupsState> { |
| 49 | + Future<NodeGroupsStore> get _storeFuture => |
| 50 | + ref.read(nodeGroupsStoreProvider.future); |
| 51 | + |
| 52 | + @override |
| 53 | + Future<NodeGroupsState> build() async { |
| 54 | + final store = await ref.watch(nodeGroupsStoreProvider.future); |
| 55 | + return _load(store); |
| 56 | + } |
| 57 | + |
| 58 | + Future<NodeGroupsState> _load(NodeGroupsStore store) async { |
| 59 | + final groups = await store.loadGroups(); |
| 60 | + final membership = await store.loadMembership(); |
| 61 | + return NodeGroupsState(groups: groups, membership: membership); |
| 62 | + } |
| 63 | + |
| 64 | + Future<void> _reload() async { |
| 65 | + final store = await _storeFuture; |
| 66 | + state = await AsyncValue.guard(() => _load(store)); |
| 67 | + } |
| 68 | + |
| 69 | + int get _nowMs => DateTime.now().millisecondsSinceEpoch; |
| 70 | + |
| 71 | + /// Generate a collision-resistant local id for a new group. |
| 72 | + String _newGroupId() => |
| 73 | + 'g${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'; |
| 74 | + |
| 75 | + /// Create a new group; returns the created group. |
| 76 | + Future<NodeGroup> createGroup({ |
| 77 | + required String name, |
| 78 | + required int colorValue, |
| 79 | + required String iconKey, |
| 80 | + }) async { |
| 81 | + final store = await _storeFuture; |
| 82 | + final now = _nowMs; |
| 83 | + final existing = state.asData?.value.groups ?? const <NodeGroup>[]; |
| 84 | + final group = NodeGroup( |
| 85 | + id: _newGroupId(), |
| 86 | + name: name.trim(), |
| 87 | + colorValue: colorValue, |
| 88 | + iconKey: iconKey, |
| 89 | + sortOrder: existing.length, |
| 90 | + createdAtMs: now, |
| 91 | + updatedAtMs: now, |
| 92 | + ); |
| 93 | + await store.upsertGroup(group); |
| 94 | + await _reload(); |
| 95 | + AppLogging.nodes('[NodeGroups] created group ${group.id} "${group.name}"'); |
| 96 | + return group; |
| 97 | + } |
| 98 | + |
| 99 | + /// Update an existing group (name / colour / icon / sort order). The |
| 100 | + /// updated timestamp is refreshed automatically. |
| 101 | + Future<void> updateGroup(NodeGroup group) async { |
| 102 | + final store = await _storeFuture; |
| 103 | + await store.upsertGroup(group.copyWith(updatedAtMs: _nowMs)); |
| 104 | + await _reload(); |
| 105 | + } |
| 106 | + |
| 107 | + /// Delete a group and clear all of its membership. |
| 108 | + Future<void> deleteGroup(String groupId) async { |
| 109 | + final store = await _storeFuture; |
| 110 | + await store.deleteGroup(groupId); |
| 111 | + await _reload(); |
| 112 | + } |
| 113 | + |
| 114 | + /// Persist reordered groups (assigns sortOrder by list position). |
| 115 | + Future<void> reorderGroups(List<NodeGroup> ordered) async { |
| 116 | + final store = await _storeFuture; |
| 117 | + final now = _nowMs; |
| 118 | + for (var i = 0; i < ordered.length; i++) { |
| 119 | + await store.upsertGroup( |
| 120 | + ordered[i].copyWith(sortOrder: i, updatedAtMs: now), |
| 121 | + ); |
| 122 | + } |
| 123 | + await _reload(); |
| 124 | + } |
| 125 | + |
| 126 | + /// Replace the full set of groups a node belongs to. |
| 127 | + Future<void> setNodeGroups(int nodeNum, Set<String> groupIds) async { |
| 128 | + final store = await _storeFuture; |
| 129 | + await store.setNodeGroups(nodeNum, groupIds, nowMs: _nowMs); |
| 130 | + await _reload(); |
| 131 | + } |
| 132 | + |
| 133 | + /// Add one node to one group. |
| 134 | + Future<void> addNodeToGroup(int nodeNum, String groupId) async { |
| 135 | + final store = await _storeFuture; |
| 136 | + await store.addNodeToGroup(nodeNum, groupId, nowMs: _nowMs); |
| 137 | + await _reload(); |
| 138 | + } |
| 139 | + |
| 140 | + /// Remove one node from one group. |
| 141 | + Future<void> removeNodeFromGroup(int nodeNum, String groupId) async { |
| 142 | + final store = await _storeFuture; |
| 143 | + await store.removeNodeFromGroup(nodeNum, groupId); |
| 144 | + await _reload(); |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +final nodeGroupsProvider = |
| 149 | + AsyncNotifierProvider<NodeGroupsNotifier, NodeGroupsState>( |
| 150 | + NodeGroupsNotifier.new, |
| 151 | + ); |
0 commit comments