first commit for mih package toolkit
This commit is contained in:
BIN
lib/src/fonts/Mih_Icons.ttf
Normal file
BIN
lib/src/fonts/Mih_Icons.ttf
Normal file
Binary file not shown.
1
lib/src/fonts/icomoon_link.txt
Normal file
1
lib/src/fonts/icomoon_link.txt
Normal file
@@ -0,0 +1 @@
|
||||
/* Mih Icons - https://icomoon.io/*/
|
||||
99
lib/src/fonts/style.css
Normal file
99
lib/src/fonts/style.css
Normal file
@@ -0,0 +1,99 @@
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?8flwgj');
|
||||
src: url('fonts/icomoon.eot?8flwgj#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?8flwgj') format('truetype'),
|
||||
url('fonts/icomoon.woff?8flwgj') format('woff'),
|
||||
url('fonts/icomoon.svg?8flwgj#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"],
|
||||
[class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'icomoon' !important;
|
||||
/* speak: never; */
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-mine_sweeper:before {
|
||||
content: "\e900";
|
||||
}
|
||||
|
||||
.icon-mzansi_directory:before {
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
.icon-personal_profile:before {
|
||||
content: "\e902";
|
||||
}
|
||||
|
||||
.icon-about_mih:before {
|
||||
content: "\e903";
|
||||
}
|
||||
|
||||
.icon-access_control:before {
|
||||
content: "\e904";
|
||||
}
|
||||
|
||||
.icon-business_profile:before {
|
||||
content: "\e905";
|
||||
}
|
||||
|
||||
.icon-business_setup:before {
|
||||
content: "\e906";
|
||||
}
|
||||
|
||||
.icon-calculator:before {
|
||||
content: "\e907";
|
||||
}
|
||||
|
||||
.icon-calendar:before {
|
||||
content: "\e908";
|
||||
}
|
||||
|
||||
.icon-i_dont_know:before {
|
||||
content: "\e909";
|
||||
}
|
||||
|
||||
.icon-mih_logo:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
|
||||
.icon-mih_ring:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
|
||||
.icon-mzansi_ai:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
|
||||
.icon-mzansi_wallet:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
|
||||
.icon-notifications:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
|
||||
.icon-patient_manager:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
|
||||
.icon-patient_profile:before {
|
||||
content: "\e910";
|
||||
}
|
||||
|
||||
.icon-profile_setup:before {
|
||||
content: "\e911";
|
||||
}
|
||||
110
lib/src/mih_button.dart
Normal file
110
lib/src/mih_button.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A custom stylized button component from the MIH Toolkit.
|
||||
///
|
||||
/// The [MihButton] provides a consistent look and feel with built-in
|
||||
/// support for elevations, custom border radii, and automatic color
|
||||
/// shading for hover and splash effects.
|
||||
///
|
||||
/// It automatically handles its disabled state by reducing opacity and
|
||||
/// removing elevation when [onPressed] is null.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihButton(
|
||||
/// buttonColor: Colors.blue,
|
||||
/// onPressed: () => print('Button Tapped'),
|
||||
/// child: Text('Click Me'),
|
||||
/// )
|
||||
/// ```
|
||||
class MihButton extends StatelessWidget {
|
||||
/// The callback that is called when the button is tapped or otherwise activated.
|
||||
///
|
||||
/// If this is null, the button will be disabled.
|
||||
final void Function()? onPressed;
|
||||
|
||||
/// The callback that is called when the button is long-pressed.
|
||||
final void Function()? onLongPressed;
|
||||
|
||||
/// The background color of the button.
|
||||
final Color buttonColor;
|
||||
|
||||
/// The horizontal extent of the button.
|
||||
///
|
||||
/// If null, the button will wrap its content with default padding.
|
||||
final double? width;
|
||||
|
||||
/// The vertical extent of the button.
|
||||
final double? height;
|
||||
|
||||
/// The radius of the button's corners. Defaults to 25.0.
|
||||
final double? borderRadius;
|
||||
|
||||
/// The z-coordinate at which to place this button relative to its parent.
|
||||
///
|
||||
/// Defaults to 4.0 when enabled, and 0.0 when disabled.
|
||||
final double? elevation;
|
||||
|
||||
/// The widget below this widget in the tree. Typically a [Text] or [Icon].
|
||||
final Widget child;
|
||||
|
||||
const MihButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
this.onLongPressed,
|
||||
required this.buttonColor,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.elevation,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// Internal helper to calculate a darker shade of the [buttonColor]
|
||||
/// for ripples and hover states.
|
||||
Color _darkerColor(Color color, [double amount = .1]) {
|
||||
final hsl = HSLColor.fromColor(color);
|
||||
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
|
||||
return hslDark.toColor();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color effectiveButtonColor = onPressed == null
|
||||
? buttonColor.withValues(alpha: 0.6)
|
||||
: buttonColor;
|
||||
final Color rippleColor = _darkerColor(effectiveButtonColor, 0.1);
|
||||
final double radius = borderRadius ?? 25.0;
|
||||
final double effectiveElevation = onPressed == null
|
||||
? 0.0
|
||||
: (elevation ?? 4.0);
|
||||
return MouseRegion(
|
||||
cursor: onPressed == null
|
||||
? SystemMouseCursors.basic
|
||||
: SystemMouseCursors.click,
|
||||
child: Material(
|
||||
color: effectiveButtonColor,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
elevation: effectiveElevation,
|
||||
shadowColor: Colors.black,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
splashColor: rippleColor,
|
||||
highlightColor: rippleColor.withValues(alpha: 0.2),
|
||||
hoverColor: rippleColor.withValues(alpha: 0.3),
|
||||
onTap: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: (width == null || height == null)
|
||||
? const EdgeInsets.symmetric(horizontal: 24, vertical: 12)
|
||||
: null,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/src/mih_colors.dart
Normal file
168
lib/src/mih_colors.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centralized design token class for the MIH Toolkit color palette.
|
||||
///
|
||||
/// [MihColors] provides static methods to retrieve consistent brand colors
|
||||
/// that adapt based on the application's brightness (Dark Mode vs Light Mode).
|
||||
///
|
||||
/// Most methods accept an optional `darkMode` boolean. If not provided,
|
||||
/// the color defaults to its **Dark Mode** variant.
|
||||
class MihColors {
|
||||
/// Toggle flag for internal theme variations (e.g., Women4Change).
|
||||
bool women4Change = true;
|
||||
|
||||
/// Returns the primary brand color.
|
||||
///
|
||||
/// Dark: `0XFF3A4454` | Light: `0XFFbedcfe`
|
||||
static Color primary({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0XFF3A4454); // Original
|
||||
// return const Color(0XFF6641b2); // Women4change
|
||||
} else {
|
||||
return const Color(0XFFbedcfe); // Original
|
||||
// return const Color(0xFFE0D1FF); // Women4change
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the secondary brand color.
|
||||
///
|
||||
/// Dark: `0XFFbedcfe` | Light: `0XFF3A4454`
|
||||
static Color secondary({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0XFFbedcfe); // Original
|
||||
// return const Color(0xFFE0D1FF); // Women4change
|
||||
} else {
|
||||
return const Color(0XFF3A4454); // Original
|
||||
// return const Color(0XFF6641b2); // Women4change
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an inverted version of the secondary color for high-contrast elements.
|
||||
static Color secondaryInverted({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0XFF412301); // Original
|
||||
// return const Color(0XFF1f2e00); // Women4change
|
||||
} else {
|
||||
return const Color(0XFFc5bbab); // Original
|
||||
// return const Color(0XFF99be4d); // Women4change
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the highlight/accent color used to draw attention to specific UI elements.
|
||||
static Color highlight({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0XFF9bc7fa);
|
||||
// return const Color(0xFFC8AFFB); // Women4change
|
||||
} else {
|
||||
return const Color(0XFF354866);
|
||||
// return const Color(0XFF6641b2); // Women4change
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a neutral grey shade.
|
||||
static Color grey({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0XFFc8c8c8);
|
||||
} else {
|
||||
return const Color(0XFF747474);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic green color typically used for success states or positive indicators.
|
||||
static Color green({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xff8ae290);
|
||||
} else {
|
||||
return const Color(0xFF41B349);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic red color typically used for error states, warnings, or deletions.
|
||||
static Color red({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffD87E8B);
|
||||
} else {
|
||||
return const Color(0xffbb3d4f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic pink color typically used for decorative or accent elements.
|
||||
static Color pink({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffdaa2e9);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xffdaa2e9);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic orange color typically used for decorative or accent elements.
|
||||
static Color orange({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffd69d7d);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xFFBD7145);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic yellow color typically used for decorative or accent elements.
|
||||
static Color yellow({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xfff4e467);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xffd4af37);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic bluish-purple color typically used for decorative or accent elements.
|
||||
static Color bluishPurple({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xff6e7dcc);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xFF5567C0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic purple color typically used for decorative or accent elements.
|
||||
static Color purple({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffb682e7);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xFF9857D4);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic gold color typically used for decorative or accent elements.
|
||||
static Color gold({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xFFD4AF37);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xffFFD700);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic silver color typically used for decorative or accent elements.
|
||||
static Color silver({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffC0C0C0);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xFFA6A6A6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic bronze color typically used for decorative or accent elements.
|
||||
static Color bronze({bool? darkMode}) {
|
||||
if (darkMode == true || darkMode == null) {
|
||||
return const Color(0xffB1560F);
|
||||
} else {
|
||||
// Add a different shade of pink for light mode
|
||||
return const Color(0xFFCD7F32);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
lib/src/mih_date_field.dart
Normal file
235
lib/src/mih_date_field.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A customized date selection field that integrates with [showDatePicker].
|
||||
///
|
||||
/// The [MihDateField] provides a stylized wrapper around a read-only [TextFormField].
|
||||
/// When tapped, it opens the system date picker and formats the result into
|
||||
/// the provided [controller].
|
||||
///
|
||||
/// It features:
|
||||
/// * Built-in validation support via [validator].
|
||||
/// * Automatic labeling with an optional "(Optional)" suffix.
|
||||
/// * Theme-aware styling that syncs with [MihColors].
|
||||
/// * Custom elevation and border radius.
|
||||
class MihDateField extends StatefulWidget {
|
||||
/// The controller that handles the text being edited.
|
||||
///
|
||||
/// The date will be stored in `YYYY-MM-DD` format.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The text to display above the input field.
|
||||
final String labelText;
|
||||
|
||||
/// Whether this field is mandatory.
|
||||
///
|
||||
/// If `false`, a "(Optional)" tag will be displayed next to the [labelText].
|
||||
final bool required;
|
||||
|
||||
/// The width of the entire widget.
|
||||
final double? width;
|
||||
|
||||
/// The height of the entire widget.
|
||||
final double? height;
|
||||
|
||||
/// The radius of the input field corners. Defaults to 8.0.
|
||||
final double? borderRadius;
|
||||
|
||||
/// The elevation of the input field material. Defaults to 4.0.
|
||||
final double? elevation;
|
||||
|
||||
/// Whether to use Dark Mode styling. If null, [MihColors] defaults to dark.
|
||||
final bool? darkMode;
|
||||
|
||||
/// An optional method that validates an input.
|
||||
///
|
||||
/// Returns an error string to display if the input is invalid, or null otherwise.
|
||||
final FormFieldValidator<String>? validator;
|
||||
const MihDateField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
required this.required,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.elevation,
|
||||
this.darkMode,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihDateField> createState() => _MihDateFieldState();
|
||||
}
|
||||
|
||||
class _MihDateFieldState extends State<MihDateField> {
|
||||
/// Used to manually trigger state changes for the internal [FormField]
|
||||
/// when the date picker updates the [TextEditingController].
|
||||
FormFieldState<String>? _formFieldState;
|
||||
|
||||
/// Internal method to trigger the Material [showDatePicker].
|
||||
///
|
||||
/// It attempts to parse the current text in the controller to set the
|
||||
/// [initialDate], defaulting to [DateTime.now] if empty or invalid.
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: widget.controller.text.isNotEmpty
|
||||
? DateTime.tryParse(widget.controller.text) ?? DateTime.now()
|
||||
: DateTime.now(),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime(2200),
|
||||
);
|
||||
if (picked != null) {
|
||||
widget.controller.text = picked.toString().split(" ")[0];
|
||||
_formFieldState?.didChange(widget.controller.text);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.labelText,
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!widget.required)
|
||||
Text(
|
||||
"(Optional)",
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FormField<String>(
|
||||
initialValue: widget.controller.text,
|
||||
validator: widget.validator,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
builder: (field) {
|
||||
_formFieldState = field;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Material(
|
||||
elevation: widget.elevation ?? 4.0,
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
readOnly: true,
|
||||
onTap: () => _selectDate(context),
|
||||
style: TextStyle(
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: Icon(
|
||||
Icons.calendar_today,
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
),
|
||||
errorStyle: const TextStyle(height: 0, fontSize: 0),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: field.hasError
|
||||
? BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 2.0,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: field.hasError
|
||||
? MihColors.red(darkMode: widget.darkMode)
|
||||
: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
field.didChange(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (field.hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
field.errorText ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
318
lib/src/mih_dropdown_field.dart
Normal file
318
lib/src/mih_dropdown_field.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A robust, searchable dropdown menu component for the MIH Toolkit.
|
||||
///
|
||||
/// The [MihDropdownField] wraps the Material 3 [DropdownMenu] to provide
|
||||
/// enhanced functionality including:
|
||||
/// * **Search & Filter**: Enabled via [enableSearch].
|
||||
/// * **Clear Action**: A built-in trash icon to quickly reset the field.
|
||||
/// * **Validation**: Full integration with Flutter's [Form] validation.
|
||||
/// * **Dynamic Labeling**: Displays a label and an automatic "(Optional)" tag.
|
||||
/// * **Custom Theming**: Deeply integrated with [MihColors] for consistent UI.
|
||||
class MihDropdownField extends StatefulWidget {
|
||||
/// The total width of the dropdown and its accompanying clear icon.
|
||||
final double? width;
|
||||
|
||||
/// The controller that manages the currently selected text.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The label text displayed above the dropdown field.
|
||||
final String hintText;
|
||||
|
||||
/// Whether this field is required.
|
||||
///
|
||||
/// If `false`, displays "(Optional)" next to the [hintText].
|
||||
final bool requiredText;
|
||||
|
||||
/// The list of string options to display in the dropdown menu.
|
||||
final List<String> dropdownOptions;
|
||||
|
||||
/// Whether the field is enabled for user interaction.
|
||||
final bool editable;
|
||||
|
||||
/// Whether to enable the search bar and filtering within the dropdown.
|
||||
final bool enableSearch;
|
||||
|
||||
/// Whether to use Dark Mode styling. Defaults to [MihColors] dark variant if null.
|
||||
final bool? darkMode;
|
||||
|
||||
/// An optional method that validates the selection.
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
/// Called when a value is selected from the menu.
|
||||
final Function(String?)? onSelected;
|
||||
|
||||
const MihDropdownField({
|
||||
super.key,
|
||||
this.width,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.dropdownOptions,
|
||||
required this.requiredText,
|
||||
required this.editable,
|
||||
required this.enableSearch,
|
||||
this.darkMode,
|
||||
this.validator,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihDropdownField> createState() => _MihDropdownFieldState();
|
||||
}
|
||||
|
||||
class _MihDropdownFieldState extends State<MihDropdownField> {
|
||||
/// The internal list of entries generated from [widget.dropdownOptions].
|
||||
late List<DropdownMenuEntry<String>> menu;
|
||||
|
||||
/// Maps the string options into [DropdownMenuEntry] objects
|
||||
/// with themed text styles.
|
||||
List<DropdownMenuEntry<String>> buildMenuOptions(List<String> options) {
|
||||
List<DropdownMenuEntry<String>> menuList = [];
|
||||
for (final i in options) {
|
||||
menuList.add(
|
||||
DropdownMenuEntry(
|
||||
value: i,
|
||||
label: i,
|
||||
style: ButtonStyle(
|
||||
foregroundColor: WidgetStatePropertyAll(
|
||||
MihColors.primary(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return menuList;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
menu = buildMenuOptions(widget.dropdownOptions);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
menu = widget.dropdownOptions
|
||||
.map((e) => DropdownMenuEntry(value: e, label: e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.hintText,
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!widget.requiredText)
|
||||
Text(
|
||||
"(Optional)",
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FormField<String>(
|
||||
validator: widget.validator,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
initialValue: widget.controller.text,
|
||||
builder: (field) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(
|
||||
MihColors.primary(darkMode: widget.darkMode),
|
||||
),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(10),
|
||||
thumbVisibility: const WidgetStatePropertyAll(
|
||||
true,
|
||||
), // Always show when scrolling
|
||||
),
|
||||
textSelectionTheme: TextSelectionThemeData(
|
||||
cursorColor: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
selectionColor: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
).withValues(alpha: 0.3),
|
||||
selectionHandleColor: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: DropdownMenu(
|
||||
controller: widget.controller,
|
||||
dropdownMenuEntries: menu,
|
||||
enableSearch: widget.enableSearch,
|
||||
enableFilter: widget.enableSearch,
|
||||
enabled: widget.editable,
|
||||
textInputAction: widget.enableSearch
|
||||
? TextInputAction.search
|
||||
: TextInputAction.none,
|
||||
requestFocusOnTap: widget.enableSearch,
|
||||
menuHeight: 400,
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
textStyle: TextStyle(
|
||||
color: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
trailingIcon: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
),
|
||||
selectedTrailingIcon: Icon(
|
||||
Icons.arrow_drop_up,
|
||||
color: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
),
|
||||
// leadingIcon:
|
||||
// IconButton(
|
||||
// onPressed: () {
|
||||
// widget.controller.clear();
|
||||
// field.didChange('');
|
||||
// },
|
||||
// icon: Icon(
|
||||
// Icons.delete_outline_rounded,
|
||||
// color: MihColors.primary(
|
||||
// MzansiInnovationHub.of(context)!.theme.mode ==
|
||||
// "Dark"),
|
||||
// ),
|
||||
// ),
|
||||
onSelected: (String? selectedValue) {
|
||||
field.didChange(selectedValue);
|
||||
widget.onSelected?.call(selectedValue);
|
||||
},
|
||||
menuStyle: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
MihColors.secondary(darkMode: widget.darkMode),
|
||||
),
|
||||
side: WidgetStatePropertyAll(
|
||||
BorderSide(
|
||||
color: MihColors.primary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
10,
|
||||
), // Increase for more roundness
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
errorStyle: const TextStyle(
|
||||
height: 0,
|
||||
fontSize: 0,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide(
|
||||
color: field.hasError
|
||||
? MihColors.red(darkMode: widget.darkMode)
|
||||
: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
widget.controller.clear();
|
||||
field.didChange('');
|
||||
},
|
||||
child: Icon(
|
||||
size: 35,
|
||||
Icons.delete_rounded,
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (field.hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
field.errorText ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/src/mih_floating_menu.dart
Normal file
84
lib/src/mih_floating_menu.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A customized floating action button that expands into a menu of sub-actions.
|
||||
///
|
||||
/// The [MihFloatingMenu] uses a "Speed Dial" pattern to present multiple
|
||||
/// actions to the user in a space-efficient manner. It features:
|
||||
/// * **Automatic Color Transition**: Changes from [MihColors.green] to
|
||||
/// [MihColors.red] when toggled open.
|
||||
/// * **Flexible Iconography**: Supports both static [IconData] and [AnimatedIconData].
|
||||
/// * **Themed Overlay**: Applies a semi-transparent dark overlay to the
|
||||
/// background when active to focus user attention on the menu.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihFloatingMenu(
|
||||
/// icon: Icons.add,
|
||||
/// children: [
|
||||
/// SpeedDialChild(
|
||||
/// child: Icon(Icons.share),
|
||||
/// label: 'Share',
|
||||
/// onTap: () => print('Share tapped'),
|
||||
/// ),
|
||||
/// ],
|
||||
/// )
|
||||
/// ```
|
||||
class MihFloatingMenu extends StatefulWidget {
|
||||
/// The static icon to display when the menu is closed.
|
||||
///
|
||||
/// If [animatedIcon] is provided, this property is typically ignored.
|
||||
final IconData? icon;
|
||||
|
||||
/// The size of the floating action button. Defaults to 56.0.
|
||||
final double? iconSize;
|
||||
|
||||
/// An optional animated icon (e.g., menu to close) to use for the button.
|
||||
final AnimatedIconData? animatedIcon;
|
||||
|
||||
/// The direction in which the menu children will expand.
|
||||
///
|
||||
/// Defaults to [SpeedDialDirection.up].
|
||||
final SpeedDialDirection? direction;
|
||||
|
||||
/// Whether to use Dark Mode styling for the component colors.
|
||||
final bool? darkMode;
|
||||
|
||||
/// The list of [SpeedDialChild] widgets to display when the menu is expanded.
|
||||
final List<SpeedDialChild> children;
|
||||
const MihFloatingMenu({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.iconSize,
|
||||
this.animatedIcon,
|
||||
this.direction,
|
||||
this.darkMode,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihFloatingMenu> createState() => _MihFloatingMenuState();
|
||||
}
|
||||
|
||||
class _MihFloatingMenuState extends State<MihFloatingMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SpeedDial(
|
||||
key: GlobalKey(),
|
||||
icon: widget.icon,
|
||||
buttonSize: Size(widget.iconSize ?? 56.0, widget.iconSize ?? 56.0),
|
||||
animatedIcon: widget.animatedIcon,
|
||||
direction: widget.direction ?? SpeedDialDirection.up,
|
||||
activeIcon: Icons.close,
|
||||
backgroundColor: MihColors.green(darkMode: widget.darkMode),
|
||||
activeBackgroundColor: MihColors.red(darkMode: widget.darkMode),
|
||||
foregroundColor: MihColors.primary(darkMode: widget.darkMode),
|
||||
overlayColor: Colors.black,
|
||||
overlayOpacity: 0.5,
|
||||
children: widget.children,
|
||||
onOpen: () => debugPrint('OPENING DIAL'),
|
||||
onClose: () => debugPrint('DIAL CLOSED'),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/src/mih_form.dart
Normal file
57
lib/src/mih_form.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A layout wrapper that standardizes the appearance and behavior of form fields.
|
||||
///
|
||||
/// The [MihForm] simplifies form creation by wrapping a list of [formFields]
|
||||
/// in a [Form] widget and a centered [Column]. It is designed to work
|
||||
/// seamlessly with other components in this toolkit like [MihTextField],
|
||||
/// [MihDateField], and [MihDropdownField].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// final _formKey = GlobalKey<FormState>();
|
||||
///
|
||||
/// MihForm(
|
||||
/// formKey: _formKey,
|
||||
/// formFields: [
|
||||
/// MihTextField(labelText: 'Name', ...),
|
||||
/// MihButton(
|
||||
/// child: Text('Submit'),
|
||||
/// onPressed: () {
|
||||
/// if (_formKey.currentState!.validate()) {
|
||||
/// // Process data
|
||||
/// }
|
||||
/// },
|
||||
/// ),
|
||||
/// ],
|
||||
/// )
|
||||
/// ```
|
||||
class MihForm extends StatefulWidget {
|
||||
/// The key used to identify and validate the internal [Form].
|
||||
///
|
||||
/// Use this key to trigger `validate()`, `save()`, or `reset()`
|
||||
/// on the entire collection of fields.
|
||||
final GlobalKey<FormState> formKey;
|
||||
|
||||
/// The list of widgets (typically MIH input fields) to display inside the form.
|
||||
///
|
||||
/// These are arranged vertically in a [Column] with center cross-axis alignment.
|
||||
final List<Widget> formFields;
|
||||
const MihForm({super.key, required this.formKey, required this.formFields});
|
||||
|
||||
@override
|
||||
State<MihForm> createState() => _MihFormState();
|
||||
}
|
||||
|
||||
class _MihFormState extends State<MihForm> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: widget.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: widget.formFields,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
156
lib/src/mih_icons.dart
Normal file
156
lib/src/mih_icons.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/widgets.dart'; // You need this import for IconData
|
||||
|
||||
/// A custom icon collection specifically designed for the MIH Toolkit.
|
||||
///
|
||||
/// [MihIcons] provides a set of [IconData] constants mapped to the 'MihIcons'
|
||||
/// custom font family. To use these icons, ensure the following is added to
|
||||
/// your application's `pubspec.yaml`:
|
||||
///
|
||||
/// ```yaml
|
||||
/// flutter:
|
||||
/// fonts:
|
||||
/// - family: MihIcons
|
||||
/// fonts:
|
||||
/// - asset: packages/mih_package_toolkit/assets/fonts/MihIcons.ttf
|
||||
/// ```
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// Icon(MihIcons.mihLogo, color: MihColors.primary())
|
||||
/// ```
|
||||
class MihIcons {
|
||||
/// Private constructor to prevent instantiation of this utility class.
|
||||
MihIcons._();
|
||||
|
||||
/// The font family name used for all icons in this set.
|
||||
static const _mihFontFam = 'MihIcons';
|
||||
|
||||
/// The package name where the font asset is located.
|
||||
static const String _mihFontPkg = 'mih_package_toolkit';
|
||||
|
||||
/// An icon representing a mine sweeper game or tool.
|
||||
static const IconData mineSweeper = IconData(
|
||||
0xe900,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi Directory service.
|
||||
static const IconData mzansiDirectory = IconData(
|
||||
0xe901,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing a personal user profile.
|
||||
static const IconData personalProfile = IconData(
|
||||
0xe902,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon for the "About MIH" information section.
|
||||
static const IconData aboutMih = IconData(
|
||||
0xe903,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing security or access control settings.
|
||||
static const IconData accessControl = IconData(
|
||||
0xe904,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi Business Profile.
|
||||
static const IconData businessProfile = IconData(
|
||||
0xe905,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi AI assistant.
|
||||
static const IconData businessSetup = IconData(
|
||||
0xe906,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi digital wallet.
|
||||
static const IconData calculator = IconData(
|
||||
0xe907,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing a MIH calendar.
|
||||
static const IconData calendar = IconData(
|
||||
0xe908,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing uncertainty or a "don't know" state.
|
||||
static const IconData iDontKnow = IconData(
|
||||
0xe909,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the MIH logo, used for branding and identification.
|
||||
static const IconData mihLogo = IconData(
|
||||
0xe90a,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon reprosenting the the icons twisted circle, used for branding and identification.
|
||||
static const IconData mihRing = IconData(
|
||||
0xe90b,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi AI assistant, used for AI-related features and interactions.
|
||||
static const IconData mzansiAi = IconData(
|
||||
0xe90c,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the Mzansi digital wallet, used for financial transactions and management.
|
||||
static const IconData mzansiWallet = IconData(
|
||||
0xe90d,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing notifications, used for alerts and updates within the MIH ecosystem.
|
||||
static const IconData notifications = IconData(
|
||||
0xe90e,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the patient manager, used for healthcare-related features and patient management.
|
||||
static const IconData patientManager = IconData(
|
||||
0xe90f,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the patient profile, used for healthcare-related features and patient information management.
|
||||
static const IconData patientProfile = IconData(
|
||||
0xe910,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
|
||||
/// An icon representing the profile setup process, used for user onboarding and profile configuration.
|
||||
static const IconData profileSetup = IconData(
|
||||
0xe911,
|
||||
fontFamily: _mihFontFam,
|
||||
fontPackage: _mihFontPkg,
|
||||
);
|
||||
}
|
||||
118
lib/src/mih_loading_circle.dart
Normal file
118
lib/src/mih_loading_circle.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_icons.dart';
|
||||
|
||||
/// A stylized loading dialog featuring a pulsing MIH logo.
|
||||
///
|
||||
/// [Mihloadingcircle] is designed to be displayed using [showDialog]. It
|
||||
/// provides a modal overlay that signals an ongoing background process.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Pulsing Animation**: The [MihIcons.mihLogo] scales between 100% and 50%
|
||||
/// size every 500ms.
|
||||
/// * **Themed Container**: A rounded dialog box that adapts to [darkMode].
|
||||
/// * **Optional Message**: Displays a status message below the animating logo.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// showDialog(
|
||||
/// context: context,
|
||||
/// barrierDismissible: false,
|
||||
/// builder: (context) => const Mihloadingcircle(message: "Loading Data..."),
|
||||
/// );
|
||||
/// ```
|
||||
class Mihloadingcircle extends StatefulWidget {
|
||||
/// An optional text message displayed below the pulsing logo.
|
||||
final String? message;
|
||||
|
||||
/// Whether to use Dark Mode styling. If null, [MihColors] defaults to dark.
|
||||
final bool? darkMode;
|
||||
const Mihloadingcircle({super.key, this.message, this.darkMode});
|
||||
|
||||
@override
|
||||
State<Mihloadingcircle> createState() => _MihloadingcircleState();
|
||||
}
|
||||
|
||||
class _MihloadingcircleState extends State<Mihloadingcircle>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Manages the timing of the pulse effect.
|
||||
late AnimationController _controller;
|
||||
|
||||
/// Defines the scaling range (200.0 to 100.0) for the logo.
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(
|
||||
milliseconds: 500,
|
||||
), // Duration for one pulse (grow and shrink)
|
||||
vsync: this,
|
||||
);
|
||||
_animation =
|
||||
Tween<double>(
|
||||
begin: 200,
|
||||
end: 200 * 0.5, // Pulse to 50% of the initial size
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut, // Smooth start and end of the pulse
|
||||
),
|
||||
);
|
||||
_controller.repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: IntrinsicWidth(
|
||||
child: IntrinsicHeight(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
border: Border.all(
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
width: 5.0,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Icon(
|
||||
MihIcons.mihLogo,
|
||||
size: _animation
|
||||
.value, // The size changes based on the animation value
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.message != null)
|
||||
Text(
|
||||
widget.message!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
304
lib/src/mih_numeric_stepper.dart
Normal file
304
lib/src/mih_numeric_stepper.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_text_form_field.dart';
|
||||
|
||||
/// A numeric input component that allows users to increment or decrement values.
|
||||
///
|
||||
/// The [MihNumericStepper] provides a user-friendly way to select an integer
|
||||
/// within an optional range. It consists of:
|
||||
/// * **Decrement/Increment Buttons**: The minus button is themed with [MihColors.red]
|
||||
/// and the plus button with [MihColors.green].
|
||||
/// * **Read-only Display**: A central [MihTextFormField] that displays the current value.
|
||||
/// * **Validation**: Built-in range checking against [minValue] and [maxValue].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihNumericStepper(
|
||||
/// controller: myController,
|
||||
/// hintText: "Quantity",
|
||||
/// minValue: 1,
|
||||
/// maxValue: 10,
|
||||
/// requiredText: true,
|
||||
/// validationOn: true,
|
||||
/// fillColor: Colors.blue,
|
||||
/// inputColor: Colors.white,
|
||||
/// )
|
||||
/// ```
|
||||
class MihNumericStepper extends StatefulWidget {
|
||||
/// The controller managing the string value of the number.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The primary color used for the label text.
|
||||
final Color fillColor;
|
||||
|
||||
/// The color of the icons inside the increment/decrement buttons.
|
||||
final Color inputColor;
|
||||
|
||||
/// The label text displayed above the stepper.
|
||||
final String hintText;
|
||||
|
||||
/// Whether the field is marked as required.
|
||||
final bool requiredText;
|
||||
|
||||
/// The total width of the stepper widget.
|
||||
final double? width;
|
||||
|
||||
/// The minimum allowed value. Defaults to 0 if null.
|
||||
final int? minValue;
|
||||
|
||||
/// The maximum allowed value. If null, there is no upper bound.
|
||||
final int? maxValue;
|
||||
|
||||
/// Whether to trigger validation logic for range and empty states.
|
||||
final bool validationOn;
|
||||
|
||||
/// Whether to use Dark Mode styling for the step buttons.
|
||||
final bool? darkMode;
|
||||
const MihNumericStepper({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.fillColor,
|
||||
required this.inputColor,
|
||||
required this.hintText,
|
||||
required this.requiredText,
|
||||
this.width,
|
||||
this.minValue,
|
||||
this.maxValue,
|
||||
required this.validationOn,
|
||||
this.darkMode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihNumericStepper> createState() => _MihNumericStepperState();
|
||||
}
|
||||
|
||||
class _MihNumericStepperState extends State<MihNumericStepper> {
|
||||
/// Internal integer representation of the value in [widget.controller].
|
||||
late int _currentValue;
|
||||
late bool error;
|
||||
|
||||
/// Validates if the [number] string is a valid integer within the
|
||||
/// [minValue] and [maxValue] bounds.
|
||||
String? validateNumber(String? number, int? minValue, int? maxValue) {
|
||||
String? errorMessage = "";
|
||||
if (number == null || number.isEmpty) {
|
||||
errorMessage += "This field is required";
|
||||
return errorMessage;
|
||||
}
|
||||
int? value = int.tryParse(number);
|
||||
if (value == null) {
|
||||
errorMessage += "Please enter a valid number";
|
||||
return errorMessage;
|
||||
}
|
||||
if (value < (minValue ?? 0)) {
|
||||
errorMessage += "Value must be >= ${minValue ?? 0}";
|
||||
}
|
||||
if (maxValue != null && value > maxValue) {
|
||||
if (errorMessage.isNotEmpty) errorMessage += "\n";
|
||||
errorMessage += "Value must be <= $maxValue";
|
||||
}
|
||||
return errorMessage.isEmpty ? null : errorMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_syncCurrentValue);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentValue =
|
||||
int.tryParse(widget.controller.text) ?? widget.minValue ?? 0;
|
||||
widget.controller.text = _currentValue.toString();
|
||||
int.tryParse(widget.controller.text) ?? widget.minValue ?? 0;
|
||||
widget.controller.addListener(_syncCurrentValue);
|
||||
}
|
||||
|
||||
/// Synchronizes the internal [_currentValue] whenever the [widget.controller]
|
||||
/// changes (e.g., via external logic).
|
||||
void _syncCurrentValue() {
|
||||
final newValue =
|
||||
int.tryParse(widget.controller.text) ?? widget.minValue ?? 0;
|
||||
if (newValue != _currentValue) {
|
||||
setState(() {
|
||||
_currentValue = newValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.hintText,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.fillColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
// color: Colors.white,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
25,
|
||||
), // Optional: rounds the corners
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color.fromARGB(
|
||||
60,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
), // 0.2 opacity = 51 in alpha (255 * 0.2)
|
||||
spreadRadius: -2,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0, left: 5.0),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: IconButton.filled(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.all<Color>(
|
||||
MihColors.red(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
color: widget.inputColor,
|
||||
iconSize: 20,
|
||||
onPressed: () {
|
||||
if (_currentValue >= (widget.minValue ?? 0)) {
|
||||
setState(() {
|
||||
widget.controller.text = (_currentValue - 1)
|
||||
.toString();
|
||||
_currentValue = int.tryParse(
|
||||
widget.controller.text,
|
||||
)!;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible:
|
||||
_currentValue < (widget.minValue ?? 0) ||
|
||||
(widget.maxValue != null &&
|
||||
_currentValue > widget.maxValue!),
|
||||
child: const SizedBox(height: 21),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: MihTextFormField(
|
||||
darkMode: widget.darkMode,
|
||||
fillColor: widget.fillColor,
|
||||
inputColor: widget.inputColor,
|
||||
controller: widget.controller,
|
||||
hintText: null,
|
||||
requiredText: widget.requiredText,
|
||||
readOnly: true,
|
||||
numberMode: true,
|
||||
textIputAlignment: TextAlign.center,
|
||||
validator: (value) {
|
||||
if (widget.validationOn) {
|
||||
return validateNumber(
|
||||
value,
|
||||
widget.minValue,
|
||||
widget.maxValue,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
// color: Colors.white,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
25,
|
||||
), // Optional: rounds the corners
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color.fromARGB(
|
||||
60,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
), // 0.2 opacity = 51 in alpha (255 * 0.2)
|
||||
spreadRadius: -2,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0, left: 5.0),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: IconButton.filled(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.all<Color>(
|
||||
MihColors.green(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
color: widget.inputColor,
|
||||
iconSize: 20,
|
||||
onPressed: () {
|
||||
if (widget.maxValue == null ||
|
||||
_currentValue <= widget.maxValue!) {
|
||||
setState(() {
|
||||
widget.controller.text = (_currentValue + 1)
|
||||
.toString();
|
||||
_currentValue = int.tryParse(
|
||||
widget.controller.text,
|
||||
)!;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible:
|
||||
_currentValue < (widget.minValue ?? 0) ||
|
||||
(widget.maxValue != null &&
|
||||
_currentValue > widget.maxValue!),
|
||||
child: const SizedBox(height: 21),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/src/mih_package.dart
Normal file
220
lib/src/mih_package.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_package_tools.dart';
|
||||
|
||||
/// The core container for MIH application modules.
|
||||
///
|
||||
/// [MihPackage] acts as a high-level scaffold that manages a multi-page
|
||||
/// interface. It synchronizes a set of toolbar icons ([packageTools]) with
|
||||
/// a scrollable/swipeable body ([packageToolBodies]).
|
||||
///
|
||||
/// Features:
|
||||
/// * **PageView Integration**: Seamlessly swipes between different tools.
|
||||
/// * **Toolbar Sync**: Automatically updates the [MihPackageTools] state
|
||||
/// when the page changes.
|
||||
/// * **Double-Back Exit**: Built-in logic to prevent accidental app closure
|
||||
/// on mobile devices.
|
||||
/// * **Peak Animation**: A subtle visual hint that more pages exist to the right.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihPackage(
|
||||
/// selectedBodyIndex: 0,
|
||||
/// packageToolTitles: ['Home', 'Settings'],
|
||||
/// packageTools: MihPackageTools(...),
|
||||
/// packageToolBodies: [HomeWidget(), SettingsWidget()],
|
||||
/// onIndexChange: (index) => print("Navigated to $index"),
|
||||
/// packageActionButton: FloatingActionButton(...),
|
||||
/// )
|
||||
/// ```
|
||||
class MihPackage extends StatefulWidget {
|
||||
/// The floating action button or primary action trigger for this package.
|
||||
final Widget packageActionButton;
|
||||
|
||||
/// The toolbar widget containing icons that correspond to the [packageToolBodies] pages.
|
||||
final MihPackageTools packageTools;
|
||||
|
||||
/// The list of main content widgets for each "page" of the package.
|
||||
final List<Widget> packageToolBodies;
|
||||
|
||||
/// The titles displayed in the header for each corresponding page.
|
||||
final List<String> packageToolTitles;
|
||||
|
||||
/// An optional drawer for secondary actions.
|
||||
final Drawer? actionDrawer;
|
||||
|
||||
/// The initial page index to display.
|
||||
final int selectedBodyIndex;
|
||||
|
||||
/// Callback triggered whenever the user swipes or navigates to a new page.
|
||||
final Function(int) onIndexChange;
|
||||
const MihPackage({
|
||||
super.key,
|
||||
required this.packageActionButton,
|
||||
required this.packageTools,
|
||||
required this.packageToolBodies,
|
||||
required this.packageToolTitles,
|
||||
this.actionDrawer,
|
||||
required this.selectedBodyIndex,
|
||||
required this.onIndexChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackage> createState() => _MihPackageState();
|
||||
}
|
||||
|
||||
class _MihPackageState extends State<MihPackage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// The current active page index.
|
||||
late int _currentIndex;
|
||||
|
||||
/// Controls the horizontal scrolling between [packageToolBodies] items.
|
||||
late PageController _pageController;
|
||||
late AnimationController _animationController;
|
||||
DateTime? lastPressedAt;
|
||||
|
||||
void unfocusAll() {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
|
||||
/// Triggers a "peek" animation on startup to show users that
|
||||
/// horizontal navigation is available.
|
||||
Future<void> _peakAnimation() async {
|
||||
int currentPage = _currentIndex;
|
||||
double peakOffset = _pageController.position.viewportDimension * 0.075;
|
||||
double currentOffset =
|
||||
_pageController.page! * _pageController.position.viewportDimension;
|
||||
int nextPage = currentPage + 1 < widget.packageToolBodies.length
|
||||
? currentPage + 1
|
||||
: currentPage;
|
||||
if (nextPage != currentPage) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await _pageController.animateTo(
|
||||
currentOffset + peakOffset,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
// await Future.delayed(const Duration(milliseconds: 100));
|
||||
await _pageController.animateTo(
|
||||
currentPage * _pageController.position.viewportDimension,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MihPackage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedBodyIndex != widget.selectedBodyIndex &&
|
||||
_currentIndex != widget.selectedBodyIndex) {
|
||||
_currentIndex = widget.selectedBodyIndex;
|
||||
_pageController.animateToPage(
|
||||
widget.selectedBodyIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.selectedBodyIndex;
|
||||
_pageController = PageController(initialPage: widget.selectedBodyIndex);
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ModalRoute? currentRoute = ModalRoute.of(context);
|
||||
if (currentRoute != null) {
|
||||
currentRoute.animation?.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && mounted) {
|
||||
_peakAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Size screenSize = MediaQuery.of(context).size;
|
||||
return GestureDetector(
|
||||
onTap: unfocusAll,
|
||||
child: Scaffold(
|
||||
drawer: widget.actionDrawer,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
minimum: EdgeInsets.only(bottom: 0),
|
||||
child: Container(
|
||||
width: screenSize.width,
|
||||
height: screenSize.height,
|
||||
//color: Colors.black,
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
widget.packageActionButton,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
// alignment: Alignment.center,
|
||||
// alignment: Alignment.centerRight,
|
||||
alignment: Alignment.centerLeft,
|
||||
// color: Colors.black,
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
widget.packageToolTitles[_currentIndex],
|
||||
style: const TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
widget.packageTools,
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.packageToolBodies.length,
|
||||
itemBuilder: (context, index) {
|
||||
return widget.packageToolBodies[index];
|
||||
},
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
widget.onIndexChange(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/src/mih_package_action.dart
Normal file
62
lib/src/mih_package_action.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A standardized action button component for the MIH Toolkit.
|
||||
///
|
||||
/// [MihPackageAction] provides a consistent wrapper around [IconButton].
|
||||
/// It is designed for use in headers, toolbars, and navigation bars where
|
||||
/// precise control over [iconSize] and padding is required.
|
||||
///
|
||||
/// By default, it removes all internal padding to ensure the icon aligns
|
||||
/// perfectly with text or other UI elements.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihPackageAction(
|
||||
/// icon: Icon(Icons.arrow_back),
|
||||
/// iconSize: 24.0,
|
||||
/// onTap: () => Navigator.pop(context),
|
||||
/// )
|
||||
/// ```
|
||||
class MihPackageAction extends StatefulWidget {
|
||||
/// The callback that is called when the button is tapped.
|
||||
///
|
||||
/// If null, the button will be disabled and appear greyed out.
|
||||
final void Function()? onTap;
|
||||
|
||||
/// The size of the icon inside the button.
|
||||
final double iconSize;
|
||||
|
||||
/// The widget to display as the button's icon (typically an [Icon] or [MihIcons]).
|
||||
final Widget icon;
|
||||
const MihPackageAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.iconSize,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackageAction> createState() => _MihPackageActionState();
|
||||
}
|
||||
|
||||
class _MihPackageActionState extends State<MihPackageAction> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
iconSize: widget.iconSize,
|
||||
padding: const EdgeInsets.all(0),
|
||||
onPressed: widget.onTap,
|
||||
icon: widget.icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
272
lib/src/mih_package_tile.dart
Normal file
272
lib/src/mih_package_tile.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
import 'package:app_settings/app_settings.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_button.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_package_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_single_child_scroll.dart';
|
||||
|
||||
/// A touchable "App Tile" used for launching modules within the MIH ecosystem.
|
||||
///
|
||||
/// [MihPackageTile] provides a standardized way to display application icons
|
||||
/// and names. It includes sophisticated features like:
|
||||
/// * **Biometric Ready**: Integrated with `local_auth` for secure access.
|
||||
/// * **Video Hints**: Can display a help window with a YouTube video if [ytVideoID] is provided.
|
||||
/// * **Responsive Scaling**: Uses [FittedBox] to ensure the [packageIcon] and [packageName]
|
||||
/// fit perfectly within the specified [iconSize].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihPackageTile(
|
||||
/// packageName: "Settings",
|
||||
/// packageIcon: Icon(Icons.settings, size: 50),
|
||||
/// iconSize: 100.0,
|
||||
/// textColor: Colors.white,
|
||||
/// onTap: () => print("Opening Settings..."),
|
||||
/// ytVideoID: "dQw4w9WgXcQ", // Optional help video
|
||||
/// )
|
||||
/// ```
|
||||
class MihPackageTile extends StatefulWidget {
|
||||
/// The name of the package displayed below the icon.
|
||||
final String packageName;
|
||||
// final String? ytVideoID;
|
||||
final Widget packageIcon;
|
||||
|
||||
/// The callback triggered when the tile is tapped.
|
||||
final void Function() onTap;
|
||||
|
||||
/// The total height and width of the tile container.
|
||||
final double iconSize;
|
||||
|
||||
/// The color of the [packageName] text.
|
||||
final Color textColor;
|
||||
// final bool? authenticateUser;
|
||||
const MihPackageTile({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.packageName,
|
||||
// this.ytVideoID,
|
||||
required this.packageIcon,
|
||||
required this.iconSize,
|
||||
required this.textColor,
|
||||
// this.authenticateUser,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackageTile> createState() => _MihPackageTileState();
|
||||
}
|
||||
|
||||
class _MihPackageTileState extends State<MihPackageTile> {
|
||||
final LocalAuthentication _auth = LocalAuthentication();
|
||||
|
||||
// void displayHint() {
|
||||
// if (widget.ytVideoID != null) {
|
||||
// showDialog(
|
||||
// barrierDismissible: false,
|
||||
// context: context,
|
||||
// builder: (context) {
|
||||
// return MihPackageWindow(
|
||||
// fullscreen: false,
|
||||
// windowTitle: widget.packageName,
|
||||
// // windowTools: const [],
|
||||
// onWindowTapClose: () {
|
||||
// Navigator.pop(context);
|
||||
// },
|
||||
// windowBody: SizedBox(),
|
||||
// //MIHYTVideoPlayer(videoYTLink: widget.ytVideoID!),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<bool> isUserAuthenticated() async {
|
||||
final bool canAuthWithBio = await _auth.canCheckBiometrics;
|
||||
final bool canAuthenticate =
|
||||
canAuthWithBio || await _auth.isDeviceSupported();
|
||||
if (canAuthenticate) {
|
||||
try {
|
||||
final bool didBioAuth = await _auth.authenticate(
|
||||
localizedReason: "Authenticate to access ${widget.packageName}",
|
||||
options: const AuthenticationOptions(biometricOnly: false),
|
||||
);
|
||||
if (didBioAuth) {
|
||||
return true;
|
||||
} else {
|
||||
authErrorPopUp();
|
||||
}
|
||||
// print("Authenticated: $didBioAuth");
|
||||
} catch (error) {
|
||||
authErrorPopUp();
|
||||
}
|
||||
} else {
|
||||
authErrorPopUp();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void authErrorPopUp() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return MihPackageWindow(
|
||||
fullscreen: false,
|
||||
windowTitle: null,
|
||||
onWindowTapClose: null,
|
||||
backgroundColor: MihColors.red(),
|
||||
windowBody: MihSingleChildScroll(
|
||||
scrollbarOn: true,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 150,
|
||||
color: MihColors.secondary(),
|
||||
),
|
||||
Text(
|
||||
"Biometric Authentication Required",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(),
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Center(
|
||||
child: Text(
|
||||
"Hi there! To jump into the ${widget.packageName} Package, you'll need to authenticate yourself with your devices biometrics, please set up biometric authentication (like fingerprint, face ID, pattern or pin) on your device first.\n\nIf you have already set up biometric authentication, press \"Authenticate now\" to try again or press \"Set Up Authentication\" to go to your device settings.",
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Center(
|
||||
child: Wrap(
|
||||
spacing: 10.0,
|
||||
runSpacing: 10.0,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
MihButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
buttonColor: MihColors.secondary(),
|
||||
width: 300,
|
||||
child: Text(
|
||||
"Dismiss",
|
||||
style: TextStyle(
|
||||
color: MihColors.primary(),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
MihButton(
|
||||
onPressed: () {
|
||||
AppSettings.openAppSettings(
|
||||
type: AppSettingsType.security,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
buttonColor: MihColors.primary(),
|
||||
width: 300,
|
||||
child: Text(
|
||||
"Set Up Authentication",
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
MihButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
authenticateUser();
|
||||
},
|
||||
buttonColor: MihColors.green(),
|
||||
width: 300,
|
||||
child: Text(
|
||||
"Authenticate Now",
|
||||
style: TextStyle(
|
||||
color: MihColors.primary(),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> authenticateUser() async {
|
||||
// if (widget.authenticateUser != null &&
|
||||
// widget.authenticateUser! &&
|
||||
// !kIsWeb &&
|
||||
// !Platform.isLinux) {
|
||||
// if (await isUserAuthenticated()) {
|
||||
// widget.onTap();
|
||||
// }
|
||||
// } else {
|
||||
// widget.onTap();
|
||||
// }
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.topCenter,
|
||||
// color: Colors.black,
|
||||
width: widget.iconSize,
|
||||
height: widget.iconSize,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
authenticateUser();
|
||||
},
|
||||
onLongPress: null, // Do this later
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
child: widget.packageIcon,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
// Add a little padding for better visual spacing
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
widget.packageName,
|
||||
textAlign: TextAlign.center, // This centers the text content
|
||||
maxLines: 1, // Allow up to 2 lines to prevent clipping
|
||||
style: TextStyle(
|
||||
color: widget.textColor,
|
||||
fontSize: 20.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
135
lib/src/mih_package_tool_body.dart
Normal file
135
lib/src/mih_package_tool_body.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A structural container for individual pages within a [MihPackage].
|
||||
///
|
||||
/// [MihPackageToolBody] acts as the primary wrapper for page content. It
|
||||
/// provides consistent layout rules, including:
|
||||
/// * **Responsive Padding**: Automatically adjusts horizontal and vertical
|
||||
/// spacing based on screen size and the presence of a border.
|
||||
/// * **Optional Framing**: Can display a rounded border using [borderOn].
|
||||
/// * **Theme Integration**: Border and background colors sync with [MihColors].
|
||||
///
|
||||
/// This widget is typically used as an item in the `appBody` list of a [MihPackage].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihPackageToolBody(
|
||||
/// backgroundColor: Colors.white,
|
||||
/// borderOn: true,
|
||||
/// borderColor: Colors.blue,
|
||||
/// bodyItem: Center(child: Text("Page Content")),
|
||||
/// )
|
||||
/// ```
|
||||
class MihPackageToolBody extends StatefulWidget {
|
||||
/// The background color of the content area.
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The color of the outer border. Defaults to [MihColors.secondary] if null.
|
||||
final Color? borderColor;
|
||||
|
||||
/// Whether to display a 3.0 width rounded border around the content.
|
||||
final bool? borderOn;
|
||||
|
||||
/// The main widget to be displayed inside this body container.
|
||||
final Widget bodyItem;
|
||||
|
||||
/// Custom horizontal padding. Defaults to 10.0 if [borderOn] is true,
|
||||
/// otherwise 0.0.
|
||||
final double? innerHorizontalPadding;
|
||||
|
||||
/// Whether to use Dark Mode styling for default border colors.
|
||||
final bool? darkMode;
|
||||
const MihPackageToolBody({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
this.borderColor,
|
||||
this.borderOn,
|
||||
required this.bodyItem,
|
||||
this.darkMode,
|
||||
this.innerHorizontalPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackageToolBody> createState() => _MihPackageToolBodyState();
|
||||
}
|
||||
|
||||
class _MihPackageToolBodyState extends State<MihPackageToolBody> {
|
||||
/// Internal padding value calculated based on the border state.
|
||||
late double _innerBodyPadding;
|
||||
|
||||
/// Calculates horizontal padding based on [MediaQuery] and [widget.borderOn].
|
||||
double getHorizontalPaddingSize(Size screenSize) {
|
||||
if (screenSize.width > 800) {
|
||||
if (widget.borderOn != null && widget.borderOn!) {
|
||||
return widget.innerHorizontalPadding ?? 10;
|
||||
} else {
|
||||
return widget.innerHorizontalPadding ?? 0;
|
||||
}
|
||||
} else {
|
||||
// mobile
|
||||
if (widget.borderOn != null && widget.borderOn!) {
|
||||
return widget.innerHorizontalPadding ?? 10;
|
||||
} else {
|
||||
return widget.innerHorizontalPadding ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates vertical padding (bottom only) when a border is active.
|
||||
double getVerticalPaddingSize(Size screenSize) {
|
||||
// mobile
|
||||
if (widget.borderOn != null && widget.borderOn!) {
|
||||
return 10;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the [BoxDecoration] for the container, handling border
|
||||
/// visibility and color.
|
||||
Decoration? getBoder() {
|
||||
if (widget.borderOn != null && widget.borderOn!) {
|
||||
_innerBodyPadding = 10.0;
|
||||
return BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
border: Border.all(
|
||||
color:
|
||||
widget.borderColor ??
|
||||
MihColors.secondary(darkMode: widget.darkMode),
|
||||
width: 3.0,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_innerBodyPadding = 0.0;
|
||||
return BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
border: Border.all(color: widget.backgroundColor, width: 3.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Size screenSize = MediaQuery.sizeOf(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: getHorizontalPaddingSize(screenSize),
|
||||
right: getHorizontalPaddingSize(screenSize),
|
||||
bottom: getVerticalPaddingSize(screenSize),
|
||||
top: 0,
|
||||
),
|
||||
child: Container(
|
||||
height: screenSize.height,
|
||||
decoration: getBoder(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(_innerBodyPadding),
|
||||
child: widget.bodyItem,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/src/mih_package_tools.dart
Normal file
84
lib/src/mih_package_tools.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A horizontal toolbar used for navigation within a [MihPackage].
|
||||
///
|
||||
/// [MihPackageTools] displays a set of icons that correspond to different
|
||||
/// modules or pages. It automatically manages the visual state of buttons,
|
||||
/// using a filled background for the currently selected index.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Dynamic Mapping**: Uses a `Map<Widget, void Function()?>` to pair
|
||||
/// icons with their respective tap behaviors.
|
||||
/// * **Selection Highlighting**: Toggles between [IconButton] and
|
||||
/// [IconButton.filled] based on the [selectedIndex].
|
||||
/// * **Alignment**: Right-aligned by default to sit neatly in a header.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihPackageTools(
|
||||
/// selectedIndex: 0,
|
||||
/// tools: {
|
||||
/// Icon(Icons.home): () => () {
|
||||
/// setState(() {
|
||||
/// selectedbodyIndex = 0;
|
||||
/// });
|
||||
/// },
|
||||
/// Icon(Icons.settings): () => () => () {
|
||||
/// setState(() {
|
||||
/// selectedbodyIndex = 0;
|
||||
/// });
|
||||
/// },
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
// ignore: must_be_immutable
|
||||
class MihPackageTools extends StatefulWidget {
|
||||
/// A map where the key is the Icon widget and the value is the
|
||||
/// callback function triggered on tap.
|
||||
final Map<Widget, void Function()?> tools;
|
||||
|
||||
/// The index of the currently active tool.
|
||||
///
|
||||
/// This determines which button is rendered as "filled".
|
||||
int selectedIndex;
|
||||
MihPackageTools({
|
||||
super.key,
|
||||
required this.tools,
|
||||
required this.selectedIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackageTools> createState() => _MihPackageToolsState();
|
||||
}
|
||||
|
||||
class _MihPackageToolsState extends State<MihPackageTools> {
|
||||
/// Generates a list of widgets representing the tools.
|
||||
///
|
||||
/// Uses [Visibility] to swap between the active (filled) and
|
||||
/// inactive (standard) versions of each icon button.
|
||||
List<Widget> getTools() {
|
||||
List<Widget> temp = [];
|
||||
int index = 0;
|
||||
widget.tools.forEach((icon, onTap) {
|
||||
temp.add(
|
||||
Visibility(
|
||||
visible: widget.selectedIndex != index,
|
||||
child: IconButton(onPressed: onTap, icon: icon),
|
||||
),
|
||||
);
|
||||
temp.add(
|
||||
Visibility(
|
||||
visible: widget.selectedIndex == index,
|
||||
child: IconButton.filled(onPressed: onTap, icon: icon),
|
||||
),
|
||||
);
|
||||
index += 1;
|
||||
});
|
||||
return temp;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(mainAxisAlignment: MainAxisAlignment.end, children: getTools());
|
||||
}
|
||||
}
|
||||
258
lib/src/mih_package_window.dart
Normal file
258
lib/src/mih_package_window.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_button.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_floating_menu.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_single_child_scroll.dart';
|
||||
|
||||
/// A versatile window container used for dialogs or sub-sections.
|
||||
///
|
||||
/// [MihPackageWindow] provides a standardized "windowed" UI that can
|
||||
/// toggle between [fullscreen] and modal dialog modes.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Adaptive Layout**: Automatically adjusts padding and title size
|
||||
/// based on screen dimensions (Mobile vs. Desktop/Tablet).
|
||||
/// * **Integrated Actions**: If [menuOptions] is provided, a
|
||||
/// [MihFloatingMenu] is automatically added to the window.
|
||||
/// * **Scroll Management**: Wraps content in [MihSingleChildScroll] if
|
||||
/// [scrollbarOn] is enabled.
|
||||
/// * **Close Logic**: Provides a consistent header with a back/close button
|
||||
/// that triggers [onWindowTapClose].
|
||||
///
|
||||
/// ### Example (Modal Dialog):
|
||||
/// ```dart
|
||||
/// MihPackageWindow(
|
||||
/// fullscreen: false,
|
||||
/// windowTitle: "Settings",
|
||||
/// onWindowTapClose: () => Navigator.pop(context),
|
||||
/// windowBody: MySettingsWidget(),
|
||||
/// )
|
||||
/// ```
|
||||
class MihPackageWindow extends StatefulWidget {
|
||||
/// The text displayed in the window's header.
|
||||
final String? windowTitle;
|
||||
|
||||
/// The main content widget to display inside the window.
|
||||
final Widget windowBody;
|
||||
|
||||
/// Optional actions to display via a floating menu button.
|
||||
final List<SpeedDialChild>? menuOptions;
|
||||
|
||||
/// Callback triggered when the close/back button in the header is pressed.
|
||||
final void Function()? onWindowTapClose;
|
||||
|
||||
/// The background color of the window. Defaults to [MihColors.primary].
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// The color used for headers and primary text. Defaults to [MihColors.secondary].
|
||||
final Color? foregroundColor;
|
||||
|
||||
/// Whether to display a colored border around the window.
|
||||
final bool? borderOn;
|
||||
|
||||
/// If true, the window takes up the full screen.
|
||||
/// If false, it renders as a centered modal box.
|
||||
final bool fullscreen;
|
||||
|
||||
/// Whether to show a scrollbar for the [windowBody].
|
||||
final bool? scrollbarOn;
|
||||
|
||||
/// Whether to use Dark Mode styling.
|
||||
final bool? darkMode;
|
||||
const MihPackageWindow({
|
||||
super.key,
|
||||
required this.fullscreen,
|
||||
required this.windowTitle,
|
||||
this.menuOptions,
|
||||
required this.onWindowTapClose,
|
||||
required this.windowBody,
|
||||
this.borderOn,
|
||||
this.scrollbarOn,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.darkMode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihPackageWindow> createState() => _MihPackageWindowState();
|
||||
}
|
||||
|
||||
class _MihPackageWindowState extends State<MihPackageWindow> {
|
||||
late double windowTitleSize;
|
||||
late double horizontralWindowPadding;
|
||||
late double verticalWindowPadding;
|
||||
late double windowWidth;
|
||||
late double windowHeight;
|
||||
late double width;
|
||||
late double height;
|
||||
|
||||
void checkScreenSize(Size screenSize) {
|
||||
// print("screen width: $width");
|
||||
// print("screen height: $height");
|
||||
if (screenSize.width > 800) {
|
||||
setState(() {
|
||||
windowTitleSize = 25;
|
||||
horizontralWindowPadding = width / 7;
|
||||
verticalWindowPadding = 10;
|
||||
windowWidth = width;
|
||||
windowHeight = height;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
windowTitleSize = 20;
|
||||
horizontralWindowPadding = 10;
|
||||
verticalWindowPadding = 10;
|
||||
windowWidth = width;
|
||||
windowHeight = height;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget getHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.onWindowTapClose != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5.0, left: 5.0),
|
||||
child: MihButton(
|
||||
width: 40,
|
||||
height: 40,
|
||||
elevation: 10,
|
||||
onPressed: widget.onWindowTapClose,
|
||||
buttonColor: MihColors.red(darkMode: widget.darkMode),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.windowTitle != null)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Text(
|
||||
widget.windowTitle!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: windowTitleSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
widget.foregroundColor ??
|
||||
MihColors.secondary(darkMode: widget.darkMode),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.menuOptions != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5.0, right: 5.0),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: MihFloatingMenu(
|
||||
iconSize: 40,
|
||||
animatedIcon: AnimatedIcons.menu_close,
|
||||
direction: SpeedDialDirection.down,
|
||||
children: widget.menuOptions != null ? widget.menuOptions! : [],
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var size = MediaQuery.of(context).size;
|
||||
setState(() {
|
||||
width = size.width;
|
||||
height = size.height;
|
||||
});
|
||||
checkScreenSize(size);
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: horizontralWindowPadding,
|
||||
vertical: verticalWindowPadding,
|
||||
),
|
||||
insetAnimationCurve: Easing.emphasizedDecelerate,
|
||||
insetAnimationDuration: Durations.short1,
|
||||
child: Material(
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black,
|
||||
color:
|
||||
widget.backgroundColor ??
|
||||
MihColors.primary(darkMode: widget.darkMode),
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
border: widget.borderOn == null || !widget.borderOn!
|
||||
? null
|
||||
: Border.all(
|
||||
color:
|
||||
widget.foregroundColor ??
|
||||
MihColors.secondary(darkMode: widget.darkMode),
|
||||
width: 5.0,
|
||||
),
|
||||
),
|
||||
child: widget.fullscreen
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
getHeader(),
|
||||
const SizedBox(height: 5),
|
||||
Expanded(
|
||||
child: widget.scrollbarOn != null || !widget.scrollbarOn!
|
||||
? widget.windowBody
|
||||
: MihSingleChildScroll(
|
||||
scrollbarOn: true,
|
||||
child: widget.windowBody,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
getHeader(),
|
||||
const SizedBox(height: 5),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 25,
|
||||
right: 25,
|
||||
bottom: verticalWindowPadding,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: windowHeight * 0.85,
|
||||
maxWidth: windowWidth * 0.85,
|
||||
),
|
||||
child: MihSingleChildScroll(
|
||||
scrollbarOn: true,
|
||||
child: widget.windowBody,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/src/mih_radio_options.dart
Normal file
189
lib/src/mih_radio_options.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A customized group of radio buttons for selecting a single option from a list.
|
||||
///
|
||||
/// [MihRadioOptions] provides a stylized vertical list of radio buttons.
|
||||
/// It is unique because it uses a [TextEditingController] to manage its state,
|
||||
/// allowing it to behave like other text-based input fields in a form.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Auto-Selection**: If the controller is empty, it defaults to the first
|
||||
/// option in [radioOptions] during initialization.
|
||||
/// * **Reactive UI**: Uses an [AnimatedBuilder] to listen to the controller
|
||||
/// and update the "selected" dot immediately when changed.
|
||||
/// * **Labeling**: Includes a primary label and an automatic "(Optional)"
|
||||
/// tag based on [requiredText].
|
||||
/// * **Custom Theming**: Primary and secondary colors can be set via
|
||||
/// [fillColor] and [secondaryFillColor].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihRadioOptions(
|
||||
/// controller: genderController,
|
||||
/// hintText: "Gender",
|
||||
/// radioOptions: ["Male", "Female", "Other"],
|
||||
/// fillColor: Colors.blue,
|
||||
/// secondaryFillColor: Colors.grey,
|
||||
/// requiredText: true,
|
||||
/// )
|
||||
/// ```
|
||||
class MihRadioOptions extends StatefulWidget {
|
||||
/// The total width of the radio group container.
|
||||
final double? width;
|
||||
|
||||
/// The controller that stores the string value of the selected option.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The title text displayed above the options.
|
||||
final String hintText;
|
||||
|
||||
/// The color used for the title and the selected radio state.
|
||||
final Color fillColor;
|
||||
|
||||
/// The color used for the unselected radio state and borders.
|
||||
final Color secondaryFillColor;
|
||||
|
||||
/// Whether the field is mandatory.
|
||||
///
|
||||
/// If `false`, displays "(Optional)" next to the [hintText].
|
||||
final bool requiredText;
|
||||
|
||||
/// The list of string labels for each radio option.
|
||||
final List<String> radioOptions;
|
||||
const MihRadioOptions({
|
||||
super.key,
|
||||
this.width,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.fillColor,
|
||||
required this.secondaryFillColor,
|
||||
required this.requiredText,
|
||||
required this.radioOptions,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihRadioOptions> createState() => _MihRadioOptionsState();
|
||||
}
|
||||
|
||||
class _MihRadioOptionsState extends State<MihRadioOptions> {
|
||||
// late String _currentSelection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.controller.text.isEmpty && widget.radioOptions.isNotEmpty) {
|
||||
widget.controller.text = widget.radioOptions[0];
|
||||
}
|
||||
// else{
|
||||
// int index = widget.radioOptions
|
||||
// .indexWhere((element) => element == option);
|
||||
// _currentSelection = widget.radioOptions[index];
|
||||
// widget.controller.text = option;
|
||||
|
||||
// }
|
||||
// _currentSelection = widget.radioOptions[0];
|
||||
}
|
||||
|
||||
// The method to handle a change in selection.
|
||||
void _onChanged(String? value) {
|
||||
if (value != null) {
|
||||
widget.controller.text = value;
|
||||
}
|
||||
}
|
||||
|
||||
Widget displayRadioOptions(String selection) {
|
||||
return Material(
|
||||
elevation: 4.0,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.fillColor,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Column(
|
||||
children: widget.radioOptions.map((option) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_onChanged(option);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
color: widget.secondaryFillColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Radio<String>(
|
||||
value: option,
|
||||
groupValue: selection,
|
||||
onChanged: _onChanged,
|
||||
activeColor: widget.secondaryFillColor,
|
||||
fillColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return widget.secondaryFillColor; // Color when selected
|
||||
}
|
||||
return widget.secondaryFillColor;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, child) {
|
||||
final currentSelection = widget.controller.text;
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.hintText,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
color: widget.fillColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: !widget.requiredText,
|
||||
child: Text(
|
||||
"(Optional)",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
color: widget.fillColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
displayRadioOptions(currentSelection),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/src/mih_search_bar.dart
Normal file
239
lib/src/mih_search_bar.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A highly customizable search bar with reactive icon states.
|
||||
///
|
||||
/// [MihSearchBar] provides a polished search interface that handles
|
||||
/// common search UX patterns automatically, such as:
|
||||
/// * **Dynamic Prefixing**: Can swap between [prefixIcon] and [prefixAltIcon]
|
||||
/// when the user starts typing.
|
||||
/// * **Automatic Clear Button**: Displays a clear (X) icon only when
|
||||
/// the [controller] is not empty.
|
||||
/// * **Tool Extensibility**: Allows for additional widgets (like filter or
|
||||
/// voice icons) to be added to the end of the bar via [suffixTools].
|
||||
/// * **Focus Management**: Requires a [searchFocusNode] to handle keyboard
|
||||
/// interactions and state changes correctly.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihSearchBar(
|
||||
/// controller: _searchController,
|
||||
/// searchFocusNode: _myFocusNode,
|
||||
/// hintText: "Search products...",
|
||||
/// prefixIcon: Icons.search,
|
||||
/// prefixAltIcon: Icons.arrow_back, // Swaps when typing
|
||||
/// fillColor: Colors.white,
|
||||
/// hintColor: Colors.grey,
|
||||
/// onPrefixIconTap: () => Navigator.pop(context),
|
||||
/// suffixTools: [
|
||||
/// IconButton(icon: Icon(Icons.mic), onPressed: () {}),
|
||||
/// ],
|
||||
/// )
|
||||
/// ```
|
||||
class MihSearchBar extends StatefulWidget {
|
||||
/// The controller managing the search query text.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The placeholder text shown when the field is empty.
|
||||
final String hintText;
|
||||
|
||||
/// The primary icon shown at the start of the bar.
|
||||
final IconData prefixIcon;
|
||||
|
||||
/// An optional icon that replaces [prefixIcon] when text is entered.
|
||||
final IconData? prefixAltIcon;
|
||||
|
||||
/// A list of additional widgets to display before the clear icon.
|
||||
final List<Widget>? suffixTools;
|
||||
|
||||
/// Total width of the search bar.
|
||||
final double? width;
|
||||
|
||||
/// Total height of the search bar.
|
||||
final double? height;
|
||||
|
||||
/// The background color of the search bar.
|
||||
final Color fillColor;
|
||||
|
||||
/// The color of the hint text and default icons.
|
||||
final Color hintColor;
|
||||
|
||||
/// Callback for when the prefix icon (e.g., back arrow) is tapped.
|
||||
final void Function()? onPrefixIconTap;
|
||||
|
||||
/// Custom callback for the clear icon. If null, defaults to `controller.clear()`.
|
||||
final void Function()? onClearIconTap;
|
||||
|
||||
/// The shadow depth of the search bar.
|
||||
final double? elevation;
|
||||
|
||||
/// The focus node used to control keyboard focus and listener state.
|
||||
final FocusNode searchFocusNode;
|
||||
|
||||
const MihSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.prefixIcon,
|
||||
this.prefixAltIcon,
|
||||
this.suffixTools,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.fillColor,
|
||||
required this.hintColor,
|
||||
required this.onPrefixIconTap,
|
||||
this.onClearIconTap,
|
||||
this.elevation,
|
||||
required this.searchFocusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihSearchBar> createState() => _MihSearchBarState();
|
||||
}
|
||||
|
||||
class _MihSearchBarState extends State<MihSearchBar> {
|
||||
bool _showClearIcon = false;
|
||||
|
||||
Widget getPrefixIcon() {
|
||||
if (_showClearIcon) {
|
||||
// If the clear icon is shown and an alternative prefix icon is provided, use it
|
||||
return widget.prefixAltIcon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Icon(
|
||||
widget.prefixAltIcon,
|
||||
color: widget.hintColor,
|
||||
size: 35,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Icon(Icons.search, color: widget.hintColor, size: 35),
|
||||
); // Default to search icon if no alt icon
|
||||
} else {
|
||||
// Return the primary prefix icon or the alternative if provided
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Icon(Icons.search, color: widget.hintColor, size: 35),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. Add the listener to the controller
|
||||
widget.controller.addListener(_updateClearIconVisibility);
|
||||
// 2. Initialize the clear icon visibility based on the current text
|
||||
_updateClearIconVisibility();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_updateClearIconVisibility);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateClearIconVisibility() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final bool shouldShow = widget.controller.text.isNotEmpty;
|
||||
// Only call setState if the visibility state actually changes
|
||||
if (_showClearIcon != shouldShow) {
|
||||
setState(() {
|
||||
_showClearIcon = shouldShow;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: widget.elevation ?? 4.0, // Use provided elevation or default
|
||||
borderRadius: BorderRadius.circular(30.0),
|
||||
color: widget.fillColor,
|
||||
child: AnimatedContainer(
|
||||
// Keep AnimatedContainer for width/height transitions
|
||||
alignment: Alignment.centerLeft,
|
||||
width: widget.width,
|
||||
height: widget.height ?? 50,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(30.0)),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
textSelectionTheme: TextSelectionThemeData(
|
||||
selectionColor: widget.hintColor.withValues(alpha: 0.3),
|
||||
selectionHandleColor: widget.hintColor,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
controller: widget.controller, // Assign the controller
|
||||
focusNode: widget.searchFocusNode,
|
||||
autocorrect: true,
|
||||
spellCheckConfiguration:
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
|
||||
? SpellCheckConfiguration()
|
||||
: null,
|
||||
onSubmitted: (value) {
|
||||
widget.onPrefixIconTap
|
||||
?.call(); // Call the prefix icon tap handler
|
||||
},
|
||||
style: TextStyle(
|
||||
color: widget.hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
cursorColor: widget.hintColor,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: widget.hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: widget.hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 15.0,
|
||||
),
|
||||
prefixIcon: GestureDetector(
|
||||
onTap: widget.onPrefixIconTap,
|
||||
child: getPrefixIcon(),
|
||||
),
|
||||
suffixIcon: Row(
|
||||
// Use a Row for multiple suffix icons
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Optional suffix tools
|
||||
if (widget.suffixTools != null) ...widget.suffixTools!,
|
||||
// Clear Icon (conditionally visible)
|
||||
if (_showClearIcon) // Only show if input is not empty
|
||||
IconButton(
|
||||
iconSize: 35,
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: widget.hintColor,
|
||||
), // Clear icon
|
||||
onPressed:
|
||||
widget.onClearIconTap ??
|
||||
() {
|
||||
widget.controller.clear();
|
||||
// No need for setState here, _updateClearIconVisibility will handle it
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/src/mih_single_child_scroll.dart
Normal file
61
lib/src/mih_single_child_scroll.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A standardized scrollable container for the MIH Toolkit.
|
||||
///
|
||||
/// [MihSingleChildScroll] wraps a single widget in a [SingleChildScrollView]
|
||||
/// and provides integrated support for [SafeArea] and custom [ScrollConfiguration].
|
||||
///
|
||||
/// Features:
|
||||
/// * **Safe Area Insets**: Automatically protects content from overlapping
|
||||
/// with system UI elements (like notches or home indicators).
|
||||
/// * **Toggleable Scrollbars**: Control scrollbar visibility via the
|
||||
/// [scrollbarOn] property across all platforms.
|
||||
/// * **Consistent Bottom Padding**: Applies a minimum 5px bottom margin
|
||||
/// to ensure content doesn't sit flush against the bottom of the screen.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihSingleChildScroll(
|
||||
/// scrollbarOn: true,
|
||||
/// child: Column(
|
||||
/// children: [
|
||||
/// Text("Long list of items..."),
|
||||
/// // ... more widgets
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class MihSingleChildScroll extends StatefulWidget {
|
||||
/// The widget to be made scrollable.
|
||||
final Widget child;
|
||||
|
||||
/// Whether to force the display of scrollbars.
|
||||
///
|
||||
/// Defaults to `false` if null. When enabled, it applies a
|
||||
/// [ScrollConfiguration] to the child subtree.
|
||||
final bool? scrollbarOn;
|
||||
const MihSingleChildScroll({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.scrollbarOn,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihSingleChildScroll> createState() => _MihSingleChildScrollState();
|
||||
}
|
||||
|
||||
class _MihSingleChildScrollState extends State<MihSingleChildScroll> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
minimum: EdgeInsets.only(bottom: 5),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(
|
||||
context,
|
||||
).copyWith(scrollbars: widget.scrollbarOn ?? false),
|
||||
child: SingleChildScrollView(child: widget.child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/src/mih_snack_bar.dart
Normal file
35
lib/src/mih_snack_bar.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A helper function to generate a standardized MIH-styled SnackBar.
|
||||
///
|
||||
/// [MihSnackBar] returns a [SnackBar] configured with a modern, floating
|
||||
/// aesthetic. It is designed to be used with `ScaffoldMessenger.of(context).showSnackBar()`.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Stadium Border**: Uses a pill-shaped [StadiumBorder] for a sleek look.
|
||||
/// * **Floating Behavior**: Set to [SnackBarBehavior.floating], allowing it
|
||||
/// to appear above bottom navigation or floating action buttons.
|
||||
/// * **Auto-Dismiss**: Features a default 2-second duration and a
|
||||
/// built-in "Dismiss" action.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// ScaffoldMessenger.of(context).showSnackBar(
|
||||
/// MihSnackBar(
|
||||
/// backgroundColor: Colors.blue,
|
||||
/// child: Text("Data saved successfully!"),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
// ignore: non_constant_identifier_names
|
||||
SnackBar MihSnackBar({Color? backgroundColor, required Widget child}) {
|
||||
return SnackBar(
|
||||
backgroundColor: backgroundColor,
|
||||
content: child,
|
||||
shape: StadiumBorder(),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 2),
|
||||
width: null,
|
||||
action: SnackBarAction(label: "Dismiss", onPressed: () {}),
|
||||
);
|
||||
}
|
||||
384
lib/src/mih_text_form_field.dart
Normal file
384
lib/src/mih_text_form_field.dart
Normal file
@@ -0,0 +1,384 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// The foundational text input component for the MIH Toolkit.
|
||||
///
|
||||
/// [MihTextFormField] is a comprehensive wrapper around [TextFormField]
|
||||
/// that integrates seamlessly with [MihColors] and the package's
|
||||
/// standard layout rules.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Validation Display**: Automatically renders a thick 3.0-width red
|
||||
/// border when validation fails.
|
||||
/// * **Adaptive Input**: Supports [passwordMode] (obfuscation),
|
||||
/// [numberMode] (numeric keyboard), and [multiLineInput].
|
||||
/// * **Themed Styling**: Features an [elevation] parameter for a card-like
|
||||
/// shadow and custom [borderRadius] (defaults to 8.0).
|
||||
/// * **Labeling**: Includes a header row that displays the [hintText]
|
||||
/// and an "(Optional)" tag based on [requiredText].
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihTextFormField(
|
||||
/// controller: _emailController,
|
||||
/// hintText: "Email Address",
|
||||
/// requiredText: true,
|
||||
/// fillColor: Colors.white,
|
||||
/// inputColor: Colors.black,
|
||||
/// validator: (value) => value!.contains('@') ? null : "Invalid Email",
|
||||
/// )
|
||||
/// ```
|
||||
class MihTextFormField extends StatefulWidget {
|
||||
/// The total width of the input field container.
|
||||
final double? width;
|
||||
|
||||
/// /// The total height of the input field container.
|
||||
final double? height;
|
||||
|
||||
/// Whether to use Dark Mode styling for validation and borders.
|
||||
final bool? darkMode;
|
||||
|
||||
/// The background color of the input field.
|
||||
final Color fillColor;
|
||||
|
||||
/// The color of the text entered by the user.
|
||||
final Color inputColor;
|
||||
|
||||
/// The controller managing the field's text content.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// Manually force an error state (thick red border).
|
||||
final bool? hasError;
|
||||
|
||||
/// The label displayed above the input field.
|
||||
final String? hintText;
|
||||
|
||||
/// Custom corner radius for the input box. Defaults to 8.0.
|
||||
final double? borderRadius;
|
||||
|
||||
/// Allows for expandable, multi-line text entry.
|
||||
final bool? multiLineInput;
|
||||
|
||||
/// Prevents the user from modifying the text.
|
||||
final bool? readOnly;
|
||||
|
||||
/// Obfuscates text for sensitive inputs like passwords.
|
||||
final bool? passwordMode;
|
||||
|
||||
/// Optimizes the keyboard for integer/numeric input.
|
||||
final bool? numberMode;
|
||||
|
||||
/// Whether the field is mandatory. Displays "(Optional)" if false.
|
||||
final bool requiredText;
|
||||
|
||||
/// Standard Flutter validator for form integration.
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
/// Provides hints to the system for autofilling (e.g., Email, SMS code).
|
||||
final List<String>? autofillHints;
|
||||
|
||||
/// Built-in elevation/shadow depth for the input box.
|
||||
final double? elevation;
|
||||
|
||||
/// Controls the alignment of the text within the input (e.g., center for PINs).
|
||||
final TextAlign? textIputAlignment;
|
||||
|
||||
const MihTextFormField({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.fillColor,
|
||||
required this.inputColor,
|
||||
required this.controller,
|
||||
this.hasError,
|
||||
required this.hintText,
|
||||
required this.requiredText,
|
||||
this.borderRadius,
|
||||
this.multiLineInput,
|
||||
this.readOnly,
|
||||
this.passwordMode,
|
||||
this.numberMode,
|
||||
this.validator,
|
||||
this.autofillHints,
|
||||
this.elevation,
|
||||
this.textIputAlignment,
|
||||
this.darkMode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihTextFormField> createState() => _MihTextFormFieldState();
|
||||
}
|
||||
|
||||
class _MihTextFormFieldState extends State<MihTextFormField> {
|
||||
late bool _obscureText;
|
||||
FormFieldState<String>? _formFieldState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_obscureText = widget.passwordMode ?? false;
|
||||
widget.controller.addListener(_onControllerTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MihTextFormField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// If the controller itself changes, remove listener from old and add to new
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
oldWidget.controller.removeListener(_onControllerTextChanged);
|
||||
widget.controller.addListener(_onControllerTextChanged);
|
||||
// Immediately update form field state if controller changed and has value
|
||||
_formFieldState?.didChange(widget.controller.text);
|
||||
}
|
||||
}
|
||||
|
||||
void _onControllerTextChanged() {
|
||||
// Only update the FormField's value if it's not already the same
|
||||
// and if the formFieldState is available.
|
||||
if (_formFieldState != null &&
|
||||
_formFieldState!.value != widget.controller.text) {
|
||||
_formFieldState!.didChange(widget.controller.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMultiline = widget.multiLineInput == true;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
// height: widget.height,
|
||||
height: isMultiline ? null : widget.height,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
textSelectionTheme: TextSelectionThemeData(
|
||||
selectionColor: widget.inputColor.withValues(alpha: 0.3),
|
||||
selectionHandleColor: widget.inputColor,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Visibility(
|
||||
visible: widget.hintText != null,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.hintText ?? "",
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
color: widget.fillColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: !widget.requiredText,
|
||||
child: Text(
|
||||
"(Optional)",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
color: widget.fillColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FormField<String>(
|
||||
initialValue: widget.controller.text,
|
||||
validator: widget.validator,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
builder: (field) {
|
||||
_formFieldState = field;
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start, // <-- Add this line
|
||||
children: [
|
||||
Material(
|
||||
elevation: widget.elevation ?? 4.0,
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: widget.height != null
|
||||
? widget.height! - 30
|
||||
: null,
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
cursorColor: widget.inputColor,
|
||||
autofillHints: widget.autofillHints,
|
||||
autocorrect: true,
|
||||
// spellCheckConfiguration: (kIsWeb ||
|
||||
// widget.passwordMode == true ||
|
||||
// widget.numberMode == true)
|
||||
// ? null
|
||||
// : SpellCheckConfiguration(),
|
||||
spellCheckConfiguration:
|
||||
!kIsWeb &&
|
||||
(Platform.isAndroid || Platform.isIOS)
|
||||
? SpellCheckConfiguration()
|
||||
: null,
|
||||
textAlign:
|
||||
widget.textIputAlignment ?? TextAlign.start,
|
||||
textAlignVertical: widget.multiLineInput == true
|
||||
? TextAlignVertical.top
|
||||
: TextAlignVertical.center,
|
||||
obscureText: widget.passwordMode == true
|
||||
? _obscureText
|
||||
: false,
|
||||
expands: widget.passwordMode == true
|
||||
? false
|
||||
: (widget.multiLineInput ?? false),
|
||||
maxLines: widget.passwordMode == true ? 1 : null,
|
||||
readOnly: widget.readOnly ?? false,
|
||||
keyboardType: widget.numberMode == true
|
||||
? const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
)
|
||||
: null,
|
||||
inputFormatters: widget.numberMode == true
|
||||
? [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d*\.?\d*'),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
style: TextStyle(
|
||||
color: widget.inputColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: widget.passwordMode == true
|
||||
? FocusScope(
|
||||
canRequestFocus: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_obscureText
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
color: widget.inputColor,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
errorStyle: const TextStyle(
|
||||
height: 0,
|
||||
fontSize: 0,
|
||||
), // <-- Add this line
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: widget.fillColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: field.hasError
|
||||
? BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 2.0,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: field.hasError
|
||||
? MihColors.red(darkMode: widget.darkMode)
|
||||
: widget.inputColor,
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
field.didChange(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (field.hasError)
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
top: 4.0,
|
||||
),
|
||||
child: Text(
|
||||
field.errorText ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
248
lib/src/mih_time_field.dart
Normal file
248
lib/src/mih_time_field.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A specialized input field for selecting and displaying time.
|
||||
///
|
||||
/// [MihTimeField] provides a read-only text input that, when tapped,
|
||||
/// opens the system time picker. It ensures a consistent user experience
|
||||
/// by forcing a **24-hour format** and updating an external [controller].
|
||||
///
|
||||
/// Features:
|
||||
/// * **System Picker Integration**: Wraps [showTimePicker] with a custom
|
||||
/// [MediaQuery] to enforce 24-hour time regardless of device settings.
|
||||
/// * **Consistent UI**: Shares the same design language as [MihTextFormField],
|
||||
/// including [elevation], [borderRadius], and labeling logic.
|
||||
/// * **Form Validation**: Fully compatible with [Form] widgets via the
|
||||
/// [validator] property and internal [FormField] state tracking.
|
||||
/// * **Visual Feedback**: Displays a thick red border and error text
|
||||
/// automatically upon failed validation.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihTimeField(
|
||||
/// controller: startTimeController,
|
||||
/// labelText: "Shift Start",
|
||||
/// required: true,
|
||||
/// elevation: 2.0,
|
||||
/// validator: (value) => value == null ? "Please pick a time" : null,
|
||||
/// )
|
||||
/// ```
|
||||
class MihTimeField extends StatefulWidget {
|
||||
/// The controller that stores and displays the selected time string (HH:mm).
|
||||
final TextEditingController controller;
|
||||
|
||||
/// The label displayed above the time input.
|
||||
final String labelText;
|
||||
|
||||
/// Whether the field is mandatory. Displays "(Optional)" if false.
|
||||
final bool required;
|
||||
|
||||
/// The width of the input field container.
|
||||
final double? width;
|
||||
|
||||
/// The height of the input field container.
|
||||
final double? height;
|
||||
|
||||
/// The corner radius of the input field. Defaults to 8.0.
|
||||
final double? borderRadius;
|
||||
|
||||
/// The shadow depth for the input box.
|
||||
final double? elevation;
|
||||
|
||||
/// Whether to use Dark Mode styling for borders and error text.
|
||||
final bool? darkMode;
|
||||
|
||||
/// Standard Flutter validator for form integration.
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
const MihTimeField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
required this.required,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.elevation,
|
||||
this.darkMode,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihTimeField> createState() => _MihTimeFieldState();
|
||||
}
|
||||
|
||||
class _MihTimeFieldState extends State<MihTimeField> {
|
||||
FormFieldState<String>? _formFieldState;
|
||||
|
||||
Future<void> _selectTime(BuildContext context) async {
|
||||
TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: widget.controller.text.isNotEmpty
|
||||
? TimeOfDay(
|
||||
hour: int.tryParse(widget.controller.text.split(":")[0]) ?? 0,
|
||||
minute: int.tryParse(widget.controller.text.split(":")[1]) ?? 0,
|
||||
)
|
||||
: TimeOfDay.now(),
|
||||
builder: (context, child) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
||||
child: child as Widget,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (picked != null) {
|
||||
final hours = picked.hour.toString().padLeft(2, '0');
|
||||
final minutes = picked.minute.toString().padLeft(2, '0');
|
||||
widget.controller.text = "$hours:$minutes";
|
||||
_formFieldState?.didChange(widget.controller.text);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.labelText,
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!widget.required)
|
||||
Text(
|
||||
"(Optional)",
|
||||
style: TextStyle(
|
||||
color: MihColors.secondary(darkMode: widget.darkMode),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FormField<String>(
|
||||
initialValue: widget.controller.text,
|
||||
validator: widget.validator,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
builder: (field) {
|
||||
_formFieldState = field;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Material(
|
||||
elevation: widget.elevation ?? 4.0,
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
readOnly: true,
|
||||
onTap: () => _selectTime(context),
|
||||
style: TextStyle(
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: Icon(
|
||||
Icons.access_time,
|
||||
color: MihColors.primary(darkMode: widget.darkMode),
|
||||
),
|
||||
errorStyle: const TextStyle(height: 0, fontSize: 0),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: field.hasError
|
||||
? BorderSide(
|
||||
color: MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 2.0,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: field.hasError
|
||||
? MihColors.red(darkMode: widget.darkMode)
|
||||
: MihColors.secondary(
|
||||
darkMode: widget.darkMode,
|
||||
),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.borderRadius ?? 8.0,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
field.didChange(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (field.hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
field.errorText ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: MihColors.red(darkMode: widget.darkMode),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/src/mih_toggle.dart
Normal file
184
lib/src/mih_toggle.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mih_package_toolkit/src/mih_colors.dart';
|
||||
|
||||
/// A stylized binary switch with integrated labeling.
|
||||
///
|
||||
/// [MihToggle] provides a labeled toggle switch that uses the MIH
|
||||
/// color palette to provide clear visual feedback.
|
||||
///
|
||||
/// Features:
|
||||
/// * **Semantic Color Coding**: Automatically uses [MihColors.green] for
|
||||
/// the "On" state and [MihColors.red] for the "Off" state.
|
||||
/// * **Read-Only Mode**: When [readOnly] is true, the toggle is greyed
|
||||
/// out and interactions are disabled, preserving the UI layout while
|
||||
/// preventing input.
|
||||
/// * **Elevation Support**: Includes a [Material] wrapper to allow for
|
||||
/// shadows and depth, matching other form components.
|
||||
/// * **State Syncing**: Automatically updates its internal position if the
|
||||
/// [initialPostion] property is changed by a parent widget.
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```dart
|
||||
/// MihToggle(
|
||||
/// hintText: "Enable Notifications",
|
||||
/// initialPostion: true,
|
||||
/// fillColor: Colors.blue,
|
||||
/// secondaryFillColor: Colors.white,
|
||||
/// onChange: (val) => print("Toggle is now: $val"),
|
||||
/// )
|
||||
/// ```
|
||||
class MihToggle extends StatefulWidget {
|
||||
/// The total width of the toggle row container.
|
||||
final double? width;
|
||||
|
||||
/// The label text displayed next to the toggle.
|
||||
final String hintText;
|
||||
|
||||
/// The initial state of the toggle (true for on, false for off).
|
||||
final bool initialPostion;
|
||||
|
||||
/// The color of the label text and the outer elevation surface.
|
||||
final Color fillColor;
|
||||
|
||||
/// The color of the moving toggle thumb.
|
||||
final Color secondaryFillColor;
|
||||
|
||||
/// If true, the toggle is displayed in a greyed-out, non-interactive state.
|
||||
final bool? readOnly;
|
||||
|
||||
/// The shadow depth of the toggle container.
|
||||
final double? elevation;
|
||||
|
||||
/// Whether to use Dark Mode shades for the track colors.
|
||||
final bool? darkMode;
|
||||
|
||||
/// Callback triggered whenever the toggle state changes.
|
||||
final void Function(bool) onChange;
|
||||
const MihToggle({
|
||||
super.key,
|
||||
this.width,
|
||||
required this.hintText,
|
||||
required this.initialPostion,
|
||||
required this.fillColor,
|
||||
required this.secondaryFillColor,
|
||||
this.readOnly,
|
||||
this.elevation,
|
||||
this.darkMode,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MihToggle> createState() => _MihToggleState();
|
||||
}
|
||||
|
||||
class _MihToggleState extends State<MihToggle> {
|
||||
late bool togglePosition;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MihToggle oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialPostion != oldWidget.initialPostion) {
|
||||
setState(() {
|
||||
togglePosition = widget.initialPostion;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
togglePosition = widget.initialPostion;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hintText,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.fillColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Material(
|
||||
// elevation: widget.elevation ?? 0.01,
|
||||
// shadowColor: widget.secondaryFillColor.withOpacity(0.5),
|
||||
// color: Colors.transparent,
|
||||
// shape: StadiumBorder(),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(
|
||||
30,
|
||||
), // Adjust the border radius to match the toggle
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
offset: Offset(
|
||||
0,
|
||||
widget.elevation ?? 10,
|
||||
), // Adjust the vertical offset
|
||||
blurRadius: widget.elevation ?? 10,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Switch(
|
||||
value: togglePosition,
|
||||
trackOutlineColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
states,
|
||||
) {
|
||||
if (widget.readOnly == true) {
|
||||
return Colors.grey;
|
||||
}
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return MihColors.green(
|
||||
darkMode: widget.darkMode,
|
||||
); // Outline color when active
|
||||
}
|
||||
return MihColors.red(
|
||||
darkMode: widget.darkMode,
|
||||
); // Outline color when active
|
||||
}),
|
||||
activeColor: widget.readOnly == true
|
||||
? Colors.grey
|
||||
: widget.secondaryFillColor,
|
||||
activeTrackColor: widget.readOnly == true
|
||||
? Colors.grey.shade400
|
||||
: MihColors.green(darkMode: widget.darkMode),
|
||||
inactiveThumbColor: widget.readOnly == true
|
||||
? Colors.grey
|
||||
: widget.secondaryFillColor,
|
||||
inactiveTrackColor: widget.readOnly == true
|
||||
? Colors.grey.shade400
|
||||
: MihColors.red(darkMode: widget.darkMode),
|
||||
// activeColor: widget.secondaryFillColor,
|
||||
// activeTrackColor: widget.fillColor,
|
||||
// inactiveThumbColor: widget.fillColor,
|
||||
// inactiveTrackColor: widget.secondaryFillColor,
|
||||
// onChanged: widget.readOnly != true ? widget.onChange : null,
|
||||
onChanged: widget.readOnly != true
|
||||
? (newValue) {
|
||||
setState(() {
|
||||
togglePosition = newValue; // Update internal state
|
||||
});
|
||||
widget.onChange(newValue); // Call the parent's onChange
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user