This MVP release provides a complete full-stack solution for managing action mappings in Geutebruck's GeViScope and GeViSoft video surveillance systems. ## Features ### Flutter Web Application (Port 8081) - Modern, responsive UI for managing action mappings - Action picker dialog with full parameter configuration - Support for both GSC (GeViScope) and G-Core server actions - Consistent UI for input and output actions with edit/delete capabilities - Real-time action mapping creation, editing, and deletion - Server categorization (GSC: prefix for GeViScope, G-Core: prefix for G-Core servers) ### FastAPI REST Backend (Port 8000) - RESTful API for action mapping CRUD operations - Action template service with comprehensive action catalog (247 actions) - Server management (G-Core and GeViScope servers) - Configuration tree reading and writing - JWT authentication with role-based access control - PostgreSQL database integration ### C# SDK Bridge (gRPC, Port 50051) - Native integration with GeViSoft SDK (GeViProcAPINET_4_0.dll) - Action mapping creation with correct binary format - Support for GSC and G-Core action types - Proper Camera parameter inclusion in action strings (fixes CrossSwitch bug) - Action ID lookup table with server-specific action IDs - Configuration reading/writing via SetupClient ## Bug Fixes - **CrossSwitch Bug**: GSC and G-Core actions now correctly display camera/PTZ head parameters in GeViSet - Action strings now include Camera parameter: `@ PanLeft (Comment: "", Camera: 101028)` - Proper filter flags and VideoInput=0 for action mappings - Correct action ID assignment (4198 for GSC, 9294 for G-Core PanLeft) ## Technical Stack - **Frontend**: Flutter Web, Dart, Dio HTTP client - **Backend**: Python FastAPI, PostgreSQL, Redis - **SDK Bridge**: C# .NET 8.0, gRPC, GeViSoft SDK - **Authentication**: JWT tokens - **Configuration**: GeViSoft .set files (binary format) ## Credentials - GeViSoft/GeViScope: username=sysadmin, password=masterkey - Default admin: username=admin, password=admin123 ## Deployment All services run on localhost: - Flutter Web: http://localhost:8081 - FastAPI: http://localhost:8000 - SDK Bridge gRPC: localhost:50051 - GeViServer: localhost (default port) Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
674 lines
22 KiB
Markdown
674 lines
22 KiB
Markdown
# Flutter Action Mapping Redesign - Based on Native GeViSet App
|
|
|
|
## Analysis of Native GeViSet UI
|
|
|
|
### Key UI Patterns Observed
|
|
|
|
#### 1. **Main List View** (Action_mapping_list.png)
|
|
- **Table/DataGrid layout** with columns:
|
|
- Input action (name/description)
|
|
- Actions (count)
|
|
- Output action 1, 2, 3, 4... (shows action names)
|
|
- **Selected row highlighting** (blue background)
|
|
- **Bottom action bar**: Add, Edit, Remove buttons
|
|
- **Horizontal scrolling** for multiple output actions
|
|
|
|
#### 2. **Mapping Settings Dialog** (Action_mapping_settings.png)
|
|
- **Two sections**:
|
|
- Input action (single field with browse button)
|
|
- Output actions (list with management controls)
|
|
- **List management controls**: + (add), ⚙ (edit), - (remove), ▲ ▼ (reorder)
|
|
- **Modal dialog** approach (not inline editing)
|
|
|
|
#### 3. **Action Settings Dialog** (Input_action.png, Output_CrossSwitch.png, Output_PanStop.png)
|
|
- **Three-pane layout**:
|
|
1. **Left pane**: Category dropdown + scrollable action list
|
|
2. **Right pane**: Parameters section (dynamic based on action type)
|
|
3. **Bottom**: Caption field, Delay execution, Description text
|
|
- **Category-based organization** (Telemetry control, Crossbar control, GSC: Camera control, etc.)
|
|
- **Action descriptions** shown at bottom (helps users understand what each action does)
|
|
- **Dynamic parameter widgets**:
|
|
- Checkboxes with spinners
|
|
- Text fields with browse buttons
|
|
- Table format (Parameter | Value)
|
|
- **Default button** to reset to defaults
|
|
|
|
---
|
|
|
|
## Proposed Flutter App Redesign
|
|
|
|
### Architecture Changes
|
|
|
|
#### 1. **Add Action Categories to Backend**
|
|
|
|
Update `ACTION_PARAMETER_TEMPLATES` to include categories:
|
|
|
|
```python
|
|
# In configuration.py
|
|
ACTION_CATEGORIES = {
|
|
"Camera Control": ["PanLeft", "PanRight", "PanStop", "TiltUp", ...],
|
|
"Video Switching": ["CrossSwitch OpCon -> Matrix", "CrossSwitch C -> M"],
|
|
"Recording": ["StartRecording", "StopRecording"],
|
|
"Events": ["StartEvent", "StopEvent", "KillEvent"],
|
|
"Alarms": ["AlarmRecording"],
|
|
"Digital I/O": ["DigitalContactActivate", "DigitalContactDeactivate"],
|
|
"Viewer": ["GSC ViewerConnectLive V <- C", "GSC ViewerDisconnect"]
|
|
}
|
|
```
|
|
|
|
Add endpoint:
|
|
```
|
|
GET /api/v1/configuration/action-categories
|
|
```
|
|
|
|
#### 2. **Add Action Descriptions**
|
|
|
|
Enhance `ACTION_PARAMETER_TEMPLATES` with descriptions:
|
|
|
|
```python
|
|
"PanStop": {
|
|
"parameters": ["GCoreServer", "PTZ head"],
|
|
"description": "Stop pan movement. The panning of the camera will be stopped.",
|
|
"category": "Camera Control",
|
|
"required_caption": True,
|
|
"supports_delay": True
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### UI Component Structure
|
|
|
|
```
|
|
ActionMappingsScreen
|
|
├── ActionMappingsList (DataTable/ListView)
|
|
│ ├── Column: Input Action
|
|
│ ├── Column: # Actions
|
|
│ ├── Column: Output Action 1
|
|
│ ├── Column: Output Action 2
|
|
│ ├── Column: Output Action 3
|
|
│ └── BottomBar: [Add] [Edit] [Remove]
|
|
│
|
|
└── ActionMappingDialog (when Add/Edit clicked)
|
|
├── InputActionField
|
|
│ └── [Browse] button → ActionPickerDialog
|
|
├── OutputActionsSection
|
|
│ ├── OutputActionsList
|
|
│ └── Controls: [+] [⚙] [-] [▲] [▼]
|
|
└── [OK] [Cancel]
|
|
|
|
ActionPickerDialog (for selecting/editing an action)
|
|
├── CategoryDropdown
|
|
├── ActionsList (filtered by category)
|
|
├── ParametersPanel (dynamic based on selected action)
|
|
│ └── DynamicParameterFields
|
|
├── CaptionField (required)
|
|
├── DelayExecutionField
|
|
├── DescriptionText (read-only, shows action description)
|
|
└── [Default] [OK] [Cancel]
|
|
```
|
|
|
|
---
|
|
|
|
### Screen-by-Screen Design
|
|
|
|
#### Screen 1: Action Mappings List (Main Screen)
|
|
|
|
**Layout**: Desktop-style DataTable (use DataTable widget or custom ListView)
|
|
|
|
```dart
|
|
class ActionMappingsListScreen extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text('Action Mappings')),
|
|
body: Column(
|
|
children: [
|
|
// DataTable with horizontal scrolling
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: DataTable(
|
|
columns: [
|
|
DataColumn(label: Text('Input Action')),
|
|
DataColumn(label: Text('Actions')),
|
|
DataColumn(label: Text('Output Action 1')),
|
|
DataColumn(label: Text('Output Action 2')),
|
|
DataColumn(label: Text('Output Action 3')),
|
|
DataColumn(label: Text('Output Action 4')),
|
|
],
|
|
rows: _buildRows(),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Bottom action bar
|
|
Container(
|
|
padding: EdgeInsets.all(8),
|
|
child: Row(
|
|
children: [
|
|
ElevatedButton(
|
|
onPressed: _onAddMapping,
|
|
child: Text('Add...'),
|
|
),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: _onEditMapping,
|
|
child: Text('Edit...'),
|
|
),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: _onRemoveMapping,
|
|
child: Text('Remove'),
|
|
),
|
|
Spacer(),
|
|
ElevatedButton(
|
|
onPressed: _onOk,
|
|
child: Text('OK'),
|
|
),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: _onCancel,
|
|
child: Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Features**:
|
|
- Row selection (highlight selected row in blue)
|
|
- Show input action name
|
|
- Show count of output actions
|
|
- Show first 4 output action names in separate columns
|
|
- Horizontal scrolling if more columns needed
|
|
|
|
---
|
|
|
|
#### Screen 2: Action Mapping Settings Dialog
|
|
|
|
**Layout**: Dialog with input section and output actions list
|
|
|
|
```dart
|
|
class ActionMappingDialog extends StatefulWidget {
|
|
final ActionMapping? mapping; // null for new, existing for edit
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
child: Container(
|
|
width: 600,
|
|
height: 400,
|
|
child: Column(
|
|
children: [
|
|
// Title
|
|
DialogTitle(text: 'Action mapping settings'),
|
|
|
|
// Input action section
|
|
Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Input action', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _inputActionController,
|
|
decoration: InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: _onBrowseInputAction,
|
|
child: Text('...'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Output actions section
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Output actions', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Output actions list
|
|
Expanded(
|
|
child: Container(
|
|
margin: EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey),
|
|
),
|
|
child: ListView.builder(
|
|
itemCount: outputActions.length,
|
|
itemBuilder: (context, index) {
|
|
return ListTile(
|
|
title: Text(outputActions[index].action),
|
|
selected: selectedOutputIndex == index,
|
|
onTap: () => setState(() => selectedOutputIndex = index),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Output actions controls
|
|
Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton(icon: Icon(Icons.add), onPressed: _onAddOutput),
|
|
IconButton(icon: Icon(Icons.settings), onPressed: _onEditOutput),
|
|
IconButton(icon: Icon(Icons.remove), onPressed: _onRemoveOutput),
|
|
IconButton(icon: Icon(Icons.arrow_upward), onPressed: _onMoveUp),
|
|
IconButton(icon: Icon(Icons.arrow_downward), onPressed: _onMoveDown),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Bottom buttons
|
|
Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
ElevatedButton(onPressed: _onOk, child: Text('OK')),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(onPressed: _onCancel, child: Text('Cancel')),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
#### Screen 3: Action Picker Dialog (most important!)
|
|
|
|
**Layout**: Three-section layout matching native app
|
|
|
|
```dart
|
|
class ActionPickerDialog extends StatefulWidget {
|
|
final Action? existingAction; // null for new, existing for edit
|
|
|
|
@override
|
|
_ActionPickerDialogState createState() => _ActionPickerDialogState();
|
|
}
|
|
|
|
class _ActionPickerDialogState extends State<ActionPickerDialog> {
|
|
String? selectedCategory;
|
|
String? selectedAction;
|
|
Map<String, ActionTemplate> actionTemplates = {};
|
|
Map<String, dynamic> parameters = {};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
child: Container(
|
|
width: 700,
|
|
height: 500,
|
|
child: Column(
|
|
children: [
|
|
DialogTitle(text: 'Action settings...'),
|
|
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// LEFT PANE: Category and Action List
|
|
Container(
|
|
width: 250,
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Category dropdown
|
|
Text('Category:', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
DropdownButton<String>(
|
|
isExpanded: true,
|
|
value: selectedCategory,
|
|
items: categories.map((cat) =>
|
|
DropdownMenuItem(value: cat, child: Text(cat))
|
|
).toList(),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
selectedCategory = value;
|
|
selectedAction = null; // Reset action selection
|
|
});
|
|
},
|
|
),
|
|
|
|
SizedBox(height: 16),
|
|
|
|
// Action list
|
|
Text('Action:', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey),
|
|
),
|
|
child: ListView.builder(
|
|
itemCount: _getFilteredActions().length,
|
|
itemBuilder: (context, index) {
|
|
final actionName = _getFilteredActions()[index];
|
|
return ListTile(
|
|
title: Text(actionName),
|
|
selected: selectedAction == actionName,
|
|
selectedTileColor: Colors.blue,
|
|
onTap: () {
|
|
setState(() {
|
|
selectedAction = actionName;
|
|
_loadParametersForAction(actionName);
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// DIVIDER
|
|
VerticalDivider(width: 1),
|
|
|
|
// RIGHT PANE: Parameters
|
|
Expanded(
|
|
child: Container(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Parameters section
|
|
Text('Parameters:', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
SizedBox(height: 8),
|
|
|
|
// Dynamic parameter fields
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: _buildParameterFields(),
|
|
),
|
|
),
|
|
|
|
Divider(),
|
|
|
|
// Caption field (required)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
labelText: 'Caption (required)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 16),
|
|
Text('Delay execution:'),
|
|
SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 80,
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
suffixText: 'ms',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
SizedBox(height: 8),
|
|
|
|
// Description (read-only)
|
|
Container(
|
|
padding: EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
color: Colors.grey[100],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Description:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
_getActionDescription(),
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Bottom buttons
|
|
Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
ElevatedButton(
|
|
onPressed: _onDefault,
|
|
child: Text('Default'),
|
|
),
|
|
Row(
|
|
children: [
|
|
ElevatedButton(onPressed: _onOk, child: Text('Ok')),
|
|
SizedBox(width: 8),
|
|
ElevatedButton(onPressed: _onCancel, child: Text('Cancel')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildParameterFields() {
|
|
if (selectedAction == null) {
|
|
return Center(child: Text('Select an action to view parameters'));
|
|
}
|
|
|
|
final template = actionTemplates[selectedAction!];
|
|
if (template == null || template.parameters.isEmpty) {
|
|
return Center(child: Text('No parameters required'));
|
|
}
|
|
|
|
// Build dynamic parameter fields based on template
|
|
return Column(
|
|
children: template.parameters.map((param) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
children: [
|
|
Checkbox(
|
|
value: parameters.containsKey(param),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
if (value == true) {
|
|
parameters[param] = '';
|
|
} else {
|
|
parameters.remove(param);
|
|
}
|
|
});
|
|
},
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
enabled: parameters.containsKey(param),
|
|
decoration: InputDecoration(
|
|
labelText: param,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) {
|
|
parameters[param] = value;
|
|
},
|
|
),
|
|
),
|
|
if (_parameterHasBrowseButton(param))
|
|
Padding(
|
|
padding: EdgeInsets.only(left: 8),
|
|
child: ElevatedButton(
|
|
onPressed: () => _onBrowseParameter(param),
|
|
child: Text('...'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Backend API Enhancements Needed
|
|
|
|
#### 1. Add Action Categories Endpoint
|
|
|
|
```python
|
|
@router.get(
|
|
"/action-categories",
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Get action categories",
|
|
description="Get all action types grouped by category"
|
|
)
|
|
async def get_action_categories(
|
|
current_user: User = Depends(require_viewer)
|
|
):
|
|
"""Get action types organized by category"""
|
|
categories = {}
|
|
for action_name, template in ACTION_PARAMETER_TEMPLATES.items():
|
|
category = template.get("category", "Other")
|
|
if category not in categories:
|
|
categories[category] = []
|
|
categories[category].append(action_name)
|
|
|
|
return {
|
|
"categories": categories,
|
|
"total_categories": len(categories)
|
|
}
|
|
```
|
|
|
|
#### 2. Enhance Action Templates with More Details
|
|
|
|
```python
|
|
ACTION_PARAMETER_TEMPLATES = {
|
|
"PanStop": {
|
|
"parameters": ["GCoreServer", "PTZ head"],
|
|
"description": "Stop pan movement. The panning of the camera will be stopped.",
|
|
"category": "Camera Control",
|
|
"required_caption": True,
|
|
"supports_delay": True,
|
|
"parameter_types": {
|
|
"GCoreServer": "dropdown", # Could browse from servers
|
|
"PTZ head": "text"
|
|
}
|
|
},
|
|
# ... etc
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Key Differences from Current Flutter App
|
|
|
|
| Aspect | Current Design | Proposed Design (Like Native) |
|
|
|--------|---------------|-------------------------------|
|
|
| **Main View** | Card-based list | DataTable with columns |
|
|
| **Editing** | Inline expansion | Modal dialogs |
|
|
| **Action Selection** | Dropdown only | Category + Scrollable list |
|
|
| **Parameters** | Form fields only | Checkboxes + fields (optional parameters) |
|
|
| **Descriptions** | Not shown | Shown for each action at bottom |
|
|
| **Multiple Outputs** | Not clearly visible | Shown as separate columns in table |
|
|
| **Reordering** | Not supported | Up/Down arrow buttons |
|
|
| **Visual Style** | Material Design cards | Desktop-style dialogs and tables |
|
|
|
|
---
|
|
|
|
### Implementation Priority
|
|
|
|
**Phase 1: Core Structure**
|
|
1. ✅ Backend: Add action categories endpoint
|
|
2. ✅ Backend: Enhance templates with descriptions
|
|
3. Create ActionPickerDialog with category/action list
|
|
4. Implement dynamic parameter fields
|
|
|
|
**Phase 2: List View**
|
|
1. Replace card list with DataTable
|
|
2. Add column for output action names
|
|
3. Implement row selection
|
|
|
|
**Phase 3: Dialogs**
|
|
1. Create ActionMappingDialog for editing
|
|
2. Add output actions list management
|
|
3. Implement reordering (up/down)
|
|
|
|
**Phase 4: Polish**
|
|
1. Add descriptions to action picker
|
|
2. Add Default button functionality
|
|
3. Add delay execution field
|
|
4. Improve parameter field types (browse buttons, etc.)
|
|
|
|
---
|
|
|
|
### Benefits of This Redesign
|
|
|
|
1. **Familiar UX for GeViSet users** - Matches their mental model
|
|
2. **Better information density** - See more mappings at once
|
|
3. **Clearer action relationships** - Input → multiple outputs visible
|
|
4. **Category organization** - Easier to find actions
|
|
5. **Action descriptions** - Users understand what each action does
|
|
6. **Optional parameters** - Checkboxes show what's configurable
|
|
7. **Professional desktop feel** - More appropriate for power users
|
|
|
|
---
|
|
|
|
### Next Steps
|
|
|
|
Would you like me to:
|
|
1. **Update the backend** to add categories endpoint and enhance templates?
|
|
2. **Create a prototype** of the ActionPickerDialog in Flutter?
|
|
3. **Design the DataTable** layout for the main list?
|
|
4. **All of the above** - full implementation?
|