Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inconvenient to add an alternate name to each enum value using Enum Value Options #921

Open
luangong opened this issue Mar 10, 2024 · 0 comments

Comments

@luangong
Copy link

luangong commented Mar 10, 2024

TL;DR: Enum value options extensions are generated as global variables instead of being attached to each enum value.

Background

I’m creating a Flutter widget CupertinoSelectFormFieldRow<T> (inspired by CupertinoTextFormFieldRow and CupertinoListTile that lets the user select an option from a drop-down list or a pop-up menu, with each item corresponding to a value of an enum type. I would like to add a human-friendly name to each enum to be shown in a drop-down list or a pop-up menu (related to #919).

CupertinoSelectFormFieldRow

I found three solutions, with the first using JSON and enhanced enums in Dart, and the rest two using enums in Protocol Buffers to model the underlying data.

Solution 1: JSON + enhance enums + extra field

In the first solution, I modeled my underlying data with JSON (so that the data can be easily serialized and deserialized) and used enhanced enums in Dart, adding a title property to each enum type, and overriding the toString() method, delegating it to the title property, as shown below:

enum Direction {
  up('UP'), down('DOWN'), left('LEFT'), right('RIGHT');

  final String title;

  const Direction(this.title);

  @override
  String toString() => title;
}

enum PhoneType {
  mobile('Mobile'), home('Home'), work('Work');
  
  final String title;
  
  const PhoneType(this.title);
  
  @override
  String toString() => title;
}

The complete Flutter code is in this dartpad and the code for CupertinoSelectFormFieldRow is shown below:

class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
  final Widget title;
  final List<T> candidates;
  final T initialValue;
  final String? Function(T)? validator;
  final void Function(T)? onChanged;
  final void Function(T)? onSaved;

  const CupertinoSelectFormFieldRow({
    super.key,
    required this.title,
    required this.candidates,
    required this.initialValue,
    this.validator,
    this.onChanged,
    this.onSaved,
  });

  @override
  Widget build(BuildContext context) {
    return FormField<T>(
      builder: (field) => CupertinoListTile(
        title: title,
        additionalInfo: Text(field.value!.toString()),
        trailing: const CupertinoListTileChevron(),
        onTap: () async {
          final selection = await showCupertinoModalPopup(
            context: context,
            builder: (context) => CupertinoActionSheet(
              actions: candidates.map((candidate) {
                return CupertinoActionSheetAction(
                  onPressed: () => Navigator.of(context).pop(candidate),
                  child: Text(candidate.toString()),
                );
              }).toList(),
            ),
          );
          if (selection == null) return;
          field.didChange(selection);
          onChanged?.call(selection);
        },
      ),
      initialValue: initialValue,
      validator: (selection) => validator?.call(selection as T),
      onSaved: (selection) => onSaved?.call(selection as T),
    );
  }
}

In the code above, I used candidate.toString() instead of candidate.title or candidate.name. There are two reasons:

  1. title is not an intrinsic property of Enum or T,
  2. The name property of an enum value contains the lowerCamelCase variable name (up, down, left, right, mobile, home, or work), which is usually different from the human-friendly name.

The problem of this solution is that it’s difficult to evolve the schema of the data model with JSON.

Solution 2: Protocol Buffers enums + one extra map for each enum type

In the second solution, I modeled my data with Protocol Buffers, representing the choices as protobuf enums, as shown below:

// demo.proto

syntax = "proto3";

enum Direction {
  DIRECTION_UNSPECIFIED = 0;
  DIRECTION_UP = 1;
  DIRECTION_DOWN = 2;
  DIRECTION_LEFT = 3;
  DIRECTION_RIGHT = 4;
}

enum PhoneType {
  PHONE_TYPE_UNSPECIFIED = 0;
  PHONE_TYPE_MOBILE = 1;
  PHONE_TYPE_HOME = 2;
  PHONE_TYPE_WORK = 3;
}

but there is no easy way to add a human-friendly name to each enum value. So I have to manually define two maps like this:

// In main.dart

final directions = {
  Direction.DIRECTION_UP: 'UP',
  Direction.DIRECTION_DOWN: 'DOWN',
  Direction.DIRECTION_LEFT: 'LEFT',
  Direction.DIRECTION_RIGHT: 'RIGHT',
};

final phoneTypes = {
  PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
  PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
  PhoneType.PHONE_TYPE_HOME: 'Home',
  PhoneType.PHONE_TYPE_WORK: 'Work',
};

and then add a map argument of type Map<T, String> to the CupertinoSelectFormFieldRow() constructor. The diff of main.dart against solution 1 is shown below:

Toggle code
 import 'package:flutter/cupertino.dart';
+import 'package:protobuf/protobuf.dart';
+
+import 'src/demo.pb.dart';

-enum Direction {
-  up('UP'),
-  down('DOWN'),
-  left('LEFT'),
-  right('RIGHT');

-  final String title;

-  const Direction(this.title);
-
-  @override
-  String toString() => title;
-}
-
-enum PhoneType {
-  mobile('Mobile'),
-  home('Home'),
-  work('Work');
-
-  final String title;
-
-  const PhoneType(this.title);
-
-  @override
-  String toString() => title;
-}
+final directions = {
+  Direction.DIRECTION_UNSPECIFIED: 'Unspecified',
+  Direction.DIRECTION_UP: 'UP',
+  Direction.DIRECTION_DOWN: 'DOWN',
+  Direction.DIRECTION_LEFT: 'LEFT',
+  Direction.DIRECTION_RIGHT: 'RIGHT',
+};
+
+final phoneTypes = {
+  PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
+  PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
+  PhoneType.PHONE_TYPE_HOME: 'Home',
+  PhoneType.PHONE_TYPE_WORK: 'Work',
+};

 void main() => runApp(const MyApp());

@@ -46,16 +37,18 @@ class MyApp extends StatelessWidget {
         ),
         child: CupertinoListSection(
           hasLeading: false,
             CupertinoSelectFormFieldRow<Direction>(
-              candidates: Direction.values,
+              candidates: Direction.values.sublist(1),
+              map: directions,
-              initialValue: Direction.up,
+              initialValue: Direction.DIRECTION_UP,
             ),
             CupertinoSelectFormFieldRow<PhoneType>(
-              candidates: PhoneType.values,
+              candidates: PhoneType.values.sublist(1),
+              map: phoneTypes,
-              initialValue: PhoneType.mobile,
+              initialValue: PhoneType.PHONE_TYPE_MOBILE,
             ),
           ],
         ),
@@ -64,9 +57,11 @@ class MyApp extends StatelessWidget {
   }
 }
 
-class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
+class CupertinoSelectFormFieldRow<T extends ProtobufEnum> extends StatelessWidget {
   final Widget title;
   final List<T> candidates;
+  final Map<T, String> map;
   final T initialValue;
   final String? Function(T)? validator;
   final void Function(T)? onChanged;
@@ -76,6 +71,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
     super.key,
     required this.title,
     required this.candidates,
+    required this.map,
     required this.initialValue,
     this.validator,
     this.onChanged,
@@ -87,7 +83,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
     return FormField<T>(
       builder: (field) => CupertinoListTile(
         title: title,
-        additionalInfo: Text(field.value!.toString()),
+        additionalInfo: Text(map[field.value!] ?? 'Unknown'),
         trailing: const CupertinoListTileChevron(),
         onTap: () async {
           final selection = await showCupertinoModalPopup(
@@ -96,7 +92,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
               actions: candidates.map((candidate) {
                 return CupertinoActionSheetAction(
                   onPressed: () => Navigator.of(context).pop(candidate),
-                  child: Text(candidate.toString()),
+                  child: Text(map[candidate] ?? 'Unknown'),
                 );
               }).toList(),
             ),

There are two problems of this solution:

  1. The definition of the maps are separated into .proto file and .dart file, making it difficult to keep them in sync.
  2. It requires an extra map argument for CupertinoSelectFormFieldRow(), which is inconvenient and inelegant.

Solution 3: Protocol Buffers enums + enum value options

Finally I came across a post about Alternate names for enumeration values on the Google Groups forum and I thought it’s the best solution. The post says that we can extend google.protobuf.EnumValueOptions and add any extra fields to it, like this:

// demo.proto

syntax = "proto3";

import "google/protobuf/descriptor.proto";

option java_multiple_files = true;
option java_package = "com.example.protobuf";

extend google.protobuf.EnumValueOptions {
  string title = 1000;
}

enum Direction {
  DIRECTION_UNSPECIFIED = 0 [(title) = "Unspecified"];
  DIRECTION_UP = 1 [(title) = "UP"];
  DIRECTION_DOWN = 2 [(title) = "DOWN"];
  DIRECTION_LEFT = 3 [(title) = "LEFT"];
  DIRECTION_RIGHT = 4 [(title) = "RIGHT"];
}

enum PhoneType {
  PHONE_TYPE_UNSPECIFIED = 0 [(title) = "Unspecified"];
  PHONE_TYPE_MOBILE = 1 [(title) = "Mobile"];
  PHONE_TYPE_HOME = 2 [(title) = "Home"];
  PHONE_TYPE_WORK = 3 [(title) = "Work"];
}

Then in the Java code we can retrieve the extra title field for any enum value, like this:

// src/main/java/com/example/protobuf/Application.java

package com.example.protobuf;

public class Application {
  public static void main(String[] args) {
    var up = Direction.DIRECTION_DOWN;
    System.out.println(up.getValueDescriptor().getOptions().getExtension(Demo.title));
    var mobile = PhoneType.PHONE_TYPE_MOBILE;
    System.out.println(mobile.getValueDescriptor().getOptions().getExtension(Demo.title));
  }
}

It looks great, but when I applied this technique to the Dart code, the protoc Dart plugin generates one enum descriptor blob as a global variable for each protobuf enum type, with each global variable containing all the values of all the extra fields of an enum type, as shown below:

Toggle code
// demo.pbjson.dart

import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;

@$core.Deprecated('Use directionDescriptor instead')
const Direction$json = {
  '1': 'Direction',
  '2': [
    {'1': 'DIRECTION_UNSPECIFIED', '2': 0, '3': {}},
    {'1': 'DIRECTION_UP', '2': 1, '3': {}},
    {'1': 'DIRECTION_DOWN', '2': 2, '3': {}},
    {'1': 'DIRECTION_LEFT', '2': 3, '3': {}},
    {'1': 'DIRECTION_RIGHT', '2': 4, '3': {}},
  ],
};

/// Descriptor for `Direction`. Decode as a `google.protobuf.EnumDescriptorProto`.
final $typed_data.Uint8List directionDescriptor = $convert.base64Decode(
    'CglEaXJlY3Rpb24SKQoVRElSRUNUSU9OX1VOU1BFQ0lGSUVEEAAaDsI+C1Vuc3BlY2lmaWVkEh'
    'cKDERJUkVDVElPTl9VUBABGgXCPgJVUBIbCg5ESVJFQ1RJT05fRE9XThACGgfCPgRET1dOEhsK'
    'DkRJUkVDVElPTl9MRUZUEAMaB8I+BExFRlQSHQoPRElSRUNUSU9OX1JJR0hUEAQaCMI+BVJJR0'
    'hU');

@$core.Deprecated('Use phoneTypeDescriptor instead')
const PhoneType$json = {
  '1': 'PhoneType',
  '2': [
    {'1': 'PHONE_TYPE_UNSPECIFIED', '2': 0, '3': {}},
    {'1': 'PHONE_TYPE_MOBILE', '2': 1, '3': {}},
    {'1': 'PHONE_TYPE_HOME', '2': 2, '3': {}},
  ],
};

/// Descriptor for `PhoneType`. Decode as a `google.protobuf.EnumDescriptorProto`.
final $typed_data.Uint8List phoneTypeDescriptor = $convert.base64Decode(
    'CglQaG9uZVR5cGUSKgoWUEhPTkVfVFlQRV9VTlNQRUNJRklFRBAAGg7CPgtVbnNwZWNpZmllZB'
    'IgChFQSE9ORV9UWVBFX01PQklMRRABGgnCPgZNb2JpbGUSHAoPUEhPTkVfVFlQRV9IT01FEAIa'
    'B8I+BEhvbWU=');

Since the values of the extra field of the enum value options are stored in separate global variables instead of being attached to each enum value. There is no way in the implementation code of CupertinoSelectFormFieldRow to retrieve the associated title field for a generic enum value. Although we eliminated the maps, an extra enumDescriptor argument has to be added to CupertinoSelectFormFieldRow. The diff of main.dart against solution 2 is shown below:

Toggle code
 import 'package:flutter/cupertino.dart';
 import 'package:protobuf/protobuf.dart';
+import 'package:protobuf_wellknown/protobuf_wellknown.dart';
 
 import 'src/demo.pb.dart';
+import 'src/demo.pbjson.dart';
-
-final directions = {
-  Direction.DIRECTION_UNSPECIFIED: 'Unspecified',
-  Direction.DIRECTION_UP: 'UP',
-  Direction.DIRECTION_DOWN: 'DOWN',
-  Direction.DIRECTION_LEFT: 'LEFT',
-  Direction.DIRECTION_RIGHT: 'RIGHT',
-};
-
-final phoneTypes = {
-  PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
-  PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
-  PhoneType.PHONE_TYPE_HOME: 'Home',
-  PhoneType.PHONE_TYPE_WORK: 'Work',
-};
 
 void main() => runApp(const MyApp());
 
@@ -25,6 +12,17 @@ class MyApp extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    ExtensionRegistry registry = ExtensionRegistry();
+    Demo.registerAllExtensions(registry);
+    final directionEnumDescriptor = EnumDescriptorProto.fromBuffer(
+      directionDescriptor,
+      registry,
+    );
+    final phoneTypeEnumDescriptor = EnumDescriptorProto.fromBuffer(
+      phoneTypeDescriptor,
+      registry,
+    );
+
     return CupertinoApp(
       debugShowCheckedModeBanner: false,
       theme: const CupertinoThemeData(
@@ -41,13 +39,13 @@ class MyApp extends StatelessWidget {
             CupertinoSelectFormFieldRow<Direction>(
               title: const Text('Direction'),
               candidates: Direction.values.sublist(1),
-              map: directions,
+              enumDescriptor: directionEnumDescriptor,
               initialValue: Direction.DIRECTION_UP,
             ),
             CupertinoSelectFormFieldRow<PhoneType>(
               title: const Text('Phone Type'),
               candidates: PhoneType.values.sublist(1),
-              map: phoneTypes,
+              enumDescriptor: phoneTypeEnumDescriptor,
               initialValue: PhoneType.PHONE_TYPE_MOBILE,
             ),
           ],
@@ -61,7 +59,7 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
     extends StatelessWidget {
   final Widget title;
   final List<T> candidates;
-  final Map<T, String> map;
+  final EnumDescriptorProto enumDescriptor;
   final T initialValue;
   final String? Function(T)? validator;
   final void Function(T)? onChanged;
@@ -71,7 +69,7 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
     super.key,
     required this.title,
     required this.candidates,
-    required this.map,
+    required this.enumDescriptor,
     required this.initialValue,
     this.validator,
     this.onChanged,
@@ -83,16 +81,24 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
     return FormField<T>(
       builder: (field) => CupertinoListTile(
         title: title,
-        additionalInfo: Text(map[field.value!] ?? 'Unknown'),
+        additionalInfo: Text(
+          enumDescriptor.value
+              .firstWhere((e) => e.number == field.value!.value)
+              .options
+              .getExtension(Demo.title),
+        ),
         trailing: const CupertinoListTileChevron(),
         onTap: () async {
           final selection = await showCupertinoModalPopup(
             context: context,
             builder: (context) => CupertinoActionSheet(
-              actions: candidates.map((candidate) {
+              actions: candidates.asMap().entries.map((entry) {
                 return CupertinoActionSheetAction(
-                  onPressed: () => Navigator.of(context).pop(candidate),
+                  onPressed: () => Navigator.of(context).pop(entry.value),
-                  child: Text(map[candidate] ?? 'Unknown'),
+                  child: Text(
+                    enumDescriptor.value[entry.key + 1].options
+                        .getExtension(Demo.title),
+                  ),
                 );
               }).toList(),
             ),

In conclusion, solution 3 has the benefit of keeping the definition of human-friendly names next to the definition of the enum values, thus making it easy to keep them in sync, but it also requires an extra enumDescriptor argument (when applying the enum value options technique to Dart), so it’s still not elegant.

My Questions

  1. Is it possible to generate the enum value options extension in a way that it is associated with each enum value (similar to that in C++ and Java) so we can directly retrieve the extension field from the enum value instead of relying on an external map?
  2. Why did the team decide to generate the descriptors as global variables?

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant