Skip to content

Commit 4187755

Browse files
committed
feat(nodedex): add user-defined node groups with many-to-many membership
Introduce two new SQLite tables (`nodedex_groups` and `nodedex_node_groups`) to support local user-defined node grouping. Groups can have a name, color, icon, and sort order; membership is a many-to-many join keyed on `node_num` and `group_id`. Timestamps and a `deleted_at_ms` column are included for future Cloud Sync readiness. Add migration logic from schema version 14→15 that creates these tables and indexes. Update `NodeDexTables` constants and bump `nodedexSchemaVersion` to 15. Also add corresponding UI text localizations (IT, PT, JA, FR, DE, etc.) for group management dialogs, including buttons, counters, and placeholder labels.
1 parent d6bd181 commit 4187755

33 files changed

Lines changed: 3312 additions & 4 deletions
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 gotnull (developer@socialmesh.app)
3+
4+
import 'package:flutter/material.dart';
5+
6+
// A user-defined grouping of nodes (e.g. "Repeaters", "My Team").
7+
//
8+
// Groups are a local organisation concept with no Meshtastic radio
9+
// equivalent; they are persisted in nodedex.db alongside the other per-node
10+
// user metadata (social tag, note, local nickname). Row mapping lives in
11+
// NodeGroupsStore so this model stays free of any database coupling.
12+
//
13+
// [colorValue] is an ARGB int rendered via `Color(colorValue)` (mirroring the
14+
// Routes feature). [iconKey] is a stable string key into [kNodeGroupIcons] --
15+
// never a raw code point, so Flutter's icon tree-shaking stays intact in
16+
// release builds.
17+
@immutable
18+
class NodeGroup {
19+
final String id;
20+
final String name;
21+
final int colorValue;
22+
final String iconKey;
23+
final int sortOrder;
24+
final int createdAtMs;
25+
final int updatedAtMs;
26+
27+
const NodeGroup({
28+
required this.id,
29+
required this.name,
30+
required this.colorValue,
31+
required this.iconKey,
32+
this.sortOrder = 0,
33+
required this.createdAtMs,
34+
required this.updatedAtMs,
35+
});
36+
37+
/// The group's swatch colour.
38+
Color get color => Color(colorValue);
39+
40+
/// The group's icon, resolved from the curated const set. Falls back to a
41+
/// neutral label icon when the key is unknown (e.g. written by a newer build).
42+
IconData get icon => kNodeGroupIcons[iconKey] ?? kNodeGroupFallbackIcon;
43+
44+
NodeGroup copyWith({
45+
String? name,
46+
int? colorValue,
47+
String? iconKey,
48+
int? sortOrder,
49+
int? updatedAtMs,
50+
}) {
51+
return NodeGroup(
52+
id: id,
53+
name: name ?? this.name,
54+
colorValue: colorValue ?? this.colorValue,
55+
iconKey: iconKey ?? this.iconKey,
56+
sortOrder: sortOrder ?? this.sortOrder,
57+
createdAtMs: createdAtMs,
58+
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
59+
);
60+
}
61+
62+
@override
63+
bool operator ==(Object other) =>
64+
identical(this, other) ||
65+
other is NodeGroup &&
66+
other.id == id &&
67+
other.name == name &&
68+
other.colorValue == colorValue &&
69+
other.iconKey == iconKey &&
70+
other.sortOrder == sortOrder &&
71+
other.createdAtMs == createdAtMs &&
72+
other.updatedAtMs == updatedAtMs;
73+
74+
@override
75+
int get hashCode => Object.hash(
76+
id,
77+
name,
78+
colorValue,
79+
iconKey,
80+
sortOrder,
81+
createdAtMs,
82+
updatedAtMs,
83+
);
84+
}
85+
86+
/// Fallback icon used when a stored [NodeGroup.iconKey] is not recognised.
87+
const IconData kNodeGroupFallbackIcon = Icons.label_outline;
88+
89+
/// Default icon key for newly created groups.
90+
const String kNodeGroupDefaultIconKey = 'label';
91+
92+
/// Curated set of group icons, keyed by a stable string stored in the DB.
93+
///
94+
/// Every value is a `const IconData`, so icon tree-shaking is preserved in
95+
/// release builds. Add new entries to the END with a new key; never repurpose
96+
/// an existing key (stored rows reference it).
97+
const Map<String, IconData> kNodeGroupIcons = {
98+
'label': Icons.label,
99+
'star': Icons.star,
100+
'home': Icons.home,
101+
'work': Icons.work,
102+
'group': Icons.group,
103+
'favorite': Icons.favorite,
104+
'router': Icons.router,
105+
'sensors': Icons.sensors,
106+
'hub': Icons.hub,
107+
'place': Icons.place,
108+
'shield': Icons.shield,
109+
'bolt': Icons.bolt,
110+
'terrain': Icons.terrain,
111+
'directions_car': Icons.directions_car,
112+
'flag': Icons.flag,
113+
'bookmark': Icons.bookmark,
114+
'antenna': Icons.settings_input_antenna,
115+
'campaign': Icons.campaign,
116+
};
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)