Last active
October 1, 2025 06:12
-
-
Save PlugFox/4d186e0d5a191da2a3b2b53d7dc838e1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:doctorina/src/profile/model/profile_data_codecs.dart'; | |
| import 'package:flutter/foundation.dart'; | |
| typedef ProfileId = String; | |
| @immutable | |
| class Profile { | |
| const Profile({ | |
| required this.id, | |
| required this.name, | |
| required this.isPrimary, | |
| required this.createdAt, | |
| required this.updatedAt, | |
| }); | |
| final ProfileId id; | |
| final String name; | |
| final bool isPrimary; | |
| final DateTime createdAt; | |
| final DateTime updatedAt; | |
| @override | |
| String toString() => 'Profile{id: $id, name: $name}'; | |
| } | |
| @immutable | |
| class ProfileData { | |
| const ProfileData({ | |
| required this.locale, | |
| required this.profileId, | |
| required this.updatedAt, | |
| required this.schemaVersion, | |
| required this.sections, | |
| }); | |
| factory ProfileData.fromJson(Map<String, Object?> json) => const ProfileDataJsonDecoder().convert(json); | |
| final ProfileId profileId; | |
| final String locale; | |
| final DateTime updatedAt; | |
| final Object? schemaVersion; | |
| final List<ProfileSection> sections; | |
| @override | |
| String toString() => 'ProfileData{profileId: $profileId, locale: $locale, sections: ${sections.length}}'; | |
| } | |
| @immutable | |
| class ProfileSection { | |
| const ProfileSection({ | |
| required this.id, | |
| required this.title, | |
| required this.description, | |
| required this.collapsible, | |
| //required this.visibleIf, | |
| required this.fields, | |
| }); | |
| factory ProfileSection.fromJson(Map<String, Object?> json) => const ProfileSectionJsonDecoder().convert(json); | |
| final String id; | |
| final String title; | |
| final String? description; | |
| final bool collapsible; | |
| //final Object? visibleIf; | |
| final List<ProfileField> fields; | |
| @override | |
| String toString() => 'ProfileSection{id: $id, title: $title}'; | |
| } | |
| /// The type of the profile field. | |
| enum ProfileFieldType { | |
| /// A single line text field. | |
| /// e.g. | |
| /// ```json | |
| /// "type": "text", | |
| /// "value": "Some text" | |
| /// ``` | |
| text(), | |
| /// Selection from provided options. | |
| /// e.g. | |
| /// ```json | |
| /// "type": "select", | |
| /// "options": { | |
| /// "male": "Male", | |
| /// "female": "Female", | |
| /// "other": "Other" | |
| /// }, | |
| /// value: "male" | |
| /// ``` | |
| select(), | |
| /// Multiple selection. | |
| /// e.g. | |
| /// ```json | |
| /// "type": "multiselect", | |
| /// "value": ["tag", "option", "id"], | |
| /// ``` | |
| enumeration(), | |
| /// A date field. | |
| /// e.g. | |
| /// ```json | |
| /// "type": "date", | |
| /// "value": "2023-01-01" | |
| /// ``` | |
| date(); | |
| const ProfileFieldType(); | |
| } | |
| @immutable | |
| sealed class ProfileField { | |
| const ProfileField({ | |
| required this.id, | |
| required this.label, | |
| required this.placeholder, | |
| required this.required, | |
| //required this.widget, | |
| //required this.visibleIf, | |
| //required this.options, | |
| //required this.value, | |
| }); | |
| factory ProfileField.fromJson(Map<String, Object?> json) => const ProfileFieldJsonDecoder().convert(json); | |
| /// The type of the profile field. | |
| abstract final ProfileFieldType type; | |
| /// The unique identifier of the profile field. | |
| final String id; | |
| /// The label of the profile field. | |
| final String label; | |
| /// The placeholder of the profile field. | |
| final String placeholder; | |
| /// Whether the profile field is required. | |
| final bool required; | |
| //final Object? widget; | |
| //final Object? visibleIf; | |
| //final Map<String, String>? options; | |
| //final Object? value; | |
| T map<T>({ | |
| required T Function(ProfileField$Text value) text, | |
| required T Function(ProfileField$Select value) select, | |
| required T Function(ProfileField$Enumeration value) enumeration, | |
| required T Function(ProfileField$Date value) date, | |
| }); | |
| T maybeMap<T>({ | |
| T Function(ProfileField$Text value)? text, | |
| T Function(ProfileField$Select value)? select, | |
| T Function(ProfileField$Enumeration value)? enumeration, | |
| T Function(ProfileField$Date value)? date, | |
| required T Function() orElse, | |
| }) => map( | |
| text: text ?? (_) => orElse(), | |
| select: select ?? (_) => orElse(), | |
| enumeration: enumeration ?? (_) => orElse(), | |
| date: date ?? (_) => orElse(), | |
| ); | |
| } | |
| final class ProfileField$Text extends ProfileField { | |
| const ProfileField$Text({ | |
| required super.id, | |
| required super.label, | |
| required super.placeholder, | |
| required super.required, | |
| required this.value, | |
| }); | |
| @override | |
| ProfileFieldType get type => ProfileFieldType.text; | |
| final String? value; | |
| @override | |
| T map<T>({ | |
| required T Function(ProfileField$Text value) text, | |
| required T Function(ProfileField$Select value) select, | |
| required T Function(ProfileField$Enumeration value) enumeration, | |
| required T Function(ProfileField$Date value) date, | |
| }) => text(this); | |
| @override | |
| String toString() => 'ProfileField\$Text{id: $id, label: $label}'; | |
| } | |
| final class ProfileField$Select extends ProfileField { | |
| const ProfileField$Select({ | |
| required super.id, | |
| required super.label, | |
| required super.placeholder, | |
| required super.required, | |
| required this.options, | |
| required this.value, | |
| }); | |
| @override | |
| ProfileFieldType get type => ProfileFieldType.select; | |
| final Map<String, String>? options; | |
| final String? value; | |
| @override | |
| T map<T>({ | |
| required T Function(ProfileField$Text value) text, | |
| required T Function(ProfileField$Select value) select, | |
| required T Function(ProfileField$Enumeration value) enumeration, | |
| required T Function(ProfileField$Date value) date, | |
| }) => select(this); | |
| @override | |
| String toString() => 'ProfileField\$Select{id: $id, label: $label}'; | |
| } | |
| final class ProfileField$Enumeration extends ProfileField { | |
| const ProfileField$Enumeration({ | |
| required super.id, | |
| required super.label, | |
| required super.placeholder, | |
| required super.required, | |
| required this.value, | |
| }); | |
| @override | |
| ProfileFieldType get type => ProfileFieldType.enumeration; | |
| final List<String>? value; | |
| @override | |
| T map<T>({ | |
| required T Function(ProfileField$Text value) text, | |
| required T Function(ProfileField$Select value) select, | |
| required T Function(ProfileField$Enumeration value) enumeration, | |
| required T Function(ProfileField$Date value) date, | |
| }) => enumeration(this); | |
| @override | |
| String toString() => 'ProfileField\$Enumeration{id: $id, label: $label}'; | |
| } | |
| final class ProfileField$Date extends ProfileField { | |
| const ProfileField$Date({ | |
| required super.id, | |
| required super.label, | |
| required super.placeholder, | |
| required super.required, | |
| required this.value, | |
| }); | |
| @override | |
| ProfileFieldType get type => ProfileFieldType.date; | |
| final DateTime? value; | |
| @override | |
| T map<T>({ | |
| required T Function(ProfileField$Text value) text, | |
| required T Function(ProfileField$Select value) select, | |
| required T Function(ProfileField$Enumeration value) enumeration, | |
| required T Function(ProfileField$Date value) date, | |
| }) => date(this); | |
| @override | |
| String toString() => 'ProfileField\$Date{id: $id, label: $label}'; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "data": { | |
| "profile_id": "P_E35bKgX82ioMWRQ9x8V", | |
| "form_schema_link": "/v1/forms/profile/version/1", | |
| "form_data": { | |
| "name": "profile", | |
| "schema_version": "1", | |
| "profile_id": "P_E35bKgX82ioMWRQ9x8V", | |
| "locale": "en", | |
| "updated_at": "2025-09-26T12:47:57.501976Z", | |
| "layout": { | |
| "order": [ | |
| "basic", | |
| "body_diet", | |
| "health_profile" | |
| ] | |
| }, | |
| "sections": [ | |
| { | |
| "id": "basic", | |
| "title": "General Information", | |
| "description": null, | |
| "collapsible": true, | |
| "visible_if": null, | |
| "fields": [ | |
| { | |
| "id": "first_name", | |
| "type": "text", | |
| "label": "First name", | |
| "placeholder": "John", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": false, | |
| "value": null | |
| }, | |
| { | |
| "id": "last_name", | |
| "type": "text", | |
| "label": "Last name", | |
| "placeholder": "Doe", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": false, | |
| "value": null | |
| }, | |
| { | |
| "id": "sex", | |
| "type": "select", | |
| "label": "Sex", | |
| "placeholder": "Please select", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "options": { | |
| "male": "Male", | |
| "female": "Female", | |
| "other": "Other" | |
| }, | |
| "value": null | |
| }, | |
| { | |
| "id": "date_of_birth", | |
| "type": "date", | |
| "label": "Date of Birth", | |
| "placeholder": "YYYY-MM-DD", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "phonenumber", | |
| "type": "text", | |
| "label": "Phone number", | |
| "placeholder": "+xxx xxx xxx xxx", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": false, | |
| "value": null | |
| }, | |
| { | |
| "id": "email", | |
| "type": "text", | |
| "label": "Email", | |
| "placeholder": "[email protected]", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| }, | |
| { | |
| "id": "location", | |
| "type": "text", | |
| "label": "Location", | |
| "placeholder": "e.g. City, Country", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| } | |
| ] | |
| }, | |
| { | |
| "id": "body_diet", | |
| "title": "Body & Diet", | |
| "description": null, | |
| "collapsible": true, | |
| "visible_if": null, | |
| "fields": [ | |
| { | |
| "id": "height", | |
| "type": "text", | |
| "label": "Height", | |
| "placeholder": "e.g. 180 cm", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| }, | |
| { | |
| "id": "weight", | |
| "type": "text", | |
| "label": "Weight", | |
| "placeholder": "e.g. 75 kg", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| }, | |
| { | |
| "id": "menstrual_cycle", | |
| "type": "text", | |
| "label": "Menstrual Cycle", | |
| "placeholder": "e.g. Regular, Irregular", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| }, | |
| { | |
| "id": "dietary_restrictions", | |
| "type": "select", | |
| "label": "Dietary Restrictions", | |
| "placeholder": "Please select", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "options": { | |
| "none": "None", | |
| "vegetarian": "Vegetarian", | |
| "vegan": "Vegan", | |
| "gluten_free": "Gluten Free" | |
| }, | |
| "value": null | |
| }, | |
| { | |
| "id": "bmi", | |
| "type": "text", | |
| "label": "Body Mass Index (BMI)", | |
| "placeholder": "e.g. 24.5", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "readonly": true, | |
| "value": null | |
| } | |
| ] | |
| }, | |
| { | |
| "id": "health_profile", | |
| "title": "Health Profile", | |
| "description": null, | |
| "collapsible": true, | |
| "visible_if": null, | |
| "fields": [ | |
| { | |
| "id": "chronic_illnesses", | |
| "type": "enumeration", | |
| "label": "Chronic Illnesses", | |
| "placeholder": "e.g. Diabetes, Hypertension", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "past_illnesses", | |
| "type": "enumeration", | |
| "label": "Past Illnesses", | |
| "placeholder": "e.g. Chickenpox", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "surgical_history", | |
| "type": "enumeration", | |
| "label": "Surgical History", | |
| "placeholder": "e.g. Appendectomy", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "occasional_medications", | |
| "type": "enumeration", | |
| "label": "Occasionally used Medications", | |
| "placeholder": "e.g. Aspirin, Metformin", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "regular_medications", | |
| "type": "enumeration", | |
| "label": "Regular Medications", | |
| "placeholder": "e.g. Ibuprofen", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "allergies", | |
| "type": "enumeration", | |
| "label": "Allergies", | |
| "placeholder": "e.g. Peanuts, Shellfish", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "special_conditions", | |
| "type": "enumeration", | |
| "label": "Special Conditions", | |
| "placeholder": "e.g. Pregnancy, Disability", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "family_history", | |
| "type": "enumeration", | |
| "label": "Family History", | |
| "placeholder": "e.g. Heart Disease, Cancer", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| }, | |
| { | |
| "id": "social_lifestyle_factors", | |
| "type": "enumeration", | |
| "label": "Social & Lifestyle Factors", | |
| "placeholder": "e.g. Smoking, Alcohol consumption", | |
| "required": false, | |
| "widget": null, | |
| "visible_if": null, | |
| "value": null | |
| } | |
| ] | |
| } | |
| ] | |
| }, | |
| "updated_at": "2025-09-26T12:47:57.501976Z" | |
| }, | |
| "status": "ok" | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:collection'; | |
| import 'dart:convert'; | |
| import 'package:doctorina/src/profile/model/profile_data.dart'; | |
| import 'package:l/l.dart'; | |
| /// JSON decoder for ProfileData | |
| /// Converts a JSON map to a [ProfileData] object. | |
| /// Handles nested structures and type conversions. | |
| /// See also [ProfileSectionJsonDecoder] and [ProfileFieldJsonDecoder]. | |
| class ProfileDataJsonDecoder extends Converter<Map<String, Object?>, ProfileData> { | |
| const ProfileDataJsonDecoder(); | |
| @override | |
| ProfileData convert(Map<String, Object?> input) { | |
| const sectionDecoder = ProfileSectionJsonDecoder(); | |
| List<ProfileSection> sections; | |
| { | |
| final $sections = switch (input['sections']) { | |
| List<Object?> list => list, | |
| _ => const <Object?>[], | |
| }; | |
| var valid = true; | |
| final $sectionList = List<ProfileSection?>.filled($sections.length, null, growable: false); | |
| for (var i = 0; i < $sections.length; i++) { | |
| try { | |
| if ($sections[i] case Map<String, Object?> sectionMap) { | |
| $sectionList[i] = sectionDecoder.convert(sectionMap); | |
| } else { | |
| throw FormatException('Invalid section format: ${$sections[i]}'); | |
| } | |
| } on Object catch (e, s) { | |
| l.w('Failed to decode section at index $i: $e', s); | |
| valid = false; | |
| } | |
| } | |
| sections = | |
| valid | |
| ? List<ProfileSection>.unmodifiable($sectionList) | |
| : List<ProfileSection>.unmodifiable($sectionList.whereType<ProfileSection>()); | |
| final layoutOrder = switch (input['layout']) { | |
| <String, Object?>{'order': List<Object?> order} => order.whereType<String>().toList(growable: false), | |
| _ => const <String>[], | |
| }; | |
| if (sections.isNotEmpty && layoutOrder.length == sections.length) { | |
| final layoutOrderSet = layoutOrder.toSet(); | |
| final sectionsIds = sections.map((e) => e.id).toSet(); | |
| if (layoutOrderSet.length == sectionsIds.length && layoutOrderSet.containsAll(sectionsIds)) { | |
| sections.sort((a, b) => layoutOrder.indexOf(a.id).compareTo(layoutOrder.indexOf(b.id))); | |
| } | |
| } | |
| } | |
| return ProfileData( | |
| schemaVersion: input['schema_version'], | |
| profileId: switch (input['profile_id'] ?? input['id']) { | |
| String str => str, | |
| _ => throw const FormatException('Invalid profile ID format'), | |
| }, | |
| locale: switch (input['locale']) { | |
| String str => str, | |
| _ => 'en', | |
| }, | |
| updatedAt: | |
| switch (input['updated_at']) { | |
| String str => DateTime.tryParse(str), | |
| DateTime dateTime => dateTime, | |
| int timestamp => DateTime.fromMillisecondsSinceEpoch(timestamp), | |
| _ => null, | |
| } ?? | |
| DateTime.now(), | |
| sections: sections, | |
| ); | |
| } | |
| } | |
| /// JSON decoder for ProfileSection | |
| /// Converts a JSON map to a [ProfileSection] object. | |
| /// Handles nested structures and type conversions. | |
| /// See also [ProfileDataJsonDecoder] and [ProfileFieldJsonDecoder]. | |
| class ProfileSectionJsonDecoder extends Converter<Map<String, Object?>, ProfileSection> { | |
| const ProfileSectionJsonDecoder(); | |
| @override | |
| ProfileSection convert(Map<String, Object?> input) { | |
| const fieldDecoder = ProfileFieldJsonDecoder(); | |
| if (input case { | |
| 'id': String $id, | |
| 'title': String $title, | |
| 'description': String? $description, | |
| 'collapsible': bool? $collapsible, | |
| 'fields': List<Object?> $fields, | |
| }) { | |
| var valid = true; | |
| var fields = List<ProfileField?>.filled($fields.length, null, growable: false); | |
| for (var i = 0; i < $fields.length; i++) { | |
| try { | |
| if ($fields[i] case Map<String, Object?> fieldMap) { | |
| fields[i] = fieldDecoder.convert(fieldMap); | |
| } else { | |
| throw FormatException('Invalid field format: ${$fields[i]}'); | |
| } | |
| } on Object catch (e, s) { | |
| l.w('Failed to decode field at index $i in section ${$id}: $e', s); | |
| valid = false; | |
| } | |
| } | |
| return ProfileSection( | |
| id: $id, | |
| title: $title, | |
| description: $description, | |
| collapsible: $collapsible ?? false, | |
| //visibleIf: input['visible_if'], | |
| fields: | |
| valid | |
| ? List<ProfileField>.unmodifiable(fields) | |
| : List<ProfileField>.unmodifiable(fields.whereType<ProfileField>()), | |
| ); | |
| } else { | |
| throw FormatException('Invalid section format: $input'); | |
| } | |
| } | |
| } | |
| /// JSON decoder for ProfileField | |
| /// Converts a JSON map to a [ProfileField] object. | |
| /// Handles different field types and their specific properties. | |
| /// See also [ProfileDataJsonDecoder] and [ProfileSectionJsonDecoder]. | |
| class ProfileFieldJsonDecoder extends Converter<Map<String, Object?>, ProfileField> { | |
| const ProfileFieldJsonDecoder(); | |
| @override | |
| ProfileField convert(Map<String, Object?> input) { | |
| if (input case { | |
| 'id': String $id, | |
| 'label': String $label, | |
| 'placeholder': String? $placeholder, | |
| 'required': bool? $required, | |
| 'type': String $type, | |
| 'value': Object? $value, | |
| }) { | |
| switch ($type) { | |
| case 'text': | |
| case 'string': | |
| case 'input': | |
| case 'textfield': | |
| case 'multiline': | |
| case 'multi_line': | |
| case 'longtext': | |
| case 'long_text': | |
| case 'paragraph': | |
| case 'note': | |
| return ProfileField$Text( | |
| id: $id, | |
| label: $label, | |
| placeholder: $placeholder ?? '', | |
| required: $required ?? false, | |
| value: switch ($value) { | |
| String str => str, | |
| _ => null, | |
| }, | |
| ); | |
| case 'select': | |
| case 'dropdown': | |
| case 'option': | |
| case 'options': | |
| case 'choice': | |
| case 'choices': | |
| return ProfileField$Select( | |
| id: input['id'] as String, | |
| label: input['label'] as String, | |
| placeholder: input['placeholder'] as String? ?? '', | |
| required: input['required'] as bool? ?? false, | |
| options: switch (input['options']) { | |
| Map<String, String> map => UnmodifiableMapView<String, String>(map), | |
| Map<String, Object?> map => UnmodifiableMapView<String, String>(<String, String>{ | |
| for (final MapEntry<String, Object?>(:key, :value) in map.entries) | |
| if (value case String valueString) key: valueString, | |
| }), | |
| Map<Object?, Object?> map => UnmodifiableMapView<String, String>(<String, String>{ | |
| for (final MapEntry<Object?, Object?>(:key, :value) in map.entries) | |
| if (key case String keyString) | |
| if (value case String valueString) keyString: valueString, | |
| }), | |
| _ => null, | |
| }, | |
| value: switch ($value) { | |
| String str => str, | |
| _ => null, | |
| }, | |
| ); | |
| case 'enumeration': | |
| case 'multiselect': | |
| case 'multi_select': | |
| case 'multiple': | |
| case 'list': | |
| case 'tags': | |
| case 'enums': | |
| return ProfileField$Enumeration( | |
| id: $id, | |
| label: $label, | |
| placeholder: $placeholder ?? '', | |
| required: $required ?? false, | |
| value: switch ($value) { | |
| List<String> list => UnmodifiableListView<String>(list), | |
| Iterable<Object?> list => List<String>.unmodifiable(list.whereType<String>()), | |
| _ => null, | |
| }, | |
| ); | |
| case 'date': | |
| case 'datetime': | |
| case 'time': | |
| case 'timestamp': | |
| case 'day': | |
| case 'birthday': | |
| case 'birthdate': | |
| case 'calendar': | |
| return ProfileField$Date( | |
| id: $id, | |
| label: $label, | |
| placeholder: $placeholder ?? '', | |
| required: $required ?? false, | |
| value: switch ($value) { | |
| String str => DateTime.tryParse(str), | |
| DateTime dateTime => dateTime, | |
| int timestamp => DateTime.fromMillisecondsSinceEpoch(timestamp), | |
| _ => null, | |
| }, | |
| ); | |
| default: | |
| throw UnimplementedError('Unsupported field type: ${$type}'); | |
| } | |
| } else { | |
| throw FormatException('Invalid field format: $input'); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:doctorina/src/profile/controller/profile_form_controller.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| /// {@template inherited_profile_form} | |
| /// InheritedProfileForm widget. | |
| /// {@endtemplate} | |
| class InheritedProfileForm extends InheritedNotifier<ProfileFormController> { | |
| /// {@macro inherited_profile_form} | |
| const InheritedProfileForm({ | |
| required this.form, | |
| required super.child, | |
| super.key, // ignore: unused_element | |
| }) : super(notifier: form); | |
| /// The [ProfileFormController] to manage the state of the profile form. | |
| final ProfileFormController form; | |
| /// The state from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| /// e.g. `ProfileFieldBuilder.maybeOf(context)`. | |
| static ProfileFormController? maybeOf(BuildContext context, {bool listen = true}) => | |
| listen | |
| ? context.dependOnInheritedWidgetOfExactType<InheritedProfileForm>()?.form | |
| : context.getInheritedWidgetOfExactType<InheritedProfileForm>()?.form; | |
| static Never _notFoundInheritedWidgetOfExactType() => | |
| throw ArgumentError( | |
| 'Out of scope, not found inherited widget ' | |
| 'a InheritedProfileForm of the exact type', | |
| 'out_of_scope', | |
| ); | |
| /// The state from the closest instance of this class | |
| /// that encloses the given context. | |
| /// e.g. `ProfileFieldBuilder.of(context)` | |
| static ProfileFormController of(BuildContext context, {bool listen = true}) => | |
| maybeOf(context, listen: listen) ?? _notFoundInheritedWidgetOfExactType(); | |
| } | |
| /// {@template profile_field_builder} | |
| /// ProfileFieldBuilder widget responsible for locating or creating a [ProfileFieldController]. | |
| /// [section] and [field] are identifiers for the profile section and field respectively. | |
| /// The [create] function is called only if the controller does not already exist. | |
| /// The optional [changed] callback can be used to determine if the controller's data has changed. | |
| /// The [builder] function is used to build the widget tree based on the current state of the profile field. | |
| /// The optional [form] parameter can be used to provide a specific [ProfileFormController]. | |
| /// {@endtemplate} | |
| class ProfileFieldBuilder<Value extends Object?, Controller extends ProfileFieldController<Value>> | |
| extends StatefulWidget { | |
| /// {@macro profile_field_builder} | |
| const ProfileFieldBuilder({ | |
| required this.section, | |
| required this.field, | |
| required this.create, | |
| required this.builder, | |
| this.changed, | |
| this.form, | |
| super.key, | |
| }); | |
| /// The section identifier of the profile field. | |
| final String section; | |
| /// The field identifier of the profile field. | |
| final String field; | |
| /// A function that creates a new instance of the [Controller]. | |
| final Controller Function(int index) create; | |
| /// A function that determines if the profile field has changed. | |
| final bool Function(Value prev, Value next)? changed; | |
| /// The [ProfileFormController] to manage the state of the profile form. | |
| final ProfileFormController? form; | |
| /// A builder function that builds the widget tree | |
| /// based on the current state of the profile field. | |
| final Widget Function(BuildContext context, Controller controller) builder; | |
| @override | |
| State<ProfileFieldBuilder<Value, Controller>> createState() => _ProfileFieldBuilderState<Value, Controller>(); | |
| } | |
| /// State for widget ProfileFieldBuilder. | |
| class _ProfileFieldBuilderState<Value extends Object?, Controller extends ProfileFieldController<Value>> | |
| extends State<ProfileFieldBuilder<Value, Controller>> { | |
| int _index = 0; // ignore: unused_field | |
| late Controller _controller; | |
| void _onChanged() {} | |
| Controller _create(int index) { | |
| final controller = widget.create(index); | |
| _index = index; | |
| assert(controller.$section == widget.section); | |
| assert(controller.$field == widget.field); | |
| return controller; | |
| } | |
| /* #region Lifecycle */ | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = switch (widget.form) { | |
| null => InheritedProfileForm.of(context, listen: false), | |
| final form => form, | |
| }.locate<Value, Controller>( | |
| section: widget.section, | |
| field: widget.field, | |
| create: _create, | |
| changed: widget.changed, | |
| )..addListener(_onChanged); | |
| } | |
| @override | |
| void didUpdateWidget(covariant ProfileFieldBuilder<Value, Controller> oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (!identical(oldWidget.form, widget.form) || | |
| oldWidget.section != widget.section || | |
| oldWidget.field != widget.field) { | |
| _controller.removeListener(_onChanged); | |
| _controller = switch (widget.form) { | |
| null => InheritedProfileForm.of(context, listen: false), | |
| final form => form, | |
| }.locate<Value, Controller>( | |
| section: widget.section, | |
| field: widget.field, | |
| create: _create, | |
| changed: widget.changed, | |
| )..addListener(_onChanged); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _controller.removeListener(_onChanged); | |
| super.dispose(); | |
| } | |
| /* #endregion */ | |
| @override | |
| Widget build(BuildContext context) => widget.builder(context, _controller); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:collection' show UnmodifiableMapView, UnmodifiableSetView; | |
| import 'dart:math' as math show max; | |
| import 'dart:typed_data' show Uint32List; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/widgets.dart' | |
| show ChangeNotifier, TextEditingController, ValueNotifier, protected, visibleForTesting; | |
| /// Controller for managing profile fields and their state. | |
| /// This controller tracks changes to profile fields and notifies listeners | |
| /// when any field is modified. It supports locating or creating field controllers | |
| /// as needed, and maintains an internal state to efficiently track modifications. | |
| class ProfileFormController with ChangeNotifier { | |
| /// Initializes a new instance of [ProfileFormController]. | |
| ProfileFormController({int capacity = 32}) | |
| : _indexes = <String, Map<String, int>>{}, | |
| _controllers = List.filled(capacity, null, growable: false), | |
| _sourceData = List.filled(capacity, null, growable: false), | |
| _flags = Uint32List((capacity / 32).ceil()); | |
| /// Indicates whether the controller has been disposed. | |
| bool _disposed = false; | |
| /// Indicates whether any of the profile fields have been modified. | |
| /// If true, at least one field has changed since the last reset or initialization. | |
| /// And false if all fields are unchanged. | |
| bool _dirty = false; | |
| /// Returns true if any controllers have changes. | |
| bool get hasChanges => _dirty; | |
| /// Returns the number of controllers currently managed. | |
| int get controllerCount => _indexCounter + 1; | |
| /// Internal counter for tracking the number of controllers. | |
| int _indexCounter = -1; | |
| /// Generates the next index for storing a new controller. | |
| int _nextIndex() { | |
| final idx = ++_indexCounter; | |
| if (_controllers.length <= idx) _increaseCapacity(); | |
| return idx; | |
| } | |
| /// Doubles the capacity of internal storage lists. | |
| void _increaseCapacity() { | |
| final newSize = math.max(_controllers.length << 1, 64); | |
| _controllers = List.filled(newSize, null, growable: false)..setAll(0, _controllers); | |
| _sourceData = List.filled(newSize, null, growable: false)..setAll(0, _sourceData); | |
| _flags = Uint32List((newSize / 32).ceil())..setAll(0, _flags); | |
| } | |
| /// Maps section and field identifiers to their respective controller indices. | |
| Map<String, Map<String, int>> _indexes; | |
| /// List of current field controllers. | |
| List<ProfileFieldController<Object?>?> _controllers; | |
| /// Initial data of field controllers. | |
| /// This is a snapshot of the data when the controller was created. | |
| /// It is used to restore the initial state if needed. | |
| /// Also used to detect changes by comparing current data against this snapshot. | |
| /// After commitChanges() is called, this is updated to match the current data. | |
| List<Object?> _sourceData; | |
| /// Bit flags representing the dirty state of each controller. | |
| /// Each 32-bit integer can store flags for 32 controllers. | |
| Uint32List _flags; | |
| /// Sets the dirty flag for the controller at the given index. | |
| /// Notifies listeners if the overall dirty state changes from false to true. | |
| void _setDirtyFlag(int index) { | |
| final wordIndex = index ~/ 32; | |
| assert(wordIndex < _flags.length, 'Index $index out of bounds'); | |
| if (wordIndex >= _flags.length) return; // Out of bounds, ignore | |
| final current = _flags[wordIndex]; | |
| final bitIndex = index % 32; | |
| final bitMask = 1 << bitIndex; // Bit mask for this index | |
| if ((current & bitMask) != 0) return; // Already dirty, no change | |
| _flags[wordIndex] |= bitMask; // Mark as dirty | |
| _dirty = true; | |
| notifyListeners(); // Notify listeners of the change | |
| } | |
| /// Clears the dirty flag for the controller at the given index. | |
| /// Notifies listeners if the overall dirty state changes from true to false. | |
| void _clearDirtyFlag(int index) { | |
| final wordIndex = index ~/ 32; | |
| assert(wordIndex < _flags.length, 'Index $index out of bounds'); | |
| if (wordIndex >= _flags.length) return; // Out of bounds, ignore | |
| final bitIndex = index % 32; | |
| _flags[wordIndex] &= ~(1 << bitIndex); | |
| if (!_dirty) return; // No change in dirty state | |
| if (_flags.every((f) => f == 0)) _dirty = false; // All flags cleared | |
| notifyListeners(); | |
| } | |
| /// Returns true if the controller at the given index is marked as dirty. | |
| @protected | |
| @visibleForTesting | |
| bool isDirtyFlag(int index) { | |
| final wordIndex = index ~/ 32; | |
| assert(wordIndex < _flags.length, 'Index $index out of bounds'); | |
| if (wordIndex >= _flags.length) return false; | |
| final bitIndex = index % 32; | |
| return (_flags[wordIndex] & (1 << bitIndex)) != 0; | |
| } | |
| /// Returns true if the controller at the given index is marked as dirty. | |
| @protected | |
| @visibleForTesting | |
| bool isDirtyField(String section, String field) { | |
| final idx = _indexes[section]?[field]; // Get the index for the section/field | |
| if (idx == null) return false; // No controller exists for this section/field | |
| return isDirtyFlag(idx); | |
| } | |
| /// Returns a list of indices of controllers that have been modified. | |
| @protected | |
| @visibleForTesting | |
| List<int> get changedIndexes { | |
| if (!_dirty) return const []; // No changes to report | |
| final result = <int>[]; | |
| for (var flagIdx = 0; flagIdx < _flags.length; flagIdx++) { | |
| final f = _flags[flagIdx]; | |
| if (f == 0) continue; // Skip if no bits are set in this flag | |
| for (var bitIdx = 0; bitIdx < 32; bitIdx++) { | |
| if ((f & (1 << bitIdx)) == 0) continue; // Skip if this bit is not set | |
| final index = flagIdx * 32 + bitIdx; | |
| if (index <= _indexCounter) result.add(index); | |
| } | |
| } | |
| return result; | |
| } | |
| /// Returns a map of changes made to the profile fields. | |
| /// The outer map's keys are section identifiers, and the inner maps' keys are field identifiers. | |
| /// The values in the inner maps are the current data of the modified fields. | |
| Map<String, Map<String, Object?>> get changes { | |
| if (!_dirty) return const {}; // No changes to report | |
| final result = <String, Map<String, Object?>>{}; | |
| for (var flagIdx = 0; flagIdx < _flags.length; flagIdx++) { | |
| final f = _flags[flagIdx]; | |
| if (f == 0) continue; // Skip if no bits are set in this flag | |
| for (var bitIdx = 0; bitIdx < 32; bitIdx++) { | |
| if ((f & (1 << bitIdx)) == 0) continue; // Skip if this bit is not set | |
| final index = flagIdx * 32 + bitIdx; | |
| final controller = _controllers[index]; | |
| if (controller == null) continue; // Skip if no controller exists at this index | |
| final ProfileFieldController<Object?>($section: section, $field: field, $data: data) = controller; | |
| result.putIfAbsent(section, () => <String, Object?>{})[field] = data; | |
| } | |
| } | |
| return UnmodifiableMapView<String, Map<String, Object?>>(result); | |
| } | |
| /// Commits all changes by updating the previous state to match the current state. | |
| /// Clears the dirty flags for all modified controllers. | |
| void commitChanges() { | |
| if (!_dirty) return; // No changes to commit | |
| for (final index in changedIndexes) _sourceData[index] = _controllers[index]?.$data; | |
| _flags.fillRange(0, _flags.length, 0); | |
| _dirty = false; | |
| notifyListeners(); | |
| } | |
| /// Retrieves the initial data of the field at the given [index]. | |
| /// Returns null if the index is out of bounds or if no data exists at that index. | |
| Value? sourceIndexData<Value>(int index) { | |
| if (index < 0 || index >= _sourceData.length) return null; // Index out of bounds | |
| return switch (_sourceData[index]) { | |
| Value value => value, | |
| _ => null, | |
| }; | |
| } | |
| /// Retrieves the initial data of the field at the given [section] and [field]. | |
| /// Returns null if no data exists for the specified section and field. | |
| Value? sourceFieldData<Value>(String section, String field) { | |
| final idx = _indexes[section]?[field]; // Get the index for the section/field | |
| if (idx == null) return null; // No controller exists for this section/field | |
| if (idx < 0 || idx >= _sourceData.length) return null; // Index out of bounds | |
| return switch (_sourceData[idx]) { | |
| Value value => value, | |
| _ => null, | |
| }; | |
| } | |
| /// Locate or create a [ProfileFieldController] for the given [section] and [field]. | |
| /// [section] and [field] are identifiers for the profile section and field respectively. | |
| /// The [create] function is called only if the controller does not already exist. | |
| /// The optional [changed] callback can be used to determine if the controller's data has changed. | |
| Controller locate<Value extends Object?, Controller extends ProfileFieldController<Value>>({ | |
| required String section, | |
| required String field, | |
| required Controller Function(int index) create, | |
| bool Function(Value prev, Value next)? changed, | |
| }) { | |
| final int idx; | |
| { | |
| var i = _indexes[section]?[field]; | |
| if (i == null) { | |
| idx = _nextIndex(); | |
| _indexes.putIfAbsent(section, () => <String, int>{})[field] = idx; | |
| } else { | |
| idx = i; | |
| } | |
| } | |
| final $changed = changed ?? (prev, next) => prev != next; | |
| switch (_controllers[idx]) { | |
| case Controller controller: | |
| return controller; | |
| case null: | |
| if (_disposed) throw StateError('Cannot locate controller after dispose() has been called'); | |
| final controller = _controllers[idx] = create(idx); | |
| final initialData = _sourceData[idx] = controller.$data; | |
| controller.addListener(() { | |
| if (_disposed) return; | |
| final next = controller.$data; | |
| final prev = switch (_sourceData[idx]) { | |
| Value value => value, | |
| _ => initialData, | |
| }; | |
| if (identical(prev, next) || !$changed(prev, next)) { | |
| // No actual change in data detected | |
| _clearDirtyFlag(idx); | |
| } else { | |
| // Data has changed | |
| _setDirtyFlag(idx); | |
| } | |
| }); | |
| return controller; | |
| default: | |
| throw StateError( | |
| 'Incompatible controller type stored at index $idx: ' | |
| 'expected ${Controller.runtimeType}, found ${_controllers[idx]?.runtimeType}', | |
| ); | |
| } | |
| } | |
| /// Clears all controllers and resets the state of the [ProfileFormController]. | |
| void clear() { | |
| if (_disposed) throw StateError('Cannot clear controllers after dispose() has been called'); | |
| for (var i = 0; i <= _indexCounter; i++) if (_controllers[i] case ChangeNotifier controller) controller.dispose(); | |
| _indexes = <String, Map<String, int>>{}; | |
| final capacity = math.max(_controllers.length, 32); | |
| _controllers = List.filled(capacity, null, growable: false); | |
| _sourceData = List.filled(capacity, null, growable: false); | |
| _flags = Uint32List((capacity / 32).ceil()); | |
| _indexCounter = -1; | |
| _dirty = false; | |
| notifyListeners(); | |
| } | |
| /// Disposes the controller and all its managed field controllers. | |
| @override | |
| void dispose() { | |
| _disposed = true; | |
| for (var i = 0; i <= _indexCounter; i++) if (_controllers[i] case ChangeNotifier controller) controller.dispose(); | |
| super.dispose(); | |
| } | |
| } | |
| /// Interface for profile field controllers. | |
| /// Provides common properties and methods for accessing field data. | |
| abstract interface class ProfileFieldController<Value> implements Listenable { | |
| /// The identifier of the profile section. | |
| abstract final String $section; | |
| /// The identifier of the profile field. | |
| abstract final String $field; | |
| /// Retrieves the current data of the field. | |
| Value get $data; | |
| } | |
| /// Controller for text profile fields. | |
| class ProfileFieldController$Text extends TextEditingController implements ProfileFieldController<String> { | |
| ProfileFieldController$Text({required String section, required String field, super.text}) | |
| : $section = section, | |
| $field = field; | |
| @override | |
| final String $section; | |
| @override | |
| final String $field; | |
| @override | |
| String get $data => super.text; | |
| } | |
| /// Controller for select profile fields. | |
| class ProfileFieldController$Select extends ValueNotifier<String?> implements ProfileFieldController<String?> { | |
| ProfileFieldController$Select({required String section, required String field, String? value}) | |
| : $section = section, | |
| $field = field, | |
| super(value); | |
| @override | |
| final String $section; | |
| @override | |
| final String $field; | |
| @override | |
| String? get $data => value; | |
| } | |
| /// Controller for enumeration profile fields. | |
| class ProfileFieldController$Enumeration with ChangeNotifier implements ProfileFieldController<Set<String>> { | |
| ProfileFieldController$Enumeration({required String section, required String field, Set<String>? values}) | |
| : $section = section, | |
| $field = field, | |
| _values = UnmodifiableSetView<String>(values ?? const <String>{}); | |
| @override | |
| final String $section; | |
| @override | |
| final String $field; | |
| @override | |
| Set<String> get $data => _values; | |
| /// The current set of selected values in the enumeration. | |
| Set<String> _values; | |
| /// Adds a value to the enumeration. | |
| void add(String value) { | |
| if (_values.contains(value)) return; | |
| final copy = Set<String>.from(_values); | |
| copy.add(value); | |
| _values = UnmodifiableSetView<String>(copy); | |
| notifyListeners(); | |
| } | |
| /// Removes a value from the enumeration. | |
| void remove(String value) { | |
| if (!_values.contains(value)) return; | |
| final copy = Set<String>.from(_values); | |
| copy.remove(value); | |
| _values = UnmodifiableSetView<String>(copy); | |
| notifyListeners(); | |
| } | |
| /// Sets the entire set of selected values in the enumeration. | |
| void setAll(Iterable<String> values) { | |
| final newValues = UnmodifiableSetView<String>(Set<String>.from(values)); | |
| if (setEquals(_values, newValues)) return; | |
| _values = newValues; | |
| notifyListeners(); | |
| } | |
| } | |
| /// Controller for date profile fields. | |
| class ProfileFieldController$Date extends ValueNotifier<DateTime?> implements ProfileFieldController<DateTime?> { | |
| ProfileFieldController$Date({required String section, required String field, DateTime? value}) | |
| : $section = section, | |
| $field = field, | |
| super(value); | |
| @override | |
| final String $section; | |
| @override | |
| final String $field; | |
| @override | |
| DateTime? get $data => value; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment