first commit
This commit is contained in:
217
lib/flutterlib/custom_functions.dart
Normal file
217
lib/flutterlib/custom_functions.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import 'lat_lng.dart';
|
||||
import 'place.dart';
|
||||
import 'uploaded_file.dart';
|
||||
import '/backend/schema/enums/enums.dart';
|
||||
import '/auth/custom_auth/auth_util.dart';
|
||||
|
||||
String formatDate(String date) {
|
||||
DateTime parseDate = DateTime.parse(date);
|
||||
DateFormat format = DateFormat('yyyy/MM/dd');
|
||||
String formatedDate = format.format(parseDate);
|
||||
return formatedDate;
|
||||
}
|
||||
|
||||
String? convertToUpperCase(String? text) {
|
||||
// Check if the input text is not null before converting to uppercase
|
||||
if (text != null && text.isNotEmpty) {
|
||||
List<String> words = text.split(' ');
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
String word = words[i];
|
||||
if (word.isNotEmpty) {
|
||||
words[i] = word[0].toUpperCase() + word.substring(1);
|
||||
}
|
||||
}
|
||||
return words.join(' ');
|
||||
} else {
|
||||
return null; // or return an empty string, depending on your use case
|
||||
}
|
||||
}
|
||||
|
||||
String? positionLabel(String? position) {
|
||||
switch (position) {
|
||||
//data to label
|
||||
case 'director':
|
||||
return 'Director';
|
||||
case 'shareholder':
|
||||
return 'Shareholder';
|
||||
case 'company_secretary':
|
||||
return 'Company Secretary';
|
||||
//label to data
|
||||
case 'Director':
|
||||
return 'director';
|
||||
case 'Shareholder':
|
||||
return 'shareholder';
|
||||
case 'Company Secretary':
|
||||
return 'company_secretary';
|
||||
default:
|
||||
return position; // return the original value if not recognized
|
||||
}
|
||||
}
|
||||
|
||||
String formatTime(String timestamp) {
|
||||
// Parse the timestamp
|
||||
DateTime dateTime = DateTime.parse(timestamp).toLocal();
|
||||
|
||||
// Convert to the desired format (9:54)
|
||||
String formattedTime = DateFormat.Hm().format(dateTime);
|
||||
|
||||
return formattedTime;
|
||||
}
|
||||
|
||||
bool comparePermission(
|
||||
List<int> getPermissionID,
|
||||
int permissionID,
|
||||
) {
|
||||
// Function definition
|
||||
bool isIntInList(int target, List<int> intList) {
|
||||
return intList.contains(target);
|
||||
}
|
||||
|
||||
int myTargetInt = permissionID;
|
||||
List<int> myIntList = getPermissionID;
|
||||
|
||||
// Check if the list is empty, trigger an alert or return a different value
|
||||
if (myIntList.isEmpty) {
|
||||
// Trigger an alert or return a different value
|
||||
// You can replace the following line with your alert logic or different return value
|
||||
print('Alert: The permission list is empty!');
|
||||
return false;
|
||||
}
|
||||
|
||||
return isIntInList(myTargetInt, myIntList);
|
||||
}
|
||||
|
||||
String formatDate2(String date) {
|
||||
DateTime parseDate = DateTime.parse(date);
|
||||
DateFormat format = DateFormat('yyyyMMdd');
|
||||
String formatedDate = format.format(parseDate);
|
||||
return formatedDate;
|
||||
}
|
||||
|
||||
String getCategoryLabel(int categoryId) {
|
||||
String label;
|
||||
switch (categoryId) {
|
||||
case 1:
|
||||
label = 'Bank Statement';
|
||||
break;
|
||||
case 2:
|
||||
label = 'Income';
|
||||
break;
|
||||
case 3:
|
||||
label = 'Expense';
|
||||
break;
|
||||
case 4:
|
||||
label = 'Others';
|
||||
break;
|
||||
case 5:
|
||||
label = 'Income Tax Statement';
|
||||
break;
|
||||
default:
|
||||
label =
|
||||
'Unknown Category'; // Default label if category ID doesn't match any case
|
||||
break;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
Color getStatusColor(String status) {
|
||||
switch (status) {
|
||||
case "uploaded":
|
||||
case "in_progress":
|
||||
return Colors.white;
|
||||
case "ocr_complete":
|
||||
return Color(0xFFFFEFBD); // Yellow color for "ocr_complete"
|
||||
case "failed":
|
||||
return Color(0xFFF5AE97); // Red color for "failed"
|
||||
default:
|
||||
return Colors.white; // Default color if status doesn't match any case
|
||||
}
|
||||
}
|
||||
|
||||
List<String> filteredRemovePermissions(
|
||||
List<String>? addPermissions,
|
||||
List<String>? removePermissions,
|
||||
) {
|
||||
if (addPermissions == null || removePermissions == null) {
|
||||
// Handle the case where one or both lists are null
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create sets for faster lookup
|
||||
Set<String> addSet = Set<String>.from(addPermissions);
|
||||
Set<String> removeSet = Set<String>.from(removePermissions);
|
||||
|
||||
// Find the differing values
|
||||
List<String> differingValues = removeSet.difference(addSet).toList();
|
||||
|
||||
// Print the differing values (you can replace this with your desired logic)
|
||||
print('Differing values: $differingValues');
|
||||
|
||||
return differingValues;
|
||||
}
|
||||
|
||||
List<dynamic> extractStatus(String jsonData) {
|
||||
try {
|
||||
final Map<String, dynamic> data = jsonDecode(jsonData);
|
||||
|
||||
if (data.containsKey('documents') && data['documents'] is List) {
|
||||
List<dynamic> documents = data['documents'];
|
||||
|
||||
// Filter the documents with 'completed' status
|
||||
List<Map<String, dynamic>> completedDocuments = documents
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where((doc) => doc['status'] == 'completed')
|
||||
.toList();
|
||||
|
||||
// Extract necessary information
|
||||
List<dynamic> extractedData = completedDocuments.map((doc) {
|
||||
return {
|
||||
'Category ID': doc['bookkeeping_document_category_id'],
|
||||
'URL': doc['url'],
|
||||
'Created At': doc['created_at']
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return extractedData;
|
||||
} else {
|
||||
print('Invalid JSON format or missing "documents" key.');
|
||||
return []; // Return an empty list if data is not as expected
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error: $e');
|
||||
return []; // Return an empty list in case of an error
|
||||
}
|
||||
}
|
||||
|
||||
String getStatus2(String status) {
|
||||
switch (status) {
|
||||
case "uploaded":
|
||||
return 'Uploaded'; // Blue color for "Uploaded"
|
||||
case "in_progress":
|
||||
return 'In progress'; // Blue color for "In progress"
|
||||
case "ocr_complete":
|
||||
return 'OCR complete'; // Yellow color for "OCR complete"
|
||||
case "failed":
|
||||
return 'Failed'; // Red color for "Failed"
|
||||
default:
|
||||
return '::0xFFFFFFFF'; // Default color if status doesn't match any case (white color)
|
||||
}
|
||||
}
|
||||
|
||||
List<String> addAccessRightPrefix(List<dynamic> roles) {
|
||||
List<String> result = [];
|
||||
|
||||
for (String item in roles) {
|
||||
String newItem = "Access right : $item";
|
||||
result.add(newItem);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
93
lib/flutterlib/flutter_animations.dart
Normal file
93
lib/flutterlib/flutter_animations.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
enum AnimationTrigger {
|
||||
onPageLoad,
|
||||
onActionTrigger,
|
||||
}
|
||||
|
||||
class AnimationInfo {
|
||||
AnimationInfo({
|
||||
required this.trigger,
|
||||
required this.effects,
|
||||
this.loop = false,
|
||||
this.reverse = false,
|
||||
this.applyInitialState = true,
|
||||
});
|
||||
final AnimationTrigger trigger;
|
||||
final List<Effect<dynamic>> effects;
|
||||
final bool applyInitialState;
|
||||
final bool loop;
|
||||
final bool reverse;
|
||||
late AnimationController controller;
|
||||
}
|
||||
|
||||
void createAnimation(AnimationInfo animation, TickerProvider vsync) {
|
||||
final newController = AnimationController(vsync: vsync);
|
||||
animation.controller = newController;
|
||||
}
|
||||
|
||||
void setupAnimations(Iterable<AnimationInfo> animations, TickerProvider vsync) {
|
||||
animations.forEach((animation) => createAnimation(animation, vsync));
|
||||
}
|
||||
|
||||
extension AnimatedWidgetExtension on Widget {
|
||||
Widget animateOnPageLoad(AnimationInfo animationInfo) => Animate(
|
||||
effects: animationInfo.effects,
|
||||
child: this,
|
||||
onPlay: (controller) => animationInfo.loop
|
||||
? controller.repeat(reverse: animationInfo.reverse)
|
||||
: null,
|
||||
onComplete: (controller) => !animationInfo.loop && animationInfo.reverse
|
||||
? controller.reverse()
|
||||
: null);
|
||||
|
||||
Widget animateOnActionTrigger(
|
||||
AnimationInfo animationInfo, {
|
||||
bool hasBeenTriggered = false,
|
||||
}) =>
|
||||
hasBeenTriggered || animationInfo.applyInitialState
|
||||
? Animate(
|
||||
controller: animationInfo.controller,
|
||||
autoPlay: false,
|
||||
effects: animationInfo.effects,
|
||||
child: this)
|
||||
: this;
|
||||
}
|
||||
|
||||
class TiltEffect extends Effect<Offset> {
|
||||
const TiltEffect({
|
||||
Duration? delay,
|
||||
Duration? duration,
|
||||
Curve? curve,
|
||||
Offset? begin,
|
||||
Offset? end,
|
||||
}) : super(
|
||||
delay: delay,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
begin: begin ?? const Offset(0.0, 0.0),
|
||||
end: end ?? const Offset(0.0, 0.0),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
AnimationController controller,
|
||||
EffectEntry entry,
|
||||
) {
|
||||
Animation<Offset> animation = buildAnimation(controller, entry);
|
||||
return getOptimizedBuilder<Offset>(
|
||||
animation: animation,
|
||||
builder: (_, __) => Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateX(animation.value.dx)
|
||||
..rotateY(animation.value.dy),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
865
lib/flutterlib/flutter_button_tabbar.dart
Normal file
865
lib/flutterlib/flutter_button_tabbar.dart
Normal file
@@ -0,0 +1,865 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
const double _kTabHeight = 46.0;
|
||||
|
||||
typedef _LayoutCallback = void Function(
|
||||
List<double> xOffsets, TextDirection textDirection, double width);
|
||||
|
||||
class _TabLabelBarRenderer extends RenderFlex {
|
||||
_TabLabelBarRenderer({
|
||||
required Axis direction,
|
||||
required MainAxisSize mainAxisSize,
|
||||
required MainAxisAlignment mainAxisAlignment,
|
||||
required CrossAxisAlignment crossAxisAlignment,
|
||||
required TextDirection textDirection,
|
||||
required VerticalDirection verticalDirection,
|
||||
required this.onPerformLayout,
|
||||
}) : super(
|
||||
direction: direction,
|
||||
mainAxisSize: mainAxisSize,
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
textDirection: textDirection,
|
||||
verticalDirection: verticalDirection,
|
||||
);
|
||||
|
||||
_LayoutCallback onPerformLayout;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
// xOffsets will contain childCount+1 values, giving the offsets of the
|
||||
// leading edge of the first tab as the first value, of the leading edge of
|
||||
// the each subsequent tab as each subsequent value, and of the trailing
|
||||
// edge of the last tab as the last value.
|
||||
RenderBox? child = firstChild;
|
||||
final List<double> xOffsets = <double>[];
|
||||
while (child != null) {
|
||||
final FlexParentData childParentData =
|
||||
child.parentData! as FlexParentData;
|
||||
xOffsets.add(childParentData.offset.dx);
|
||||
assert(child.parentData == childParentData);
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
assert(textDirection != null);
|
||||
switch (textDirection!) {
|
||||
case TextDirection.rtl:
|
||||
xOffsets.insert(0, size.width);
|
||||
break;
|
||||
case TextDirection.ltr:
|
||||
xOffsets.add(size.width);
|
||||
break;
|
||||
}
|
||||
onPerformLayout(xOffsets, textDirection!, size.width);
|
||||
}
|
||||
}
|
||||
|
||||
// This class and its renderer class only exist to report the widths of the tabs
|
||||
// upon layout. The tab widths are only used at paint time (see _IndicatorPainter)
|
||||
// or in response to input.
|
||||
class _TabLabelBar extends Flex {
|
||||
_TabLabelBar({
|
||||
required List<Widget> children,
|
||||
required this.onPerformLayout,
|
||||
}) : super(
|
||||
children: children,
|
||||
direction: Axis.horizontal,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
verticalDirection: VerticalDirection.down,
|
||||
);
|
||||
|
||||
final _LayoutCallback onPerformLayout;
|
||||
|
||||
@override
|
||||
RenderFlex createRenderObject(BuildContext context) {
|
||||
return _TabLabelBarRenderer(
|
||||
direction: direction,
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
mainAxisSize: mainAxisSize,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
textDirection: getEffectiveTextDirection(context)!,
|
||||
verticalDirection: verticalDirection,
|
||||
onPerformLayout: onPerformLayout,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context, _TabLabelBarRenderer renderObject) {
|
||||
super.updateRenderObject(context, renderObject);
|
||||
renderObject.onPerformLayout = onPerformLayout;
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorPainter extends CustomPainter {
|
||||
_IndicatorPainter({
|
||||
required this.controller,
|
||||
required this.tabKeys,
|
||||
required _IndicatorPainter? old,
|
||||
}) : super(repaint: controller.animation) {
|
||||
if (old != null) {
|
||||
saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
|
||||
}
|
||||
}
|
||||
|
||||
final TabController controller;
|
||||
|
||||
final List<GlobalKey> tabKeys;
|
||||
|
||||
// _currentTabOffsets and _currentTextDirection are set each time TabBar
|
||||
// layout is completed. These values can be null when TabBar contains no
|
||||
// tabs, since there are nothing to lay out.
|
||||
List<double>? _currentTabOffsets;
|
||||
TextDirection? _currentTextDirection;
|
||||
|
||||
BoxPainter? _painter;
|
||||
bool _needsPaint = false;
|
||||
void markNeedsPaint() {
|
||||
_needsPaint = true;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_painter?.dispose();
|
||||
}
|
||||
|
||||
void saveTabOffsets(List<double>? tabOffsets, TextDirection? textDirection) {
|
||||
_currentTabOffsets = tabOffsets;
|
||||
_currentTextDirection = textDirection;
|
||||
}
|
||||
|
||||
// _currentTabOffsets[index] is the offset of the start edge of the tab at index, and
|
||||
// _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab.
|
||||
int get maxTabIndex => _currentTabOffsets!.length - 2;
|
||||
|
||||
double centerOf(int tabIndex) {
|
||||
assert(_currentTabOffsets != null);
|
||||
assert(_currentTabOffsets!.isNotEmpty);
|
||||
assert(tabIndex >= 0);
|
||||
assert(tabIndex <= maxTabIndex);
|
||||
return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) /
|
||||
2.0;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_needsPaint = false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_IndicatorPainter old) {
|
||||
return _needsPaint ||
|
||||
controller != old.controller ||
|
||||
tabKeys.length != old.tabKeys.length ||
|
||||
(!listEquals(_currentTabOffsets, old._currentTabOffsets)) ||
|
||||
_currentTextDirection != old._currentTextDirection;
|
||||
}
|
||||
}
|
||||
|
||||
// This class, and TabBarScrollController, only exist to handle the case
|
||||
// where a scrollable TabBar has a non-zero initialIndex. In that case we can
|
||||
// only compute the scroll position's initial scroll offset (the "correct"
|
||||
// pixels value) after the TabBar viewport width and scroll limits are known.
|
||||
|
||||
class _TabBarScrollPosition extends ScrollPositionWithSingleContext {
|
||||
_TabBarScrollPosition({
|
||||
required ScrollPhysics physics,
|
||||
required ScrollContext context,
|
||||
required ScrollPosition? oldPosition,
|
||||
required this.tabBar,
|
||||
}) : super(
|
||||
initialPixels: null,
|
||||
physics: physics,
|
||||
context: context,
|
||||
oldPosition: oldPosition,
|
||||
);
|
||||
|
||||
final _FlutterButtonTabBarState tabBar;
|
||||
|
||||
bool _viewportDimensionWasNonZero = false;
|
||||
|
||||
// Position should be adjusted at least once.
|
||||
bool _needsPixelsCorrection = true;
|
||||
|
||||
@override
|
||||
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
|
||||
bool result = true;
|
||||
if (!_viewportDimensionWasNonZero) {
|
||||
_viewportDimensionWasNonZero = viewportDimension != 0.0;
|
||||
}
|
||||
// If the viewport never had a non-zero dimension, we just want to jump
|
||||
// to the initial scroll position to avoid strange scrolling effects in
|
||||
// release mode: In release mode, the viewport temporarily may have a
|
||||
// dimension of zero before the actual dimension is calculated. In that
|
||||
// scenario, setting the actual dimension would cause a strange scroll
|
||||
// effect without this guard because the super call below would starts a
|
||||
// ballistic scroll activity.
|
||||
if (!_viewportDimensionWasNonZero || _needsPixelsCorrection) {
|
||||
_needsPixelsCorrection = false;
|
||||
correctPixels(tabBar._initialScrollOffset(
|
||||
viewportDimension, minScrollExtent, maxScrollExtent));
|
||||
result = false;
|
||||
}
|
||||
return super.applyContentDimensions(minScrollExtent, maxScrollExtent) &&
|
||||
result;
|
||||
}
|
||||
|
||||
void markNeedsPixelsCorrection() {
|
||||
_needsPixelsCorrection = true;
|
||||
}
|
||||
}
|
||||
|
||||
// This class, and TabBarScrollPosition, only exist to handle the case
|
||||
// where a scrollable TabBar has a non-zero initialIndex.
|
||||
class _TabBarScrollController extends ScrollController {
|
||||
_TabBarScrollController(this.tabBar);
|
||||
|
||||
final _FlutterButtonTabBarState tabBar;
|
||||
|
||||
@override
|
||||
ScrollPosition createScrollPosition(ScrollPhysics physics,
|
||||
ScrollContext context, ScrollPosition? oldPosition) {
|
||||
return _TabBarScrollPosition(
|
||||
physics: physics,
|
||||
context: context,
|
||||
oldPosition: oldPosition,
|
||||
tabBar: tabBar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A flutter Design widget that displays a horizontal row of tabs.
|
||||
class FlutterButtonTabBar extends StatefulWidget
|
||||
implements PreferredSizeWidget {
|
||||
/// The [tabs] argument must not be null and its length must match the [controller]'s
|
||||
/// [TabController.length].
|
||||
///
|
||||
/// If a [TabController] is not provided, then there must be a
|
||||
/// [DefaultTabController] ancestor.
|
||||
///
|
||||
const FlutterButtonTabBar({
|
||||
Key? key,
|
||||
required this.tabs,
|
||||
this.controller,
|
||||
this.isScrollable = false,
|
||||
this.useToggleButtonStyle = false,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
this.unselectedBackgroundColor,
|
||||
this.decoration,
|
||||
this.unselectedDecoration,
|
||||
this.labelStyle,
|
||||
this.unselectedLabelStyle,
|
||||
this.labelColor,
|
||||
this.unselectedLabelColor,
|
||||
this.borderWidth = 0,
|
||||
this.borderColor = Colors.transparent,
|
||||
this.unselectedBorderColor = Colors.transparent,
|
||||
this.physics = const BouncingScrollPhysics(),
|
||||
this.labelPadding = const EdgeInsets.symmetric(horizontal: 4),
|
||||
this.buttonMargin = const EdgeInsets.all(4),
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.borderRadius = 8.0,
|
||||
this.elevation = 0,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Typically a list of two or more [Tab] widgets.
|
||||
///
|
||||
/// The length of this list must match the [controller]'s [TabController.length]
|
||||
/// and the length of the [TabBarView.children] list.
|
||||
final List<Widget> tabs;
|
||||
|
||||
/// This widget's selection and animation state.
|
||||
///
|
||||
/// If [TabController] is not provided, then the value of [DefaultTabController.of]
|
||||
/// will be used.
|
||||
final TabController? controller;
|
||||
|
||||
/// Whether this tab bar can be scrolled horizontally.
|
||||
///
|
||||
/// If [isScrollable] is true, then each tab is as wide as needed for its label
|
||||
/// and the entire [FlutterButtonTabBar] is scrollable. Otherwise each tab gets an equal
|
||||
/// share of the available space.
|
||||
final bool isScrollable;
|
||||
|
||||
/// Whether the tab buttons should be styled as toggle buttons.
|
||||
final bool useToggleButtonStyle;
|
||||
|
||||
/// The background [Color] of the button on its selected state.
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// The background [Color] of the button on its unselected state.
|
||||
final Color? unselectedBackgroundColor;
|
||||
|
||||
/// The [BoxDecoration] of the button on its selected state.
|
||||
///
|
||||
/// If [BoxDecoration] is not provided, [backgroundColor] is used.
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
/// The [BoxDecoration] of the button on its unselected state.
|
||||
///
|
||||
/// If [BoxDecoration] is not provided, [unselectedBackgroundColor] is used.
|
||||
final BoxDecoration? unselectedDecoration;
|
||||
|
||||
/// The [TextStyle] of the button's [Text] on its selected state. The color provided
|
||||
/// on the TextStyle will be used for the [Icon]'s color.
|
||||
final TextStyle? labelStyle;
|
||||
|
||||
/// The color of selected tab labels.
|
||||
final Color? labelColor;
|
||||
|
||||
/// The color of unselected tab labels.
|
||||
final Color? unselectedLabelColor;
|
||||
|
||||
/// The [TextStyle] of the button's [Text] on its unselected state. The color provided
|
||||
/// on the TextStyle will be used for the [Icon]'s color.
|
||||
final TextStyle? unselectedLabelStyle;
|
||||
|
||||
/// The with of solid [Border] for each button. If no value is provided, the border
|
||||
/// is not drawn.
|
||||
final double borderWidth;
|
||||
|
||||
/// The [Color] of solid [Border] for each button.
|
||||
final Color? borderColor;
|
||||
|
||||
/// The [Color] of solid [Border] for each button. If no value is provided, the value of
|
||||
/// [this.borderColor] is used.
|
||||
final Color? unselectedBorderColor;
|
||||
|
||||
/// The [EdgeInsets] used for the [Padding] of the buttons' content.
|
||||
///
|
||||
/// The default value is [EdgeInsets.symmetric(horizontal: 4)].
|
||||
final EdgeInsetsGeometry labelPadding;
|
||||
|
||||
/// The [EdgeInsets] used for the [Margin] of the buttons.
|
||||
///
|
||||
/// The default value is [EdgeInsets.all(4)].
|
||||
final EdgeInsetsGeometry buttonMargin;
|
||||
|
||||
/// The amount of space by which to inset the tab bar.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// The value of the [BorderRadius.circular] applied to each button.
|
||||
final double borderRadius;
|
||||
|
||||
/// The value of the [elevation] applied to each button.
|
||||
final double elevation;
|
||||
|
||||
final DragStartBehavior dragStartBehavior;
|
||||
|
||||
final ValueChanged<int>? onTap;
|
||||
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// A size whose height depends on if the tabs have both icons and text.
|
||||
///
|
||||
/// [AppBar] uses this size to compute its own preferred size.
|
||||
@override
|
||||
Size get preferredSize {
|
||||
double maxHeight = _kTabHeight;
|
||||
for (final Widget item in tabs) {
|
||||
if (item is PreferredSizeWidget) {
|
||||
final double itemHeight = item.preferredSize.height;
|
||||
maxHeight = math.max(itemHeight, maxHeight);
|
||||
}
|
||||
}
|
||||
return Size.fromHeight(
|
||||
maxHeight + labelPadding.vertical + buttonMargin.vertical);
|
||||
}
|
||||
|
||||
@override
|
||||
State<FlutterButtonTabBar> createState() =>
|
||||
_FlutterButtonTabBarState();
|
||||
}
|
||||
|
||||
class _FlutterButtonTabBarState extends State<FlutterButtonTabBar>
|
||||
with TickerProviderStateMixin {
|
||||
ScrollController? _scrollController;
|
||||
TabController? _controller;
|
||||
_IndicatorPainter? _indicatorPainter;
|
||||
late AnimationController _animationController;
|
||||
int _currentIndex = 0;
|
||||
int _prevIndex = -1;
|
||||
|
||||
late double _tabStripWidth;
|
||||
late List<GlobalKey> _tabKeys;
|
||||
|
||||
final GlobalKey _tabsParentKey = GlobalKey();
|
||||
|
||||
bool _debugHasScheduledValidTabsCountCheck = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find
|
||||
// the width of tab widget i. See _IndicatorPainter.indicatorRect().
|
||||
_tabKeys = widget.tabs.map((tab) => GlobalKey()).toList();
|
||||
|
||||
/// The animation duration is 2/3 of the tab scroll animation duration in
|
||||
/// Material design (kTabScrollDuration).
|
||||
_animationController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
|
||||
// so the buttons start in their "final" state (color)
|
||||
_animationController
|
||||
..value = 1.0
|
||||
..addListener(() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If the TabBar is rebuilt with a new tab controller, the caller should
|
||||
// dispose the old one. In that case the old controller's animation will be
|
||||
// null and should not be accessed.
|
||||
bool get _controllerIsValid => _controller?.animation != null;
|
||||
|
||||
void _updateTabController() {
|
||||
final TabController? newController =
|
||||
widget.controller ?? DefaultTabController.maybeOf(context);
|
||||
assert(() {
|
||||
if (newController == null) {
|
||||
throw FlutterError(
|
||||
'No TabController for ${widget.runtimeType}.\n'
|
||||
'When creating a ${widget.runtimeType}, you must either provide an explicit '
|
||||
'TabController using the "controller" property, or you must ensure that there '
|
||||
'is a DefaultTabController above the ${widget.runtimeType}.\n'
|
||||
'In this case, there was neither an explicit controller nor a default controller.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
|
||||
if (newController == _controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controllerIsValid) {
|
||||
_controller!.animation!.removeListener(_handleTabControllerAnimationTick);
|
||||
_controller!.removeListener(_handleTabControllerTick);
|
||||
}
|
||||
_controller = newController;
|
||||
if (_controller != null) {
|
||||
_controller!.animation!.addListener(_handleTabControllerAnimationTick);
|
||||
_controller!.addListener(_handleTabControllerTick);
|
||||
_currentIndex = _controller!.index;
|
||||
}
|
||||
}
|
||||
|
||||
void _initIndicatorPainter() {
|
||||
_indicatorPainter = !_controllerIsValid
|
||||
? null
|
||||
: _IndicatorPainter(
|
||||
controller: _controller!,
|
||||
tabKeys: _tabKeys,
|
||||
old: _indicatorPainter,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
assert(debugCheckHasMaterial(context));
|
||||
_updateTabController();
|
||||
_initIndicatorPainter();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FlutterButtonTabBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_updateTabController();
|
||||
_initIndicatorPainter();
|
||||
// Adjust scroll position.
|
||||
if (_scrollController != null) {
|
||||
final ScrollPosition position = _scrollController!.position;
|
||||
if (position is _TabBarScrollPosition) {
|
||||
position.markNeedsPixelsCorrection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.tabs.length > _tabKeys.length) {
|
||||
final int delta = widget.tabs.length - _tabKeys.length;
|
||||
_tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey()));
|
||||
} else if (widget.tabs.length < _tabKeys.length) {
|
||||
_tabKeys.removeRange(widget.tabs.length, _tabKeys.length);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_indicatorPainter!.dispose();
|
||||
if (_controllerIsValid) {
|
||||
_controller!.animation!.removeListener(_handleTabControllerAnimationTick);
|
||||
_controller!.removeListener(_handleTabControllerTick);
|
||||
}
|
||||
_controller = null;
|
||||
// We don't own the _controller Animation, so it's not disposed here.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get maxTabIndex => _indicatorPainter!.maxTabIndex;
|
||||
|
||||
double _tabScrollOffset(
|
||||
int index, double viewportWidth, double minExtent, double maxExtent) {
|
||||
if (!widget.isScrollable) {
|
||||
return 0.0;
|
||||
}
|
||||
double tabCenter = _indicatorPainter!.centerOf(index);
|
||||
double paddingStart;
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
paddingStart = widget.padding?.resolve(TextDirection.rtl).right ?? 0;
|
||||
tabCenter = _tabStripWidth - tabCenter;
|
||||
break;
|
||||
case TextDirection.ltr:
|
||||
paddingStart = widget.padding?.resolve(TextDirection.ltr).left ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return clampDouble(
|
||||
tabCenter + paddingStart - viewportWidth / 2.0, minExtent, maxExtent);
|
||||
}
|
||||
|
||||
double _tabCenteredScrollOffset(int index) {
|
||||
final ScrollPosition position = _scrollController!.position;
|
||||
return _tabScrollOffset(index, position.viewportDimension,
|
||||
position.minScrollExtent, position.maxScrollExtent);
|
||||
}
|
||||
|
||||
double _initialScrollOffset(
|
||||
double viewportWidth, double minExtent, double maxExtent) {
|
||||
return _tabScrollOffset(_currentIndex, viewportWidth, minExtent, maxExtent);
|
||||
}
|
||||
|
||||
void _scrollToCurrentIndex() {
|
||||
final double offset = _tabCenteredScrollOffset(_currentIndex);
|
||||
_scrollController!
|
||||
.animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease);
|
||||
}
|
||||
|
||||
void _scrollToControllerValue() {
|
||||
final double? leadingPosition =
|
||||
_currentIndex > 0 ? _tabCenteredScrollOffset(_currentIndex - 1) : null;
|
||||
final double middlePosition = _tabCenteredScrollOffset(_currentIndex);
|
||||
final double? trailingPosition = _currentIndex < maxTabIndex
|
||||
? _tabCenteredScrollOffset(_currentIndex + 1)
|
||||
: null;
|
||||
|
||||
final double index = _controller!.index.toDouble();
|
||||
final double value = _controller!.animation!.value;
|
||||
final double offset;
|
||||
if (value == index - 1.0) {
|
||||
offset = leadingPosition ?? middlePosition;
|
||||
} else if (value == index + 1.0) {
|
||||
offset = trailingPosition ?? middlePosition;
|
||||
} else if (value == index) {
|
||||
offset = middlePosition;
|
||||
} else if (value < index) {
|
||||
offset = leadingPosition == null
|
||||
? middlePosition
|
||||
: lerpDouble(middlePosition, leadingPosition, index - value)!;
|
||||
} else {
|
||||
offset = trailingPosition == null
|
||||
? middlePosition
|
||||
: lerpDouble(middlePosition, trailingPosition, value - index)!;
|
||||
}
|
||||
|
||||
_scrollController!.jumpTo(offset);
|
||||
}
|
||||
|
||||
void _handleTabControllerAnimationTick() {
|
||||
assert(mounted);
|
||||
if (!_controller!.indexIsChanging && widget.isScrollable) {
|
||||
// Sync the TabBar's scroll position with the TabBarView's PageView.
|
||||
_currentIndex = _controller!.index;
|
||||
_scrollToControllerValue();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTabControllerTick() {
|
||||
if (_controller!.index != _currentIndex) {
|
||||
_prevIndex = _currentIndex;
|
||||
_currentIndex = _controller!.index;
|
||||
_triggerAnimation();
|
||||
if (widget.isScrollable) {
|
||||
_scrollToCurrentIndex();
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
// Rebuild the tabs after a (potentially animated) index change
|
||||
// has completed.
|
||||
});
|
||||
}
|
||||
|
||||
void _triggerAnimation() {
|
||||
// reset the animation so it's ready to go
|
||||
_animationController
|
||||
..reset()
|
||||
..forward();
|
||||
}
|
||||
|
||||
// Called each time layout completes.
|
||||
void _saveTabOffsets(
|
||||
List<double> tabOffsets, TextDirection textDirection, double width) {
|
||||
_tabStripWidth = width;
|
||||
_indicatorPainter?.saveTabOffsets(tabOffsets, textDirection);
|
||||
}
|
||||
|
||||
void _handleTap(int index) {
|
||||
assert(index >= 0 && index < widget.tabs.length);
|
||||
_controller?.animateTo(index);
|
||||
widget.onTap?.call(index);
|
||||
}
|
||||
|
||||
Widget _buildStyledTab(Widget child, int index) {
|
||||
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
|
||||
|
||||
final double animationValue;
|
||||
if (index == _currentIndex) {
|
||||
animationValue = _animationController.value;
|
||||
} else if (index == _prevIndex) {
|
||||
animationValue = 1 - _animationController.value;
|
||||
} else {
|
||||
animationValue = 0;
|
||||
}
|
||||
|
||||
final TextStyle? textStyle = TextStyle.lerp(
|
||||
(widget.unselectedLabelStyle ??
|
||||
tabBarTheme.labelStyle ??
|
||||
DefaultTextStyle.of(context).style)
|
||||
.copyWith(
|
||||
color: widget.unselectedLabelColor,
|
||||
),
|
||||
(widget.labelStyle ??
|
||||
tabBarTheme.labelStyle ??
|
||||
DefaultTextStyle.of(context).style)
|
||||
.copyWith(
|
||||
color: widget.labelColor,
|
||||
),
|
||||
animationValue);
|
||||
|
||||
final Color? textColor = Color.lerp(
|
||||
widget.unselectedLabelColor, widget.labelColor, animationValue);
|
||||
|
||||
final Color? borderColor = Color.lerp(
|
||||
widget.unselectedBorderColor, widget.borderColor, animationValue);
|
||||
|
||||
BoxDecoration? boxDecoration = BoxDecoration.lerp(
|
||||
BoxDecoration(
|
||||
color: widget.unselectedDecoration?.color ??
|
||||
widget.unselectedBackgroundColor ??
|
||||
Colors.transparent,
|
||||
boxShadow: widget.unselectedDecoration?.boxShadow,
|
||||
gradient: widget.unselectedDecoration?.gradient,
|
||||
borderRadius: widget.useToggleButtonStyle
|
||||
? null
|
||||
: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
BoxDecoration(
|
||||
color: widget.decoration?.color ??
|
||||
widget.backgroundColor ??
|
||||
Colors.transparent,
|
||||
boxShadow: widget.decoration?.boxShadow,
|
||||
gradient: widget.decoration?.gradient,
|
||||
borderRadius: widget.useToggleButtonStyle
|
||||
? null
|
||||
: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
animationValue);
|
||||
|
||||
if (widget.useToggleButtonStyle &&
|
||||
widget.borderWidth > 0 &&
|
||||
boxDecoration != null) {
|
||||
if (index == 0) {
|
||||
boxDecoration = boxDecoration.copyWith(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: widget.unselectedBorderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth / 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (index == widget.tabs.length - 1) {
|
||||
boxDecoration = boxDecoration.copyWith(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: widget.unselectedBorderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth / 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
boxDecoration = boxDecoration.copyWith(
|
||||
border: Border.symmetric(
|
||||
vertical: BorderSide(
|
||||
color: widget.unselectedBorderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth / 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
key: _tabKeys[index],
|
||||
// padding for the buttons
|
||||
padding:
|
||||
widget.useToggleButtonStyle ? EdgeInsets.zero : widget.buttonMargin,
|
||||
child: TextButton(
|
||||
onPressed: () => _handleTap(index),
|
||||
style: ButtonStyle(
|
||||
elevation: MaterialStateProperty.all(
|
||||
widget.useToggleButtonStyle ? 0 : widget.elevation),
|
||||
|
||||
/// give a pretty small minimum size
|
||||
minimumSize: MaterialStateProperty.all(const Size(10, 10)),
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
textStyle: MaterialStateProperty.all(textStyle),
|
||||
foregroundColor: MaterialStateProperty.all(textColor),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: MaterialStateProperty.all(
|
||||
widget.useToggleButtonStyle
|
||||
? const RoundedRectangleBorder(
|
||||
side: BorderSide.none,
|
||||
borderRadius: BorderRadius.zero,
|
||||
)
|
||||
: RoundedRectangleBorder(
|
||||
side: (widget.borderWidth == 0)
|
||||
? BorderSide.none
|
||||
: BorderSide(
|
||||
color: borderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Ink(
|
||||
decoration: boxDecoration,
|
||||
child: Container(
|
||||
padding: widget.labelPadding,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _debugScheduleCheckHasValidTabsCount() {
|
||||
if (_debugHasScheduledValidTabsCountCheck) {
|
||||
return true;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
|
||||
_debugHasScheduledValidTabsCountCheck = false;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
assert(() {
|
||||
if (_controller!.length != widget.tabs.length) {
|
||||
throw FlutterError(
|
||||
"Controller's length property (${_controller!.length}) does not match the "
|
||||
"number of tabs (${widget.tabs.length}) present in TabBar's tabs property.",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
});
|
||||
_debugHasScheduledValidTabsCountCheck = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(_debugScheduleCheckHasValidTabsCount());
|
||||
|
||||
if (_controller!.length == 0) {
|
||||
return Container(
|
||||
height: _kTabHeight +
|
||||
widget.labelPadding.vertical +
|
||||
widget.buttonMargin.vertical,
|
||||
);
|
||||
}
|
||||
|
||||
final List<Widget> wrappedTabs =
|
||||
List<Widget>.generate(widget.tabs.length, (int index) {
|
||||
return _buildStyledTab(widget.tabs[index], index);
|
||||
});
|
||||
|
||||
final int tabCount = widget.tabs.length;
|
||||
// Add the tap handler to each tab. If the tab bar is not scrollable,
|
||||
// then give all of the tabs equal flexibility so that they each occupy
|
||||
// the same share of the tab bar's overall width.
|
||||
|
||||
for (int index = 0; index < tabCount; index += 1) {
|
||||
if (!widget.isScrollable) {
|
||||
wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
|
||||
}
|
||||
}
|
||||
|
||||
Widget tabBar = AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
key: _tabsParentKey,
|
||||
builder: (context, child) {
|
||||
Widget tabBarTemp = _TabLabelBar(
|
||||
onPerformLayout: _saveTabOffsets,
|
||||
children: wrappedTabs,
|
||||
);
|
||||
|
||||
if (widget.useToggleButtonStyle) {
|
||||
tabBarTemp = Material(
|
||||
shape: widget.useToggleButtonStyle
|
||||
? RoundedRectangleBorder(
|
||||
side: (widget.borderWidth == 0)
|
||||
? BorderSide.none
|
||||
: BorderSide(
|
||||
color: widget.borderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
)
|
||||
: null,
|
||||
elevation: widget.useToggleButtonStyle ? widget.elevation : 0,
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
child: tabBarTemp,
|
||||
);
|
||||
}
|
||||
return CustomPaint(
|
||||
painter: _indicatorPainter,
|
||||
child: tabBarTemp,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.isScrollable) {
|
||||
_scrollController ??= _TabBarScrollController(this);
|
||||
tabBar = SingleChildScrollView(
|
||||
dragStartBehavior: widget.dragStartBehavior,
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: _scrollController,
|
||||
padding: widget.padding,
|
||||
physics: widget.physics,
|
||||
child: tabBar,
|
||||
);
|
||||
} else if (widget.padding != null) {
|
||||
tabBar = Padding(
|
||||
padding: widget.padding!,
|
||||
child: tabBar,
|
||||
);
|
||||
}
|
||||
|
||||
return tabBar;
|
||||
}
|
||||
}
|
||||
183
lib/flutterlib/flutter_choice_chips.dart
Normal file
183
lib/flutterlib/flutter_choice_chips.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'form_field_controller.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import '/flutterlib/flutter_util.dart';
|
||||
|
||||
class ChipData {
|
||||
const ChipData(this.label, [this.iconData]);
|
||||
final String label;
|
||||
final IconData? iconData;
|
||||
}
|
||||
|
||||
class ChipStyle {
|
||||
const ChipStyle({
|
||||
this.backgroundColor,
|
||||
this.textStyle,
|
||||
this.iconColor,
|
||||
this.iconSize,
|
||||
this.labelPadding,
|
||||
this.elevation,
|
||||
this.borderColor,
|
||||
this.borderWidth,
|
||||
this.borderRadius,
|
||||
});
|
||||
final Color? backgroundColor;
|
||||
final TextStyle? textStyle;
|
||||
final Color? iconColor;
|
||||
final double? iconSize;
|
||||
final EdgeInsetsGeometry? labelPadding;
|
||||
final double? elevation;
|
||||
final Color? borderColor;
|
||||
final double? borderWidth;
|
||||
final BorderRadius? borderRadius;
|
||||
}
|
||||
|
||||
class FlutterChoiceChips extends StatefulWidget {
|
||||
const FlutterChoiceChips({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
required this.controller,
|
||||
required this.selectedChipStyle,
|
||||
required this.unselectedChipStyle,
|
||||
required this.chipSpacing,
|
||||
this.rowSpacing = 0.0,
|
||||
required this.multiselect,
|
||||
this.initialized = true,
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.disabledColor,
|
||||
this.wrapped = true,
|
||||
});
|
||||
|
||||
final List<ChipData> options;
|
||||
final void Function(List<String>?)? onChanged;
|
||||
final FormFieldController<List<String>> controller;
|
||||
final ChipStyle selectedChipStyle;
|
||||
final ChipStyle unselectedChipStyle;
|
||||
final double chipSpacing;
|
||||
final double rowSpacing;
|
||||
final bool multiselect;
|
||||
final bool initialized;
|
||||
final WrapAlignment alignment;
|
||||
final Color? disabledColor;
|
||||
final bool wrapped;
|
||||
|
||||
@override
|
||||
State<FlutterChoiceChips> createState() => _FlutterChoiceChipsState();
|
||||
}
|
||||
|
||||
class _FlutterChoiceChipsState extends State<FlutterChoiceChips> {
|
||||
late List<String> choiceChipValues;
|
||||
ValueListenable<List<String>?> get changeSelectedValues => widget.controller;
|
||||
List<String> get selectedValues => widget.controller.value ?? [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
choiceChipValues = selectedValues;
|
||||
if (!widget.initialized && choiceChipValues.isNotEmpty) {
|
||||
SchedulerBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged!(choiceChipValues);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
changeSelectedValues.addListener(() {
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged!(selectedValues);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
changeSelectedValues.removeListener(() {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = widget.options.map<Widget>(
|
||||
(option) {
|
||||
final selected = selectedValues.contains(option.label);
|
||||
final style =
|
||||
selected ? widget.selectedChipStyle : widget.unselectedChipStyle;
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: ChoiceChip(
|
||||
selected: selected,
|
||||
onSelected: widget.onChanged != null
|
||||
? (isSelected) {
|
||||
if (isSelected) {
|
||||
widget.multiselect
|
||||
? choiceChipValues.add(option.label)
|
||||
: choiceChipValues = [option.label];
|
||||
widget.controller.value = List.from(choiceChipValues);
|
||||
setState(() {});
|
||||
} else {
|
||||
if (widget.multiselect) {
|
||||
choiceChipValues.remove(option.label);
|
||||
widget.controller.value = List.from(choiceChipValues);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
label: Text(
|
||||
option.label,
|
||||
style: style.textStyle,
|
||||
),
|
||||
labelPadding: style.labelPadding,
|
||||
avatar: option.iconData != null
|
||||
? FaIcon(
|
||||
option.iconData,
|
||||
size: style.iconSize,
|
||||
color: style.iconColor,
|
||||
)
|
||||
: null,
|
||||
elevation: style.elevation,
|
||||
disabledColor: widget.disabledColor,
|
||||
selectedColor:
|
||||
selected ? widget.selectedChipStyle.backgroundColor : null,
|
||||
backgroundColor:
|
||||
selected ? null : widget.unselectedChipStyle.backgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: style.borderRadius ?? BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: style.borderColor ?? Colors.transparent,
|
||||
width: style.borderWidth ?? 0,
|
||||
),
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
|
||||
if (widget.wrapped) {
|
||||
return Wrap(
|
||||
spacing: widget.chipSpacing,
|
||||
runSpacing: widget.rowSpacing,
|
||||
alignment: widget.alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: children,
|
||||
);
|
||||
} else {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: children.divide(
|
||||
SizedBox(width: widget.chipSpacing),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
lib/flutterlib/flutter_count_controller.dart
Normal file
72
lib/flutterlib/flutter_count_controller.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FlutterCountController extends StatefulWidget {
|
||||
const FlutterCountController({
|
||||
Key? key,
|
||||
required this.decrementIconBuilder,
|
||||
required this.incrementIconBuilder,
|
||||
required this.countBuilder,
|
||||
required this.count,
|
||||
required this.updateCount,
|
||||
this.stepSize = 1,
|
||||
this.minimum,
|
||||
this.maximum,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget Function(bool enabled) decrementIconBuilder;
|
||||
final Widget Function(bool enabled) incrementIconBuilder;
|
||||
final Widget Function(int count) countBuilder;
|
||||
final int count;
|
||||
final Function(int) updateCount;
|
||||
final int stepSize;
|
||||
final int? minimum;
|
||||
final int? maximum;
|
||||
final EdgeInsetsGeometry contentPadding;
|
||||
|
||||
@override
|
||||
_FlutterCountControllerState createState() =>
|
||||
_FlutterCountControllerState();
|
||||
}
|
||||
|
||||
class _FlutterCountControllerState
|
||||
extends State<FlutterCountController> {
|
||||
int get count => widget.count;
|
||||
int? get minimum => widget.minimum;
|
||||
int? get maximum => widget.maximum;
|
||||
int get stepSize => widget.stepSize;
|
||||
|
||||
bool get canDecrement => minimum == null || count - stepSize >= minimum!;
|
||||
bool get canIncrement => maximum == null || count + stepSize <= maximum!;
|
||||
|
||||
void _decrementCounter() {
|
||||
if (canDecrement) {
|
||||
setState(() => widget.updateCount(count - stepSize));
|
||||
}
|
||||
}
|
||||
|
||||
void _incrementCounter() {
|
||||
if (canIncrement) {
|
||||
setState(() => widget.updateCount(count + stepSize));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: widget.contentPadding,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _decrementCounter,
|
||||
child: widget.decrementIconBuilder(canDecrement),
|
||||
),
|
||||
widget.countBuilder(count),
|
||||
InkWell(
|
||||
onTap: _incrementCounter,
|
||||
child: widget.incrementIconBuilder(canIncrement),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
339
lib/flutterlib/flutter_drop_down.dart
Normal file
339
lib/flutterlib/flutter_drop_down.dart
Normal file
@@ -0,0 +1,339 @@
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
|
||||
import 'form_field_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FlutterDropDown<T> extends StatefulWidget {
|
||||
const FlutterDropDown({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.searchHintText,
|
||||
required this.options,
|
||||
this.optionLabels,
|
||||
this.onChanged,
|
||||
this.onChangedForMultiSelect,
|
||||
this.icon,
|
||||
this.width,
|
||||
this.height,
|
||||
this.maxHeight,
|
||||
this.fillColor,
|
||||
this.searchHintTextStyle,
|
||||
this.searchCursorColor,
|
||||
required this.textStyle,
|
||||
required this.elevation,
|
||||
required this.borderWidth,
|
||||
required this.borderRadius,
|
||||
required this.borderColor,
|
||||
required this.margin,
|
||||
this.hidesUnderline = false,
|
||||
this.disabled = false,
|
||||
this.isOverButton = false,
|
||||
this.isSearchable = false,
|
||||
this.isMultiSelect = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final FormFieldController<T> controller;
|
||||
final String? hintText;
|
||||
final String? searchHintText;
|
||||
final List<T> options;
|
||||
final List<String>? optionLabels;
|
||||
final Function(T?)? onChanged;
|
||||
final Function(List<T>?)? onChangedForMultiSelect;
|
||||
final Widget? icon;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double? maxHeight;
|
||||
final Color? fillColor;
|
||||
final TextStyle? searchHintTextStyle;
|
||||
final Color? searchCursorColor;
|
||||
final TextStyle textStyle;
|
||||
final double elevation;
|
||||
final double borderWidth;
|
||||
final double borderRadius;
|
||||
final Color borderColor;
|
||||
final EdgeInsetsGeometry margin;
|
||||
final bool hidesUnderline;
|
||||
final bool disabled;
|
||||
final bool isOverButton;
|
||||
final bool isSearchable;
|
||||
final bool isMultiSelect;
|
||||
|
||||
@override
|
||||
State<FlutterDropDown<T>> createState() => _FlutterDropDownState<T>();
|
||||
}
|
||||
|
||||
class _FlutterDropDownState<T> extends State<FlutterDropDown<T>> {
|
||||
final TextEditingController _textEditingController = TextEditingController();
|
||||
|
||||
void Function() get listener => widget.isMultiSelect
|
||||
? () {}
|
||||
: () => widget.onChanged!(widget.controller.value);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.controller.addListener(listener);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<T> selectedItems = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final optionToDisplayValue = Map.fromEntries(
|
||||
widget.options.asMap().entries.map((option) => MapEntry(
|
||||
option.value,
|
||||
widget.optionLabels == null ||
|
||||
widget.optionLabels!.length < option.key + 1
|
||||
? option.value.toString()
|
||||
: widget.optionLabels![option.key])),
|
||||
);
|
||||
final value = widget.options.contains(widget.controller.value)
|
||||
? widget.controller.value
|
||||
: null;
|
||||
final items = widget.options
|
||||
.map(
|
||||
(option) => DropdownMenuItem<T>(
|
||||
value: option,
|
||||
child: Text(
|
||||
optionToDisplayValue[option] ?? '',
|
||||
style: widget.textStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final hintText = widget.hintText != null
|
||||
? Text(widget.hintText!, style: widget.textStyle)
|
||||
: null;
|
||||
void Function(T?)? onChanged = widget.disabled || widget.isMultiSelect
|
||||
? null
|
||||
: (value) => widget.controller.value = value;
|
||||
final dropdownWidget = _useDropdown2()
|
||||
? _buildDropdown(
|
||||
value,
|
||||
items,
|
||||
onChanged,
|
||||
hintText,
|
||||
optionToDisplayValue,
|
||||
widget.isSearchable,
|
||||
widget.disabled,
|
||||
widget.isMultiSelect,
|
||||
widget.onChangedForMultiSelect,
|
||||
)
|
||||
: _buildLegacyDropdown(value, items, onChanged, hintText);
|
||||
final childWidget = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: Border.all(
|
||||
color: widget.borderColor,
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
color: widget.fillColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: widget.margin,
|
||||
child: widget.hidesUnderline
|
||||
? DropdownButtonHideUnderline(child: dropdownWidget)
|
||||
: dropdownWidget,
|
||||
),
|
||||
);
|
||||
if (widget.height != null || widget.width != null) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: childWidget,
|
||||
);
|
||||
}
|
||||
return childWidget;
|
||||
}
|
||||
|
||||
Widget _buildLegacyDropdown(
|
||||
T? value,
|
||||
List<DropdownMenuItem<T>>? items,
|
||||
void Function(T?)? onChanged,
|
||||
Text? hintText,
|
||||
) {
|
||||
return DropdownButton<T>(
|
||||
value: value,
|
||||
hint: hintText,
|
||||
items: items,
|
||||
elevation: widget.elevation.toInt(),
|
||||
onChanged: onChanged,
|
||||
icon: widget.icon,
|
||||
isExpanded: true,
|
||||
dropdownColor: widget.fillColor,
|
||||
focusColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown(
|
||||
T? value,
|
||||
List<DropdownMenuItem<T>>? items,
|
||||
void Function(T?)? onChanged,
|
||||
Text? hintText,
|
||||
Map<T, String> optionLabels,
|
||||
bool isSearchable,
|
||||
bool disabled,
|
||||
bool isMultiSelect,
|
||||
Function(List<T>?)? onChangedForMultiSelect,
|
||||
) {
|
||||
final overlayColor = MaterialStateProperty.resolveWith<Color?>((states) =>
|
||||
states.contains(MaterialState.focused) ? Colors.transparent : null);
|
||||
final iconStyleData = widget.icon != null
|
||||
? IconStyleData(icon: widget.icon!)
|
||||
: const IconStyleData();
|
||||
return DropdownButton2<T>(
|
||||
value: isMultiSelect
|
||||
? selectedItems.isEmpty
|
||||
? null
|
||||
: selectedItems.last
|
||||
: value,
|
||||
hint: hintText,
|
||||
items: isMultiSelect
|
||||
? widget.options.map((item) {
|
||||
return DropdownMenuItem(
|
||||
value: item,
|
||||
// Disable default onTap to avoid closing menu when selecting an item
|
||||
enabled: false,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, menuSetState) {
|
||||
final isSelected = selectedItems.contains(item);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
isSelected
|
||||
? selectedItems.remove(item)
|
||||
: selectedItems.add(item);
|
||||
onChangedForMultiSelect!(selectedItems);
|
||||
//This rebuilds the StatefulWidget to update the button's text
|
||||
setState(() {});
|
||||
//This rebuilds the dropdownMenu Widget to update the check mark
|
||||
menuSetState(() {});
|
||||
},
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isSelected)
|
||||
const Icon(Icons.check_box_outlined)
|
||||
else
|
||||
const Icon(Icons.check_box_outline_blank),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item as String,
|
||||
style: widget.textStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList()
|
||||
: items,
|
||||
iconStyleData: iconStyleData,
|
||||
buttonStyleData: ButtonStyleData(
|
||||
elevation: widget.elevation.toInt(),
|
||||
overlayColor: overlayColor,
|
||||
),
|
||||
menuItemStyleData: MenuItemStyleData(overlayColor: overlayColor),
|
||||
dropdownStyleData: DropdownStyleData(
|
||||
elevation: widget.elevation!.toInt(),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: widget.fillColor,
|
||||
),
|
||||
isOverButton: widget.isOverButton,
|
||||
maxHeight: widget.maxHeight,
|
||||
),
|
||||
// onChanged is handled by the onChangedForMultiSelect function
|
||||
onChanged: isMultiSelect
|
||||
? disabled
|
||||
? null
|
||||
: (val) {}
|
||||
: onChanged,
|
||||
isExpanded: true,
|
||||
selectedItemBuilder: isMultiSelect
|
||||
? (context) {
|
||||
return widget.options.map(
|
||||
(item) {
|
||||
return Container(
|
||||
alignment: AlignmentDirectional.center,
|
||||
child: Text(
|
||||
selectedItems.join(', '),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
}
|
||||
: null,
|
||||
dropdownSearchData: isSearchable
|
||||
? DropdownSearchData<T>(
|
||||
searchController: _textEditingController,
|
||||
searchInnerWidgetHeight: 50,
|
||||
searchInnerWidget: Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 4,
|
||||
right: 8,
|
||||
left: 8,
|
||||
),
|
||||
child: TextFormField(
|
||||
expands: true,
|
||||
maxLines: null,
|
||||
controller: _textEditingController,
|
||||
cursorColor: widget.searchCursorColor,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
hintText: widget.searchHintText,
|
||||
hintStyle: widget.searchHintTextStyle,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
searchMatchFn: (item, searchValue) {
|
||||
return (optionLabels[item.value] ?? '')
|
||||
.toLowerCase()
|
||||
.contains(searchValue.toLowerCase());
|
||||
},
|
||||
)
|
||||
: null,
|
||||
// This to clear the search value when you close the menu
|
||||
onMenuStateChange: isSearchable
|
||||
? (isOpen) {
|
||||
if (!isOpen) {
|
||||
_textEditingController.clear();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
bool _useDropdown2() {
|
||||
return widget.isMultiSelect ||
|
||||
widget.isSearchable ||
|
||||
!widget.isOverButton ||
|
||||
widget.maxHeight != null;
|
||||
}
|
||||
}
|
||||
167
lib/flutterlib/flutter_icon_button.dart
Normal file
167
lib/flutterlib/flutter_icon_button.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
||||
class FlutterIconButton extends StatefulWidget {
|
||||
const FlutterIconButton({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
this.borderColor,
|
||||
this.borderRadius,
|
||||
this.borderWidth,
|
||||
this.buttonSize,
|
||||
this.fillColor,
|
||||
this.disabledColor,
|
||||
this.disabledIconColor,
|
||||
this.hoverColor,
|
||||
this.hoverIconColor,
|
||||
this.onPressed,
|
||||
this.showLoadingIndicator = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget icon;
|
||||
final double? borderRadius;
|
||||
final double? buttonSize;
|
||||
final Color? fillColor;
|
||||
final Color? disabledColor;
|
||||
final Color? disabledIconColor;
|
||||
final Color? hoverColor;
|
||||
final Color? hoverIconColor;
|
||||
final Color? borderColor;
|
||||
final double? borderWidth;
|
||||
final bool showLoadingIndicator;
|
||||
final Function()? onPressed;
|
||||
|
||||
@override
|
||||
State<FlutterIconButton> createState() => _FlutterIconButtonState();
|
||||
}
|
||||
|
||||
class _FlutterIconButtonState extends State<FlutterIconButton> {
|
||||
bool loading = false;
|
||||
late double? iconSize;
|
||||
late Color? iconColor;
|
||||
late Widget effectiveIcon;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateIcon();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FlutterIconButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateIcon();
|
||||
}
|
||||
|
||||
void _updateIcon() {
|
||||
final isFontAwesome = widget.icon is FaIcon;
|
||||
if (isFontAwesome) {
|
||||
FaIcon icon = widget.icon as FaIcon;
|
||||
effectiveIcon = FaIcon(
|
||||
icon.icon,
|
||||
size: icon.size,
|
||||
);
|
||||
iconSize = icon.size;
|
||||
iconColor = icon.color;
|
||||
} else {
|
||||
Icon icon = widget.icon as Icon;
|
||||
effectiveIcon = Icon(
|
||||
icon.icon,
|
||||
size: icon.size,
|
||||
);
|
||||
iconSize = icon.size;
|
||||
iconColor = icon.color;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ButtonStyle style = ButtonStyle(
|
||||
shape: MaterialStateProperty.resolveWith<OutlinedBorder>(
|
||||
(states) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius ?? 0),
|
||||
side: BorderSide(
|
||||
color: widget.borderColor ?? Colors.transparent,
|
||||
width: widget.borderWidth ?? 0,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
iconColor: MaterialStateProperty.resolveWith<Color?>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.disabled) &&
|
||||
widget.disabledIconColor != null) {
|
||||
return widget.disabledIconColor;
|
||||
}
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.hoverIconColor != null) {
|
||||
return widget.hoverIconColor;
|
||||
}
|
||||
return iconColor;
|
||||
},
|
||||
),
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.disabled) &&
|
||||
widget.disabledColor != null) {
|
||||
return widget.disabledColor;
|
||||
}
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.hoverColor != null) {
|
||||
return widget.hoverColor;
|
||||
}
|
||||
|
||||
return widget.fillColor;
|
||||
},
|
||||
),
|
||||
overlayColor: MaterialStateProperty.resolveWith<Color?>((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return null;
|
||||
}
|
||||
return widget.hoverColor == null ? null : Colors.transparent;
|
||||
}),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: widget.buttonSize,
|
||||
height: widget.buttonSize,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(useMaterial3: true),
|
||||
child: IgnorePointer(
|
||||
ignoring: (widget.showLoadingIndicator && loading),
|
||||
child: IconButton(
|
||||
icon: (widget.showLoadingIndicator && loading)
|
||||
? Container(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
iconColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: effectiveIcon,
|
||||
onPressed: widget.onPressed == null
|
||||
? null
|
||||
: () async {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
await widget.onPressed!();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
splashRadius: widget.buttonSize,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
169
lib/flutterlib/flutter_model.dart
Normal file
169
lib/flutterlib/flutter_model.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
Widget wrapWithModel<T extends FlutterModel>({
|
||||
required T model,
|
||||
required Widget child,
|
||||
required VoidCallback updateCallback,
|
||||
bool updateOnChange = false,
|
||||
}) {
|
||||
// Set the component to optionally update the page on updates.
|
||||
model.setOnUpdate(
|
||||
onUpdate: updateCallback,
|
||||
updateOnChange: updateOnChange,
|
||||
);
|
||||
// Models for components within a page will be disposed by the page's model,
|
||||
// so we don't want the component widget to dispose them until the page is
|
||||
// itself disposed.
|
||||
model.disposeOnWidgetDisposal = false;
|
||||
// Wrap in a Provider so that the model can be accessed by the component.
|
||||
return Provider<T>.value(
|
||||
value: model,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
T createModel<T extends FlutterModel>(
|
||||
BuildContext context,
|
||||
T Function() defaultBuilder,
|
||||
) {
|
||||
final model = context.read<T?>() ?? defaultBuilder();
|
||||
model._init(context);
|
||||
return model;
|
||||
}
|
||||
|
||||
abstract class FlutterModel<W extends Widget> {
|
||||
// Initialization methods
|
||||
bool _isInitialized = false;
|
||||
void initState(BuildContext context);
|
||||
void _init(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
initState(context);
|
||||
_isInitialized = true;
|
||||
}
|
||||
if (context.widget is W) _widget = context.widget as W;
|
||||
}
|
||||
|
||||
// The widget associated with this model. This is useful for accessing the
|
||||
// parameters of the widget, for example.
|
||||
W? _widget;
|
||||
// This will always be non-null when used, but is nullable to allow us to
|
||||
// dispose of the widget in the [dispose] method (for garbage collection).
|
||||
W get widget => _widget!;
|
||||
|
||||
// Dispose methods
|
||||
// Whether to dispose this model when the corresponding widget is
|
||||
// disposed. By default this is true for pages and false for components,
|
||||
// as page/component models handle the disposal of their children.
|
||||
bool disposeOnWidgetDisposal = true;
|
||||
void dispose();
|
||||
void maybeDispose() {
|
||||
if (disposeOnWidgetDisposal) {
|
||||
dispose();
|
||||
}
|
||||
// Remove reference to widget for garbage collection purposes.
|
||||
_widget = null;
|
||||
}
|
||||
|
||||
// Whether to update the containing page / component on updates.
|
||||
bool updateOnChange = false;
|
||||
// Function to call when the model receives an update.
|
||||
VoidCallback _updateCallback = () {};
|
||||
void onUpdate() => updateOnChange ? _updateCallback() : () {};
|
||||
FlutterModel setOnUpdate({
|
||||
bool updateOnChange = false,
|
||||
required VoidCallback onUpdate,
|
||||
}) =>
|
||||
this
|
||||
.._updateCallback = onUpdate
|
||||
..updateOnChange = updateOnChange;
|
||||
// Update the containing page when this model received an update.
|
||||
void updatePage(VoidCallback callback) {
|
||||
callback();
|
||||
_updateCallback();
|
||||
}
|
||||
}
|
||||
|
||||
class FlutterDynamicModels<T extends FlutterModel> {
|
||||
FlutterDynamicModels(this.defaultBuilder);
|
||||
|
||||
final T Function() defaultBuilder;
|
||||
final Map<String, T> _childrenModels = {};
|
||||
final Map<String, int> _childrenIndexes = {};
|
||||
Set<String>? _activeKeys;
|
||||
|
||||
T getModel(String uniqueKey, int index) {
|
||||
_updateActiveKeys(uniqueKey);
|
||||
_childrenIndexes[uniqueKey] = index;
|
||||
return _childrenModels[uniqueKey] ??= defaultBuilder();
|
||||
}
|
||||
|
||||
List<S> getValues<S>(S? Function(T) getValue) {
|
||||
return _childrenIndexes.entries
|
||||
// Sort keys by index.
|
||||
.sorted((a, b) => a.value.compareTo(b.value))
|
||||
.where((e) => _childrenModels[e.key] != null)
|
||||
// Map each model to the desired value and return as list. In order
|
||||
// to preserve index order, rather than removing null values we provide
|
||||
// default values (for types with reasonable defaults).
|
||||
.map((e) => getValue(_childrenModels[e.key]!) ?? _getDefaultValue<S>()!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
S? getValueAtIndex<S>(int index, S? Function(T) getValue) {
|
||||
final uniqueKey =
|
||||
_childrenIndexes.entries.firstWhereOrNull((e) => e.value == index)?.key;
|
||||
return getValueForKey(uniqueKey, getValue);
|
||||
}
|
||||
|
||||
S? getValueForKey<S>(String? uniqueKey, S? Function(T) getValue) {
|
||||
final model = _childrenModels[uniqueKey];
|
||||
return model != null ? getValue(model) : null;
|
||||
}
|
||||
|
||||
void dispose() => _childrenModels.values.forEach((model) => model.dispose());
|
||||
|
||||
void _updateActiveKeys(String uniqueKey) {
|
||||
final shouldResetActiveKeys = _activeKeys == null;
|
||||
_activeKeys ??= {};
|
||||
_activeKeys!.add(uniqueKey);
|
||||
|
||||
if (shouldResetActiveKeys) {
|
||||
// Add a post-frame callback to remove and dispose of unused models after
|
||||
// we're done building, then reset `_activeKeys` to null so we know to do
|
||||
// this again next build.
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_childrenIndexes.removeWhere((k, _) => !_activeKeys!.contains(k));
|
||||
_childrenModels.keys
|
||||
.toSet()
|
||||
.difference(_activeKeys!)
|
||||
// Remove and dispose of unused models since they are not being used
|
||||
// elsewhere and would not otherwise be disposed.
|
||||
.forEach((k) => _childrenModels.remove(k)?.dispose());
|
||||
_activeKeys = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T? _getDefaultValue<T>() {
|
||||
switch (T) {
|
||||
case int:
|
||||
return 0 as T;
|
||||
case double:
|
||||
return 0.0 as T;
|
||||
case String:
|
||||
return '' as T;
|
||||
case bool:
|
||||
return false as T;
|
||||
default:
|
||||
return null as T;
|
||||
}
|
||||
}
|
||||
|
||||
extension TextValidationExtensions on String? Function(BuildContext, String?)? {
|
||||
String? Function(String?)? asValidator(BuildContext context) =>
|
||||
this != null ? (val) => this!(context, val) : null;
|
||||
}
|
||||
283
lib/flutterlib/flutter_radio_button.dart
Normal file
283
lib/flutterlib/flutter_radio_button.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
|
||||
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
|
||||
* AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
import 'form_field_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FlutterRadioButton extends StatefulWidget {
|
||||
const FlutterRadioButton({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
required this.controller,
|
||||
required this.optionHeight,
|
||||
required this.textStyle,
|
||||
this.optionWidth,
|
||||
this.selectedTextStyle,
|
||||
this.textPadding = EdgeInsets.zero,
|
||||
this.buttonPosition = RadioButtonPosition.left,
|
||||
this.direction = Axis.vertical,
|
||||
required this.radioButtonColor,
|
||||
this.inactiveRadioButtonColor,
|
||||
this.toggleable = false,
|
||||
this.horizontalAlignment = WrapAlignment.start,
|
||||
this.verticalAlignment = WrapCrossAlignment.start,
|
||||
});
|
||||
|
||||
final List<String> options;
|
||||
final Function(String?)? onChanged;
|
||||
final FormFieldController<String> controller;
|
||||
final double optionHeight;
|
||||
final double? optionWidth;
|
||||
final TextStyle textStyle;
|
||||
final TextStyle? selectedTextStyle;
|
||||
final EdgeInsetsGeometry textPadding;
|
||||
final RadioButtonPosition buttonPosition;
|
||||
final Axis direction;
|
||||
final Color radioButtonColor;
|
||||
final Color? inactiveRadioButtonColor;
|
||||
final bool toggleable;
|
||||
final WrapAlignment horizontalAlignment;
|
||||
final WrapCrossAlignment verticalAlignment;
|
||||
|
||||
@override
|
||||
State<FlutterRadioButton> createState() => _FlutterRadioButtonState();
|
||||
}
|
||||
|
||||
class _FlutterRadioButtonState extends State<FlutterRadioButton> {
|
||||
bool get enabled => widget.onChanged != null;
|
||||
FormFieldController<String> get controller => widget.controller;
|
||||
void Function()? _listener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_maybeSetOnChangedListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_maybeRemoveOnChangedListener();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FlutterRadioButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final oldWidgetEnabled = oldWidget.onChanged != null;
|
||||
if (oldWidgetEnabled != enabled) {
|
||||
_maybeRemoveOnChangedListener();
|
||||
_maybeSetOnChangedListener();
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeSetOnChangedListener() {
|
||||
if (enabled) {
|
||||
_listener = () => widget.onChanged!(controller.value);
|
||||
controller.addListener(_listener!);
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeRemoveOnChangedListener() {
|
||||
if (_listener != null) {
|
||||
controller.removeListener(_listener!);
|
||||
_listener = null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> get effectiveOptions =>
|
||||
widget.options.isEmpty ? ['[Option]'] : widget.options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context)
|
||||
.copyWith(unselectedWidgetColor: widget.inactiveRadioButtonColor),
|
||||
child: RadioGroup<String>.builder(
|
||||
direction: widget.direction,
|
||||
groupValue: controller.value,
|
||||
onChanged: enabled ? (value) => controller.value = value : null,
|
||||
activeColor: widget.radioButtonColor,
|
||||
toggleable: widget.toggleable,
|
||||
textStyle: widget.textStyle,
|
||||
selectedTextStyle: widget.selectedTextStyle ?? widget.textStyle,
|
||||
textPadding: widget.textPadding,
|
||||
optionHeight: widget.optionHeight,
|
||||
optionWidth: widget.optionWidth,
|
||||
horizontalAlignment: widget.horizontalAlignment,
|
||||
verticalAlignment: widget.verticalAlignment,
|
||||
items: effectiveOptions,
|
||||
itemBuilder: (item) =>
|
||||
RadioButtonBuilder(item, buttonPosition: widget.buttonPosition),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum RadioButtonPosition {
|
||||
right,
|
||||
left,
|
||||
}
|
||||
|
||||
class RadioButtonBuilder<T> {
|
||||
RadioButtonBuilder(
|
||||
this.description, {
|
||||
this.buttonPosition = RadioButtonPosition.left,
|
||||
});
|
||||
|
||||
final String description;
|
||||
final RadioButtonPosition buttonPosition;
|
||||
}
|
||||
|
||||
class RadioButton<T> extends StatelessWidget {
|
||||
const RadioButton({
|
||||
super.key,
|
||||
required this.description,
|
||||
required this.value,
|
||||
required this.groupValue,
|
||||
required this.onChanged,
|
||||
required this.buttonPosition,
|
||||
required this.activeColor,
|
||||
required this.toggleable,
|
||||
required this.textStyle,
|
||||
required this.selectedTextStyle,
|
||||
required this.textPadding,
|
||||
this.shouldFlex = false,
|
||||
});
|
||||
|
||||
final String description;
|
||||
final T value;
|
||||
final T? groupValue;
|
||||
final void Function(T?)? onChanged;
|
||||
final RadioButtonPosition buttonPosition;
|
||||
final Color activeColor;
|
||||
final bool toggleable;
|
||||
final TextStyle textStyle;
|
||||
final TextStyle selectedTextStyle;
|
||||
final EdgeInsetsGeometry textPadding;
|
||||
final bool shouldFlex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedStyle = selectedTextStyle;
|
||||
final isSelected = value == groupValue;
|
||||
Widget radioButtonText = Padding(
|
||||
padding: textPadding,
|
||||
child: Text(
|
||||
description,
|
||||
style: isSelected ? selectedStyle : textStyle,
|
||||
),
|
||||
);
|
||||
if (shouldFlex) {
|
||||
radioButtonText = Flexible(child: radioButtonText);
|
||||
}
|
||||
return InkWell(
|
||||
onTap: onChanged != null ? () => onChanged!(value) : null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (buttonPosition == RadioButtonPosition.right) radioButtonText,
|
||||
Radio<T>(
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
value: value,
|
||||
activeColor: activeColor,
|
||||
toggleable: toggleable,
|
||||
),
|
||||
if (buttonPosition == RadioButtonPosition.left) radioButtonText,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RadioGroup<T> extends StatelessWidget {
|
||||
const RadioGroup.builder({
|
||||
super.key,
|
||||
required this.groupValue,
|
||||
required this.onChanged,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
required this.direction,
|
||||
required this.optionHeight,
|
||||
required this.horizontalAlignment,
|
||||
required this.activeColor,
|
||||
required this.toggleable,
|
||||
required this.textStyle,
|
||||
required this.selectedTextStyle,
|
||||
required this.textPadding,
|
||||
this.optionWidth,
|
||||
this.verticalAlignment = WrapCrossAlignment.center,
|
||||
});
|
||||
|
||||
final T? groupValue;
|
||||
final List<T> items;
|
||||
final RadioButtonBuilder Function(T value) itemBuilder;
|
||||
final void Function(T?)? onChanged;
|
||||
final Axis direction;
|
||||
final double optionHeight;
|
||||
final double? optionWidth;
|
||||
final WrapAlignment horizontalAlignment;
|
||||
final WrapCrossAlignment verticalAlignment;
|
||||
final Color activeColor;
|
||||
final bool toggleable;
|
||||
final TextStyle textStyle;
|
||||
final TextStyle selectedTextStyle;
|
||||
final EdgeInsetsGeometry textPadding;
|
||||
|
||||
List<Widget> get _group => items.map(
|
||||
(item) {
|
||||
final radioButtonBuilder = itemBuilder(item);
|
||||
return SizedBox(
|
||||
height: optionHeight,
|
||||
width: optionWidth,
|
||||
child: RadioButton(
|
||||
description: radioButtonBuilder.description,
|
||||
value: item,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
buttonPosition: radioButtonBuilder.buttonPosition,
|
||||
activeColor: activeColor,
|
||||
toggleable: toggleable,
|
||||
textStyle: textStyle,
|
||||
selectedTextStyle: selectedTextStyle,
|
||||
textPadding: textPadding,
|
||||
shouldFlex: optionWidth != null,
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => direction == Axis.horizontal
|
||||
? Wrap(
|
||||
direction: direction,
|
||||
alignment: horizontalAlignment,
|
||||
children: _group,
|
||||
)
|
||||
: Wrap(
|
||||
direction: direction,
|
||||
crossAxisAlignment: verticalAlignment,
|
||||
children: _group,
|
||||
);
|
||||
}
|
||||
350
lib/flutterlib/flutter_theme.dart
Normal file
350
lib/flutterlib/flutter_theme.dart
Normal file
@@ -0,0 +1,350 @@
|
||||
// ignore_for_file: overridden_fields, annotate_overrides
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const kThemeModeKey = '__theme_mode__';
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
abstract class FlutterTheme {
|
||||
static Future initialize() async =>
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
static ThemeMode get themeMode {
|
||||
final darkMode = _prefs?.getBool(kThemeModeKey);
|
||||
return darkMode == null
|
||||
? ThemeMode.system
|
||||
: darkMode
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light;
|
||||
}
|
||||
|
||||
static void saveThemeMode(ThemeMode mode) => mode == ThemeMode.system
|
||||
? _prefs?.remove(kThemeModeKey)
|
||||
: _prefs?.setBool(kThemeModeKey, mode == ThemeMode.dark);
|
||||
|
||||
static FlutterTheme of(BuildContext context) {
|
||||
return Theme.of(context).brightness == Brightness.dark
|
||||
? DarkModeTheme()
|
||||
: LightModeTheme();
|
||||
}
|
||||
|
||||
@Deprecated('Use primary instead')
|
||||
Color get primaryColor => primary;
|
||||
@Deprecated('Use secondary instead')
|
||||
Color get secondaryColor => secondary;
|
||||
@Deprecated('Use tertiary instead')
|
||||
Color get tertiaryColor => tertiary;
|
||||
|
||||
late Color primary;
|
||||
late Color secondary;
|
||||
late Color tertiary;
|
||||
late Color alternate;
|
||||
late Color primaryText;
|
||||
late Color secondaryText;
|
||||
late Color primaryBackground;
|
||||
late Color secondaryBackground;
|
||||
late Color accent1;
|
||||
late Color accent2;
|
||||
late Color accent3;
|
||||
late Color accent4;
|
||||
late Color success;
|
||||
late Color warning;
|
||||
late Color error;
|
||||
late Color info;
|
||||
|
||||
@Deprecated('Use displaySmallFamily instead')
|
||||
String get title1Family => displaySmallFamily;
|
||||
@Deprecated('Use displaySmall instead')
|
||||
TextStyle get title1 => typography.displaySmall;
|
||||
@Deprecated('Use headlineMediumFamily instead')
|
||||
String get title2Family => typography.headlineMediumFamily;
|
||||
@Deprecated('Use headlineMedium instead')
|
||||
TextStyle get title2 => typography.headlineMedium;
|
||||
@Deprecated('Use headlineSmallFamily instead')
|
||||
String get title3Family => typography.headlineSmallFamily;
|
||||
@Deprecated('Use headlineSmall instead')
|
||||
TextStyle get title3 => typography.headlineSmall;
|
||||
@Deprecated('Use titleMediumFamily instead')
|
||||
String get subtitle1Family => typography.titleMediumFamily;
|
||||
@Deprecated('Use titleMedium instead')
|
||||
TextStyle get subtitle1 => typography.titleMedium;
|
||||
@Deprecated('Use titleSmallFamily instead')
|
||||
String get subtitle2Family => typography.titleSmallFamily;
|
||||
@Deprecated('Use titleSmall instead')
|
||||
TextStyle get subtitle2 => typography.titleSmall;
|
||||
@Deprecated('Use bodyMediumFamily instead')
|
||||
String get bodyText1Family => typography.bodyMediumFamily;
|
||||
@Deprecated('Use bodyMedium instead')
|
||||
TextStyle get bodyText1 => typography.bodyMedium;
|
||||
@Deprecated('Use bodySmallFamily instead')
|
||||
String get bodyText2Family => typography.bodySmallFamily;
|
||||
@Deprecated('Use bodySmall instead')
|
||||
TextStyle get bodyText2 => typography.bodySmall;
|
||||
|
||||
String get displayLargeFamily => typography.displayLargeFamily;
|
||||
TextStyle get displayLarge => typography.displayLarge;
|
||||
String get displayMediumFamily => typography.displayMediumFamily;
|
||||
TextStyle get displayMedium => typography.displayMedium;
|
||||
String get displaySmallFamily => typography.displaySmallFamily;
|
||||
TextStyle get displaySmall => typography.displaySmall;
|
||||
String get headlineLargeFamily => typography.headlineLargeFamily;
|
||||
TextStyle get headlineLarge => typography.headlineLarge;
|
||||
String get headlineMediumFamily => typography.headlineMediumFamily;
|
||||
TextStyle get headlineMedium => typography.headlineMedium;
|
||||
String get headlineSmallFamily => typography.headlineSmallFamily;
|
||||
TextStyle get headlineSmall => typography.headlineSmall;
|
||||
String get titleLargeFamily => typography.titleLargeFamily;
|
||||
TextStyle get titleLarge => typography.titleLarge;
|
||||
String get titleMediumFamily => typography.titleMediumFamily;
|
||||
TextStyle get titleMedium => typography.titleMedium;
|
||||
String get titleSmallFamily => typography.titleSmallFamily;
|
||||
TextStyle get titleSmall => typography.titleSmall;
|
||||
String get labelLargeFamily => typography.labelLargeFamily;
|
||||
TextStyle get labelLarge => typography.labelLarge;
|
||||
String get labelMediumFamily => typography.labelMediumFamily;
|
||||
TextStyle get labelMedium => typography.labelMedium;
|
||||
String get labelSmallFamily => typography.labelSmallFamily;
|
||||
TextStyle get labelSmall => typography.labelSmall;
|
||||
String get bodyLargeFamily => typography.bodyLargeFamily;
|
||||
TextStyle get bodyLarge => typography.bodyLarge;
|
||||
String get bodyMediumFamily => typography.bodyMediumFamily;
|
||||
TextStyle get bodyMedium => typography.bodyMedium;
|
||||
String get bodySmallFamily => typography.bodySmallFamily;
|
||||
TextStyle get bodySmall => typography.bodySmall;
|
||||
|
||||
Typography get typography => ThemeTypography(this);
|
||||
}
|
||||
|
||||
class LightModeTheme extends FlutterTheme {
|
||||
@Deprecated('Use primary instead')
|
||||
Color get primaryColor => primary;
|
||||
@Deprecated('Use secondary instead')
|
||||
Color get secondaryColor => secondary;
|
||||
@Deprecated('Use tertiary instead')
|
||||
Color get tertiaryColor => tertiary;
|
||||
|
||||
late Color primary = const Color(0xFF4B39EF);
|
||||
late Color secondary = const Color(0xFF39D2C0);
|
||||
late Color tertiary = const Color(0xFFEE8B60);
|
||||
late Color alternate = const Color(0xFFE0E3E7);
|
||||
late Color primaryText = const Color(0xFF14181B);
|
||||
late Color secondaryText = const Color(0xFF57636C);
|
||||
late Color primaryBackground = const Color(0xFFF1F4F8);
|
||||
late Color secondaryBackground = const Color(0xFFFFFFFF);
|
||||
late Color accent1 = const Color(0x4C4B39EF);
|
||||
late Color accent2 = const Color(0x4D39D2C0);
|
||||
late Color accent3 = const Color(0x4DEE8B60);
|
||||
late Color accent4 = const Color(0xCCFFFFFF);
|
||||
late Color success = const Color(0xFF249689);
|
||||
late Color warning = const Color(0xFFF9CF58);
|
||||
late Color error = const Color(0xFFFF5963);
|
||||
late Color info = const Color(0xFFFFFFFF);
|
||||
}
|
||||
|
||||
abstract class Typography {
|
||||
String get displayLargeFamily;
|
||||
TextStyle get displayLarge;
|
||||
String get displayMediumFamily;
|
||||
TextStyle get displayMedium;
|
||||
String get displaySmallFamily;
|
||||
TextStyle get displaySmall;
|
||||
String get headlineLargeFamily;
|
||||
TextStyle get headlineLarge;
|
||||
String get headlineMediumFamily;
|
||||
TextStyle get headlineMedium;
|
||||
String get headlineSmallFamily;
|
||||
TextStyle get headlineSmall;
|
||||
String get titleLargeFamily;
|
||||
TextStyle get titleLarge;
|
||||
String get titleMediumFamily;
|
||||
TextStyle get titleMedium;
|
||||
String get titleSmallFamily;
|
||||
TextStyle get titleSmall;
|
||||
String get labelLargeFamily;
|
||||
TextStyle get labelLarge;
|
||||
String get labelMediumFamily;
|
||||
TextStyle get labelMedium;
|
||||
String get labelSmallFamily;
|
||||
TextStyle get labelSmall;
|
||||
String get bodyLargeFamily;
|
||||
TextStyle get bodyLarge;
|
||||
String get bodyMediumFamily;
|
||||
TextStyle get bodyMedium;
|
||||
String get bodySmallFamily;
|
||||
TextStyle get bodySmall;
|
||||
}
|
||||
|
||||
class ThemeTypography extends Typography {
|
||||
ThemeTypography(this.theme);
|
||||
|
||||
final FlutterTheme theme;
|
||||
|
||||
String get displayLargeFamily => 'Outfit';
|
||||
TextStyle get displayLarge => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 64.0,
|
||||
);
|
||||
String get displayMediumFamily => 'Outfit';
|
||||
TextStyle get displayMedium => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 44.0,
|
||||
);
|
||||
String get displaySmallFamily => 'Outfit';
|
||||
TextStyle get displaySmall => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 36.0,
|
||||
);
|
||||
String get headlineLargeFamily => 'Outfit';
|
||||
TextStyle get headlineLarge => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 32.0,
|
||||
);
|
||||
String get headlineMediumFamily => 'Outfit';
|
||||
TextStyle get headlineMedium => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 24.0,
|
||||
);
|
||||
String get headlineSmallFamily => 'Outfit';
|
||||
TextStyle get headlineSmall => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.0,
|
||||
);
|
||||
String get titleLargeFamily => 'Outfit';
|
||||
TextStyle get titleLarge => GoogleFonts.getFont(
|
||||
'Outfit',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 22.0,
|
||||
);
|
||||
String get titleMediumFamily => 'Readex Pro';
|
||||
TextStyle get titleMedium => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.info,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 18.0,
|
||||
);
|
||||
String get titleSmallFamily => 'Readex Pro';
|
||||
TextStyle get titleSmall => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.info,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16.0,
|
||||
);
|
||||
String get labelLargeFamily => 'Readex Pro';
|
||||
TextStyle get labelLarge => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.secondaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 16.0,
|
||||
);
|
||||
String get labelMediumFamily => 'Readex Pro';
|
||||
TextStyle get labelMedium => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.secondaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14.0,
|
||||
);
|
||||
String get labelSmallFamily => 'Readex Pro';
|
||||
TextStyle get labelSmall => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.secondaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 12.0,
|
||||
);
|
||||
String get bodyLargeFamily => 'Readex Pro';
|
||||
TextStyle get bodyLarge => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 16.0,
|
||||
);
|
||||
String get bodyMediumFamily => 'Readex Pro';
|
||||
TextStyle get bodyMedium => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14.0,
|
||||
);
|
||||
String get bodySmallFamily => 'Readex Pro';
|
||||
TextStyle get bodySmall => GoogleFonts.getFont(
|
||||
'Readex Pro',
|
||||
color: theme.primaryText,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 12.0,
|
||||
);
|
||||
}
|
||||
|
||||
class DarkModeTheme extends FlutterTheme {
|
||||
@Deprecated('Use primary instead')
|
||||
Color get primaryColor => primary;
|
||||
@Deprecated('Use secondary instead')
|
||||
Color get secondaryColor => secondary;
|
||||
@Deprecated('Use tertiary instead')
|
||||
Color get tertiaryColor => tertiary;
|
||||
|
||||
late Color primary = const Color(0xFF4B39EF);
|
||||
late Color secondary = const Color(0xFF39D2C0);
|
||||
late Color tertiary = const Color(0xFFEE8B60);
|
||||
late Color alternate = const Color(0xFF262D34);
|
||||
late Color primaryText = const Color(0xFFFFFFFF);
|
||||
late Color secondaryText = const Color(0xFF95A1AC);
|
||||
late Color primaryBackground = const Color(0xFF1D2428);
|
||||
late Color secondaryBackground = const Color(0xFF14181B);
|
||||
late Color accent1 = const Color(0x4C4B39EF);
|
||||
late Color accent2 = const Color(0x4D39D2C0);
|
||||
late Color accent3 = const Color(0x4DEE8B60);
|
||||
late Color accent4 = const Color(0xB2262D34);
|
||||
late Color success = const Color(0xFF249689);
|
||||
late Color warning = const Color(0xFFF9CF58);
|
||||
late Color error = const Color(0xFFFF5963);
|
||||
late Color info = const Color(0xFFFFFFFF);
|
||||
}
|
||||
|
||||
extension TextStyleHelper on TextStyle {
|
||||
TextStyle override({
|
||||
String? fontFamily,
|
||||
Color? color,
|
||||
double? fontSize,
|
||||
FontWeight? fontWeight,
|
||||
double? letterSpacing,
|
||||
FontStyle? fontStyle,
|
||||
bool useGoogleFonts = true,
|
||||
TextDecoration? decoration,
|
||||
double? lineHeight,
|
||||
}) =>
|
||||
useGoogleFonts
|
||||
? GoogleFonts.getFont(
|
||||
fontFamily!,
|
||||
color: color ?? this.color,
|
||||
fontSize: fontSize ?? this.fontSize,
|
||||
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||
fontWeight: fontWeight ?? this.fontWeight,
|
||||
fontStyle: fontStyle ?? this.fontStyle,
|
||||
decoration: decoration,
|
||||
height: lineHeight,
|
||||
)
|
||||
: copyWith(
|
||||
fontFamily: fontFamily,
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
letterSpacing: letterSpacing,
|
||||
fontWeight: fontWeight,
|
||||
fontStyle: fontStyle,
|
||||
decoration: decoration,
|
||||
height: lineHeight,
|
||||
);
|
||||
}
|
||||
481
lib/flutterlib/flutter_util.dart
Normal file
481
lib/flutterlib/flutter_util.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:from_css_color/from_css_color.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:json_path/json_path.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
import 'lat_lng.dart';
|
||||
|
||||
export 'keep_alive_wrapper.dart';
|
||||
export 'lat_lng.dart';
|
||||
export 'place.dart';
|
||||
export 'uploaded_file.dart';
|
||||
export '../app_state.dart';
|
||||
export 'flutter_model.dart';
|
||||
export 'dart:math' show min, max;
|
||||
export 'dart:typed_data' show Uint8List;
|
||||
export 'dart:convert' show jsonEncode, jsonDecode;
|
||||
export 'package:intl/intl.dart';
|
||||
export 'package:page_transition/page_transition.dart';
|
||||
export 'internationalization.dart' show FFLocalizations;
|
||||
export 'nav/nav.dart';
|
||||
|
||||
T valueOrDefault<T>(T? value, T defaultValue) =>
|
||||
(value is String && value.isEmpty) || value == null ? defaultValue : value;
|
||||
|
||||
void _setTimeagoLocales() {
|
||||
timeago.setLocaleMessages('en', timeago.EnMessages());
|
||||
timeago.setLocaleMessages('en_short', timeago.EnShortMessages());
|
||||
timeago.setLocaleMessages('zh_Hant', timeago.ZhMessages());
|
||||
}
|
||||
|
||||
String dateTimeFormat(String format, DateTime? dateTime, {String? locale}) {
|
||||
if (dateTime == null) {
|
||||
return '';
|
||||
}
|
||||
if (format == 'relative') {
|
||||
_setTimeagoLocales();
|
||||
return timeago.format(dateTime, locale: locale, allowFromNow: true);
|
||||
}
|
||||
return DateFormat(format, locale).format(dateTime);
|
||||
}
|
||||
|
||||
Theme wrapInMaterialDatePickerTheme(
|
||||
BuildContext context,
|
||||
Widget child, {
|
||||
required Color headerBackgroundColor,
|
||||
required Color headerForegroundColor,
|
||||
required TextStyle headerTextStyle,
|
||||
required Color pickerBackgroundColor,
|
||||
required Color pickerForegroundColor,
|
||||
required Color selectedDateTimeBackgroundColor,
|
||||
required Color selectedDateTimeForegroundColor,
|
||||
required Color actionButtonForegroundColor,
|
||||
required double iconSize,
|
||||
}) {
|
||||
final baseTheme = Theme.of(context);
|
||||
final dateTimeMaterialStateForegroundColor =
|
||||
MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.disabled)) {
|
||||
return pickerForegroundColor.withOpacity(0.60);
|
||||
}
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return selectedDateTimeForegroundColor;
|
||||
}
|
||||
if (states.isEmpty) {
|
||||
return pickerForegroundColor;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final dateTimeMaterialStateBackgroundColor =
|
||||
MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return selectedDateTimeBackgroundColor;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return Theme(
|
||||
data: baseTheme.copyWith(
|
||||
colorScheme: baseTheme.colorScheme.copyWith(
|
||||
onSurface: pickerForegroundColor,
|
||||
),
|
||||
disabledColor: pickerForegroundColor.withOpacity(0.3),
|
||||
textTheme: baseTheme.textTheme.copyWith(
|
||||
headlineSmall: headerTextStyle,
|
||||
headlineMedium: headerTextStyle,
|
||||
),
|
||||
iconTheme: baseTheme.iconTheme.copyWith(
|
||||
size: iconSize,
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStatePropertyAll(
|
||||
actionButtonForegroundColor,
|
||||
),
|
||||
overlayColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return actionButtonForegroundColor.withOpacity(0.04);
|
||||
}
|
||||
if (states.contains(MaterialState.focused) ||
|
||||
states.contains(MaterialState.pressed)) {
|
||||
return actionButtonForegroundColor.withOpacity(0.12);
|
||||
}
|
||||
return null;
|
||||
})),
|
||||
),
|
||||
datePickerTheme: DatePickerThemeData(
|
||||
backgroundColor: pickerBackgroundColor,
|
||||
headerBackgroundColor: headerBackgroundColor,
|
||||
headerForegroundColor: headerForegroundColor,
|
||||
weekdayStyle: baseTheme.textTheme.labelMedium!.copyWith(
|
||||
color: pickerForegroundColor,
|
||||
),
|
||||
dayBackgroundColor: dateTimeMaterialStateBackgroundColor,
|
||||
todayBackgroundColor: dateTimeMaterialStateBackgroundColor,
|
||||
yearBackgroundColor: dateTimeMaterialStateBackgroundColor,
|
||||
dayForegroundColor: dateTimeMaterialStateForegroundColor,
|
||||
todayForegroundColor: dateTimeMaterialStateForegroundColor,
|
||||
yearForegroundColor: dateTimeMaterialStateForegroundColor,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Theme wrapInMaterialTimePickerTheme(
|
||||
BuildContext context,
|
||||
Widget child, {
|
||||
required Color headerBackgroundColor,
|
||||
required Color headerForegroundColor,
|
||||
required TextStyle headerTextStyle,
|
||||
required Color pickerBackgroundColor,
|
||||
required Color pickerForegroundColor,
|
||||
required Color selectedDateTimeBackgroundColor,
|
||||
required Color selectedDateTimeForegroundColor,
|
||||
required Color actionButtonForegroundColor,
|
||||
required double iconSize,
|
||||
}) {
|
||||
final baseTheme = Theme.of(context);
|
||||
return Theme(
|
||||
data: baseTheme.copyWith(
|
||||
iconTheme: baseTheme.iconTheme.copyWith(
|
||||
size: iconSize,
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStatePropertyAll(
|
||||
actionButtonForegroundColor,
|
||||
),
|
||||
overlayColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return actionButtonForegroundColor.withOpacity(0.04);
|
||||
}
|
||||
if (states.contains(MaterialState.focused) ||
|
||||
states.contains(MaterialState.pressed)) {
|
||||
return actionButtonForegroundColor.withOpacity(0.12);
|
||||
}
|
||||
return null;
|
||||
})),
|
||||
),
|
||||
timePickerTheme: baseTheme.timePickerTheme.copyWith(
|
||||
backgroundColor: pickerBackgroundColor,
|
||||
hourMinuteTextColor: pickerForegroundColor,
|
||||
dialHandColor: selectedDateTimeBackgroundColor,
|
||||
dialTextColor: MaterialStateColor.resolveWith((states) =>
|
||||
states.contains(MaterialState.selected)
|
||||
? selectedDateTimeForegroundColor
|
||||
: pickerForegroundColor),
|
||||
dayPeriodBorderSide: BorderSide(
|
||||
color: pickerForegroundColor,
|
||||
),
|
||||
dayPeriodTextColor: MaterialStateColor.resolveWith((states) =>
|
||||
states.contains(MaterialState.selected)
|
||||
? selectedDateTimeForegroundColor
|
||||
: pickerForegroundColor),
|
||||
dayPeriodColor: MaterialStateColor.resolveWith((states) =>
|
||||
states.contains(MaterialState.selected)
|
||||
? selectedDateTimeBackgroundColor
|
||||
: Colors.transparent),
|
||||
entryModeIconColor: pickerForegroundColor,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Future launchURL(String url) async {
|
||||
var uri = Uri.parse(url).toString();
|
||||
try {
|
||||
await launch(uri);
|
||||
} catch (e) {
|
||||
throw 'Could not launch $uri: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Color colorFromCssString(String color, {Color? defaultColor}) {
|
||||
try {
|
||||
return fromCssColor(color);
|
||||
} catch (_) {}
|
||||
return defaultColor ?? Colors.black;
|
||||
}
|
||||
|
||||
enum FormatType {
|
||||
decimal,
|
||||
percent,
|
||||
scientific,
|
||||
compact,
|
||||
compactLong,
|
||||
custom,
|
||||
}
|
||||
|
||||
enum DecimalType {
|
||||
automatic,
|
||||
periodDecimal,
|
||||
commaDecimal,
|
||||
}
|
||||
|
||||
String formatNumber(
|
||||
num? value, {
|
||||
required FormatType formatType,
|
||||
DecimalType? decimalType,
|
||||
String? currency,
|
||||
bool toLowerCase = false,
|
||||
String? format,
|
||||
String? locale,
|
||||
}) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
var formattedValue = '';
|
||||
switch (formatType) {
|
||||
case FormatType.decimal:
|
||||
switch (decimalType!) {
|
||||
case DecimalType.automatic:
|
||||
formattedValue = NumberFormat.decimalPattern().format(value);
|
||||
break;
|
||||
case DecimalType.periodDecimal:
|
||||
formattedValue = NumberFormat.decimalPattern('en_US').format(value);
|
||||
break;
|
||||
case DecimalType.commaDecimal:
|
||||
formattedValue = NumberFormat.decimalPattern('es_PA').format(value);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case FormatType.percent:
|
||||
formattedValue = NumberFormat.percentPattern().format(value);
|
||||
break;
|
||||
case FormatType.scientific:
|
||||
formattedValue = NumberFormat.scientificPattern().format(value);
|
||||
if (toLowerCase) {
|
||||
formattedValue = formattedValue.toLowerCase();
|
||||
}
|
||||
break;
|
||||
case FormatType.compact:
|
||||
formattedValue = NumberFormat.compact().format(value);
|
||||
break;
|
||||
case FormatType.compactLong:
|
||||
formattedValue = NumberFormat.compactLong().format(value);
|
||||
break;
|
||||
case FormatType.custom:
|
||||
final hasLocale = locale != null && locale.isNotEmpty;
|
||||
formattedValue =
|
||||
NumberFormat(format, hasLocale ? locale : null).format(value);
|
||||
}
|
||||
|
||||
if (formattedValue.isEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (currency != null) {
|
||||
final currencySymbol = currency.isNotEmpty
|
||||
? currency
|
||||
: NumberFormat.simpleCurrency().format(0.0).substring(0, 1);
|
||||
formattedValue = '$currencySymbol$formattedValue';
|
||||
}
|
||||
|
||||
return formattedValue;
|
||||
}
|
||||
|
||||
DateTime get getCurrentTimestamp => DateTime.now();
|
||||
DateTime dateTimeFromSecondsSinceEpoch(int seconds) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
||||
}
|
||||
|
||||
extension DateTimeConversionExtension on DateTime {
|
||||
int get secondsSinceEpoch => (millisecondsSinceEpoch / 1000).round();
|
||||
}
|
||||
|
||||
extension DateTimeComparisonOperators on DateTime {
|
||||
bool operator <(DateTime other) => isBefore(other);
|
||||
bool operator >(DateTime other) => isAfter(other);
|
||||
bool operator <=(DateTime other) => this < other || isAtSameMomentAs(other);
|
||||
bool operator >=(DateTime other) => this > other || isAtSameMomentAs(other);
|
||||
}
|
||||
|
||||
T? castToType<T>(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
switch (T) {
|
||||
case double:
|
||||
// Doubles may be stored as ints in some cases.
|
||||
return value.toDouble() as T;
|
||||
case int:
|
||||
// Likewise, ints may be stored as doubles. If this is the case
|
||||
// (i.e. no decimal value), return the value as an int.
|
||||
if (value is num && value.toInt() == value) {
|
||||
return value.toInt() as T;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
dynamic getJsonField(
|
||||
dynamic response,
|
||||
String jsonPath, [
|
||||
bool isForList = false,
|
||||
]) {
|
||||
final field = JsonPath(jsonPath).read(response);
|
||||
if (field.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (field.length > 1) {
|
||||
return field.map((f) => f.value).toList();
|
||||
}
|
||||
final value = field.first.value;
|
||||
if (isForList) {
|
||||
return value is! Iterable
|
||||
? [value]
|
||||
: (value is List ? value : value.toList());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Rect? getWidgetBoundingBox(BuildContext context) {
|
||||
try {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
return renderBox!.localToGlobal(Offset.zero) & renderBox.size;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isAndroid => !kIsWeb && Platform.isAndroid;
|
||||
bool get isiOS => !kIsWeb && Platform.isIOS;
|
||||
bool get isWeb => kIsWeb;
|
||||
|
||||
const kBreakpointSmall = 479.0;
|
||||
const kBreakpointMedium = 767.0;
|
||||
const kBreakpointLarge = 991.0;
|
||||
bool isMobileWidth(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width < kBreakpointSmall;
|
||||
bool responsiveVisibility({
|
||||
required BuildContext context,
|
||||
bool phone = true,
|
||||
bool tablet = true,
|
||||
bool tabletLandscape = true,
|
||||
bool desktop = true,
|
||||
}) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
if (width < kBreakpointSmall) {
|
||||
return phone;
|
||||
} else if (width < kBreakpointMedium) {
|
||||
return tablet;
|
||||
} else if (width < kBreakpointLarge) {
|
||||
return tabletLandscape;
|
||||
} else {
|
||||
return desktop;
|
||||
}
|
||||
}
|
||||
|
||||
const kTextValidatorUsernameRegex = r'^[a-zA-Z][a-zA-Z0-9_-]{2,16}$';
|
||||
// https://stackoverflow.com/a/201378
|
||||
const kTextValidatorEmailRegex =
|
||||
"^(?:[a-z0-9!#\$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#\$%&\'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])\$";
|
||||
const kTextValidatorWebsiteRegex =
|
||||
r'(https?:\/\/)?(www\.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,10}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)|(https?:\/\/)?(www\.)?(?!ww)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,10}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)';
|
||||
|
||||
extension FFTextEditingControllerExt on TextEditingController? {
|
||||
String get text => this == null ? '' : this!.text;
|
||||
set text(String newText) => this?.text = newText;
|
||||
}
|
||||
|
||||
extension IterableExt<T> on Iterable<T> {
|
||||
List<T> sortedList<S extends Comparable>([S Function(T)? keyOf]) => toList()
|
||||
..sort(keyOf == null ? null : ((a, b) => keyOf(a).compareTo(keyOf(b))));
|
||||
|
||||
List<S> mapIndexed<S>(S Function(int, T) func) => toList()
|
||||
.asMap()
|
||||
.map((index, value) => MapEntry(index, func(index, value)))
|
||||
.values
|
||||
.toList();
|
||||
}
|
||||
|
||||
void setAppLanguage(BuildContext context, String language) =>
|
||||
MyApp.of(context).setLocale(language);
|
||||
|
||||
void setDarkModeSetting(BuildContext context, ThemeMode themeMode) =>
|
||||
MyApp.of(context).setThemeMode(themeMode);
|
||||
|
||||
void showSnackbar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
bool loading = false,
|
||||
int duration = 4,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (loading)
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 10.0),
|
||||
child: Container(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
duration: Duration(seconds: duration),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension FFStringExt on String {
|
||||
String maybeHandleOverflow({int? maxChars, String replacement = ''}) =>
|
||||
maxChars != null && length > maxChars
|
||||
? replaceRange(maxChars, null, replacement)
|
||||
: this;
|
||||
}
|
||||
|
||||
extension ListFilterExt<T> on Iterable<T?> {
|
||||
List<T> get withoutNulls => where((s) => s != null).map((e) => e!).toList();
|
||||
}
|
||||
|
||||
extension ListDivideExt<T extends Widget> on Iterable<T> {
|
||||
Iterable<MapEntry<int, Widget>> get enumerate => toList().asMap().entries;
|
||||
|
||||
List<Widget> divide(Widget t) => isEmpty
|
||||
? []
|
||||
: (enumerate.map((e) => [e.value, t]).expand((i) => i).toList()
|
||||
..removeLast());
|
||||
|
||||
List<Widget> around(Widget t) => addToStart(t).addToEnd(t);
|
||||
|
||||
List<Widget> addToStart(Widget t) =>
|
||||
enumerate.map((e) => e.value).toList()..insert(0, t);
|
||||
|
||||
List<Widget> addToEnd(Widget t) =>
|
||||
enumerate.map((e) => e.value).toList()..add(t);
|
||||
|
||||
List<Padding> paddingTopEach(double val) =>
|
||||
map((w) => Padding(padding: EdgeInsets.only(top: val), child: w))
|
||||
.toList();
|
||||
}
|
||||
|
||||
extension StatefulWidgetExtensions on State<StatefulWidget> {
|
||||
/// Check if the widget exist before safely setting state.
|
||||
void safeSetState(VoidCallback fn) {
|
||||
if (mounted) {
|
||||
// ignore: invalid_use_of_protected_member
|
||||
setState(fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
257
lib/flutterlib/flutter_widgets.dart
Normal file
257
lib/flutterlib/flutter_widgets.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
|
||||
class FFButtonOptions {
|
||||
const FFButtonOptions({
|
||||
this.textStyle,
|
||||
this.elevation,
|
||||
this.height,
|
||||
this.width,
|
||||
this.padding,
|
||||
this.color,
|
||||
this.disabledColor,
|
||||
this.disabledTextColor,
|
||||
this.splashColor,
|
||||
this.iconSize,
|
||||
this.iconColor,
|
||||
this.iconPadding,
|
||||
this.borderRadius,
|
||||
this.borderSide,
|
||||
this.hoverColor,
|
||||
this.hoverBorderSide,
|
||||
this.hoverTextColor,
|
||||
this.hoverElevation,
|
||||
this.maxLines,
|
||||
});
|
||||
|
||||
final TextStyle? textStyle;
|
||||
final double? elevation;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Color? color;
|
||||
final Color? disabledColor;
|
||||
final Color? disabledTextColor;
|
||||
final int? maxLines;
|
||||
final Color? splashColor;
|
||||
final double? iconSize;
|
||||
final Color? iconColor;
|
||||
final EdgeInsetsGeometry? iconPadding;
|
||||
final BorderRadius? borderRadius;
|
||||
final BorderSide? borderSide;
|
||||
final Color? hoverColor;
|
||||
final BorderSide? hoverBorderSide;
|
||||
final Color? hoverTextColor;
|
||||
final double? hoverElevation;
|
||||
}
|
||||
|
||||
class FFButtonWidget extends StatefulWidget {
|
||||
const FFButtonWidget({
|
||||
Key? key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.icon,
|
||||
this.iconData,
|
||||
required this.options,
|
||||
this.showLoadingIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
final String text;
|
||||
final Widget? icon;
|
||||
final IconData? iconData;
|
||||
final Function()? onPressed;
|
||||
final FFButtonOptions options;
|
||||
final bool showLoadingIndicator;
|
||||
|
||||
@override
|
||||
State<FFButtonWidget> createState() => _FFButtonWidgetState();
|
||||
}
|
||||
|
||||
class _FFButtonWidgetState extends State<FFButtonWidget> {
|
||||
bool loading = false;
|
||||
|
||||
int get maxLines => widget.options.maxLines ?? 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget textWidget = loading
|
||||
? SizedBox(
|
||||
width: widget.options.width == null
|
||||
? _getTextWidth(widget.text, widget.options.textStyle, maxLines)
|
||||
: null,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 23,
|
||||
height: 23,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
widget.options.textStyle!.color ?? Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: AutoSizeText(
|
||||
widget.text,
|
||||
style: widget.options.textStyle?.withoutColor(),
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
|
||||
final onPressed = widget.onPressed != null
|
||||
? (widget.showLoadingIndicator
|
||||
? () async {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
await widget.onPressed!();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
: () => widget.onPressed!())
|
||||
: null;
|
||||
|
||||
ButtonStyle style = ButtonStyle(
|
||||
shape: MaterialStateProperty.resolveWith<OutlinedBorder>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.options.hoverBorderSide != null) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
widget.options.borderRadius ?? BorderRadius.circular(8),
|
||||
side: widget.options.hoverBorderSide!,
|
||||
);
|
||||
}
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
widget.options.borderRadius ?? BorderRadius.circular(8),
|
||||
side: widget.options.borderSide ?? BorderSide.none,
|
||||
);
|
||||
},
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color?>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.disabled) &&
|
||||
widget.options.disabledTextColor != null) {
|
||||
return widget.options.disabledTextColor;
|
||||
}
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.options.hoverTextColor != null) {
|
||||
return widget.options.hoverTextColor;
|
||||
}
|
||||
return widget.options.textStyle?.color;
|
||||
},
|
||||
),
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.disabled) &&
|
||||
widget.options.disabledColor != null) {
|
||||
return widget.options.disabledColor;
|
||||
}
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.options.hoverColor != null) {
|
||||
return widget.options.hoverColor;
|
||||
}
|
||||
return widget.options.color;
|
||||
},
|
||||
),
|
||||
overlayColor: MaterialStateProperty.resolveWith<Color?>((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return widget.options.splashColor;
|
||||
}
|
||||
return widget.options.hoverColor == null ? null : Colors.transparent;
|
||||
}),
|
||||
padding: MaterialStateProperty.all(widget.options.padding ??
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0)),
|
||||
elevation: MaterialStateProperty.resolveWith<double?>(
|
||||
(states) {
|
||||
if (states.contains(MaterialState.hovered) &&
|
||||
widget.options.hoverElevation != null) {
|
||||
return widget.options.hoverElevation!;
|
||||
}
|
||||
return widget.options.elevation;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if ((widget.icon != null || widget.iconData != null) && !loading) {
|
||||
return Container(
|
||||
height: widget.options.height,
|
||||
width: widget.options.width,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Padding(
|
||||
padding: widget.options.iconPadding ?? EdgeInsets.zero,
|
||||
child: widget.icon ??
|
||||
FaIcon(
|
||||
widget.iconData,
|
||||
size: widget.options.iconSize,
|
||||
color: widget.options.iconColor ??
|
||||
widget.options.textStyle!.color,
|
||||
),
|
||||
),
|
||||
label: textWidget,
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: widget.options.height,
|
||||
width: widget.options.width,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
child: textWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _WithoutColorExtension on TextStyle {
|
||||
TextStyle withoutColor() => TextStyle(
|
||||
inherit: inherit,
|
||||
color: null,
|
||||
backgroundColor: backgroundColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
fontStyle: fontStyle,
|
||||
letterSpacing: letterSpacing,
|
||||
wordSpacing: wordSpacing,
|
||||
textBaseline: textBaseline,
|
||||
height: height,
|
||||
leadingDistribution: leadingDistribution,
|
||||
locale: locale,
|
||||
foreground: foreground,
|
||||
background: background,
|
||||
shadows: shadows,
|
||||
fontFeatures: fontFeatures,
|
||||
decoration: decoration,
|
||||
decorationColor: decorationColor,
|
||||
decorationStyle: decorationStyle,
|
||||
decorationThickness: decorationThickness,
|
||||
debugLabel: debugLabel,
|
||||
fontFamily: fontFamily,
|
||||
fontFamilyFallback: fontFamilyFallback,
|
||||
// The _package field is private so unfortunately we can't set it here,
|
||||
// but it's almost always unset anyway.
|
||||
// package: _package,
|
||||
overflow: overflow,
|
||||
);
|
||||
}
|
||||
|
||||
// Slightly hacky method of getting the layout width of the provided text.
|
||||
double _getTextWidth(String text, TextStyle? style, int maxLines) =>
|
||||
(TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: maxLines,
|
||||
)..layout())
|
||||
.size
|
||||
.width;
|
||||
9
lib/flutterlib/form_field_controller.dart
Normal file
9
lib/flutterlib/form_field_controller.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FormFieldController<T> extends ValueNotifier<T?> {
|
||||
FormFieldController(this.initialValue) : super(initialValue);
|
||||
|
||||
final T? initialValue;
|
||||
|
||||
void reset() => value = initialValue;
|
||||
}
|
||||
6184
lib/flutterlib/internationalization.dart
Normal file
6184
lib/flutterlib/internationalization.dart
Normal file
File diff suppressed because it is too large
Load Diff
25
lib/flutterlib/keep_alive_wrapper.dart
Normal file
25
lib/flutterlib/keep_alive_wrapper.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class KeepAliveWidgetWrapper extends StatefulWidget {
|
||||
const KeepAliveWidgetWrapper({
|
||||
Key? key,
|
||||
required this.builder,
|
||||
}) : super(key: key);
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
@override
|
||||
State<KeepAliveWidgetWrapper> createState() => _KeepAliveWidgetWrapperState();
|
||||
}
|
||||
|
||||
class _KeepAliveWidgetWrapperState extends State<KeepAliveWidgetWrapper>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.builder(context);
|
||||
}
|
||||
}
|
||||
19
lib/flutterlib/lat_lng.dart
Normal file
19
lib/flutterlib/lat_lng.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
class LatLng {
|
||||
const LatLng(this.latitude, this.longitude);
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
|
||||
@override
|
||||
String toString() => 'LatLng(lat: $latitude, lng: $longitude)';
|
||||
|
||||
String serialize() => '$latitude,$longitude';
|
||||
|
||||
@override
|
||||
int get hashCode => latitude.hashCode + longitude.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(other) =>
|
||||
other is LatLng &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
}
|
||||
686
lib/flutterlib/nav/nav.dart
Normal file
686
lib/flutterlib/nav/nav.dart
Normal file
@@ -0,0 +1,686 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '/backend/schema/enums/enums.dart';
|
||||
|
||||
import '/auth/custom_auth/custom_auth_user_provider.dart';
|
||||
|
||||
import '/index.dart';
|
||||
import '/main.dart';
|
||||
import '/flutterlib/flutter_theme.dart';
|
||||
import '/flutterlib/lat_lng.dart';
|
||||
import '/flutterlib/place.dart';
|
||||
import '/flutterlib/flutter_util.dart';
|
||||
import 'serialization_util.dart';
|
||||
|
||||
export 'package:go_router/go_router.dart';
|
||||
export 'serialization_util.dart';
|
||||
|
||||
const kTransitionInfoKey = '__transition_info__';
|
||||
|
||||
class AppStateNotifier extends ChangeNotifier {
|
||||
AppStateNotifier._();
|
||||
|
||||
static AppStateNotifier? _instance;
|
||||
static AppStateNotifier get instance => _instance ??= AppStateNotifier._();
|
||||
|
||||
NumstationAuthUser? initialUser;
|
||||
NumstationAuthUser? user;
|
||||
bool showSplashImage = true;
|
||||
String? _redirectLocation;
|
||||
|
||||
/// Determines whether the app will refresh and build again when a sign
|
||||
/// in or sign out happens. This is useful when the app is launched or
|
||||
/// on an unexpected logout. However, this must be turned off when we
|
||||
/// intend to sign in/out and then navigate or perform any actions after.
|
||||
/// Otherwise, this will trigger a refresh and interrupt the action(s).
|
||||
bool notifyOnAuthChange = true;
|
||||
|
||||
bool get loading => user == null || showSplashImage;
|
||||
bool get loggedIn => user?.loggedIn ?? false;
|
||||
bool get initiallyLoggedIn => initialUser?.loggedIn ?? false;
|
||||
bool get shouldRedirect => loggedIn && _redirectLocation != null;
|
||||
|
||||
String getRedirectLocation() => _redirectLocation!;
|
||||
bool hasRedirect() => _redirectLocation != null;
|
||||
void setRedirectLocationIfUnset(String loc) => _redirectLocation ??= loc;
|
||||
void clearRedirectLocation() => _redirectLocation = null;
|
||||
|
||||
/// Mark as not needing to notify on a sign in / out when we intend
|
||||
/// to perform subsequent actions (such as navigation) afterwards.
|
||||
void updateNotifyOnAuthChange(bool notify) => notifyOnAuthChange = notify;
|
||||
|
||||
void update(NumstationAuthUser newUser) {
|
||||
final shouldUpdate =
|
||||
user?.uid == null || newUser.uid == null || user?.uid != newUser.uid;
|
||||
initialUser ??= newUser;
|
||||
user = newUser;
|
||||
// Refresh the app on auth change unless explicitly marked otherwise.
|
||||
// No need to update unless the user has changed.
|
||||
if (notifyOnAuthChange && shouldUpdate) {
|
||||
notifyListeners();
|
||||
}
|
||||
// Once again mark the notifier as needing to update on auth change
|
||||
// (in order to catch sign in / out events).
|
||||
updateNotifyOnAuthChange(true);
|
||||
}
|
||||
|
||||
void stopShowingSplashImage() {
|
||||
showSplashImage = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter createRouter(AppStateNotifier appStateNotifier) => GoRouter(
|
||||
initialLocation: '/',
|
||||
debugLogDiagnostics: true,
|
||||
refreshListenable: appStateNotifier,
|
||||
errorBuilder: (context, state) =>
|
||||
appStateNotifier.loggedIn ? MenuWidget() : LoginWidget(),
|
||||
routes: [
|
||||
FFRoute(
|
||||
name: '_initialize',
|
||||
path: '/',
|
||||
builder: (context, _) =>
|
||||
appStateNotifier.loggedIn ? MenuWidget() : LoginWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login',
|
||||
path: '/login',
|
||||
builder: (context, params) => LoginWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'signup',
|
||||
path: '/signup',
|
||||
builder: (context, params) => SignupWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login1',
|
||||
path: '/login1',
|
||||
builder: (context, params) => Login1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login2',
|
||||
path: '/login2',
|
||||
builder: (context, params) => Login2Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login3',
|
||||
path: '/login3',
|
||||
builder: (context, params) => Login3Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login4',
|
||||
path: '/login4',
|
||||
builder: (context, params) => Login4Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'login5',
|
||||
path: '/login5',
|
||||
builder: (context, params) => Login5Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'tnc',
|
||||
path: '/tnc',
|
||||
builder: (context, params) => TncWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'policy',
|
||||
path: '/policy',
|
||||
builder: (context, params) => PolicyWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'menu',
|
||||
path: '/menu',
|
||||
builder: (context, params) => MenuWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'dashboard',
|
||||
path: '/dashboard',
|
||||
builder: (context, params) => DashboardWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'test',
|
||||
path: '/test',
|
||||
builder: (context, params) => TestWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'setting',
|
||||
path: '/setting',
|
||||
builder: (context, params) => SettingWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'user',
|
||||
path: '/user',
|
||||
builder: (context, params) => UserWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk1',
|
||||
path: '/bk1',
|
||||
builder: (context, params) => Bk1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'enquiry',
|
||||
path: '/enquiry',
|
||||
builder: (context, params) => EnquiryWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'myenquiry',
|
||||
path: '/myenquiry',
|
||||
builder: (context, params) => MyenquiryWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'myenquiry1',
|
||||
path: '/myenquiry1',
|
||||
builder: (context, params) => Myenquiry1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'language',
|
||||
path: '/language',
|
||||
builder: (context, params) => LanguageWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'account1',
|
||||
path: '/account1',
|
||||
builder: (context, params) => Account1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'chatbox',
|
||||
path: '/chatbox',
|
||||
builder: (context, params) => ChatboxWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec',
|
||||
path: '/compsec',
|
||||
builder: (context, params) => CompsecWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec1',
|
||||
path: '/compsec1',
|
||||
builder: (context, params) => Compsec1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec2',
|
||||
path: '/compsec2',
|
||||
builder: (context, params) => Compsec2Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec3',
|
||||
path: '/compsec3',
|
||||
builder: (context, params) => Compsec3Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec4',
|
||||
path: '/compsec4',
|
||||
builder: (context, params) => Compsec4Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec5',
|
||||
path: '/compsec5',
|
||||
builder: (context, params) => Compsec5Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk2',
|
||||
path: '/bk2',
|
||||
builder: (context, params) => Bk2Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk3',
|
||||
path: '/bk3',
|
||||
builder: (context, params) => Bk3Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk4',
|
||||
path: '/bk4',
|
||||
builder: (context, params) => Bk4Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk4_1',
|
||||
path: '/bk41',
|
||||
builder: (context, params) => Bk41Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk4_2',
|
||||
path: '/bk42',
|
||||
builder: (context, params) => Bk42Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'search',
|
||||
path: '/search',
|
||||
builder: (context, params) => SearchWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk_checkout',
|
||||
path: '/bkCheckout',
|
||||
builder: (context, params) => BkCheckoutWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk_checkout1',
|
||||
path: '/bkCheckout1',
|
||||
builder: (context, params) => BkCheckout1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_checkout',
|
||||
path: '/compsecCheckout',
|
||||
builder: (context, params) => CompsecCheckoutWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_checkout1',
|
||||
path: '/compsecCheckout1',
|
||||
builder: (context, params) => CompsecCheckout1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_checkout2',
|
||||
path: '/compsecCheckout2',
|
||||
builder: (context, params) => CompsecCheckout2Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecEnq',
|
||||
path: '/compsecEnq',
|
||||
builder: (context, params) => CompsecEnqWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecEnq1',
|
||||
path: '/compsecEnq1',
|
||||
builder: (context, params) => CompsecEnq1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_changename',
|
||||
path: '/compsecChangename',
|
||||
builder: (context, params) => CompsecChangenameWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_changeaddress',
|
||||
path: '/compsecChangeaddress',
|
||||
builder: (context, params) => CompsecChangeaddressWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_changedirector',
|
||||
path: '/compsecChangedirector',
|
||||
builder: (context, params) => CompsecChangedirectorWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_changesecretary',
|
||||
path: '/compsecChangesecretary',
|
||||
builder: (context, params) => CompsecChangesecretaryWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_changesecrORdir',
|
||||
path: '/compsecChangesecrORdir',
|
||||
builder: (context, params) => CompsecChangesecrORdirWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_resignation',
|
||||
path: '/compsecResignation',
|
||||
builder: (context, params) => CompsecResignationWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_transferofShares',
|
||||
path: '/compsecTransferofShares',
|
||||
builder: (context, params) => CompsecTransferofSharesWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsec_otherRequest',
|
||||
path: '/compsecOtherRequest',
|
||||
builder: (context, params) => CompsecOtherRequestWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'account',
|
||||
path: '/account',
|
||||
builder: (context, params) => AccountWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'notification',
|
||||
path: '/notification',
|
||||
builder: (context, params) => NotificationWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecQueue',
|
||||
path: '/compsecQueue',
|
||||
builder: (context, params) => CompsecQueueWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecQueue1_1',
|
||||
path: '/compsecQueue11',
|
||||
builder: (context, params) => CompsecQueue11Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'testing',
|
||||
path: '/testing',
|
||||
builder: (context, params) => TestingWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'newuser',
|
||||
path: '/newuser',
|
||||
builder: (context, params) => NewuserWidget(
|
||||
checkPermission23:
|
||||
params.getParam('checkPermission23', ParamType.bool),
|
||||
checkPremission22:
|
||||
params.getParam('checkPremission22', ParamType.bool),
|
||||
),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'userDetail',
|
||||
path: '/userDetail',
|
||||
builder: (context, params) => UserDetailWidget(
|
||||
firstName: params.getParam('firstName', ParamType.String),
|
||||
lastName: params.getParam('lastName', ParamType.String),
|
||||
phone: params.getParam('phone', ParamType.int),
|
||||
email: params.getParam('email', ParamType.String),
|
||||
status: params.getParam('status', ParamType.String),
|
||||
),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecDigitalTrans_list',
|
||||
path: '/compsecDigitalTransList',
|
||||
builder: (context, params) => CompsecDigitalTransListWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'compsecServicedraft',
|
||||
path: '/compsecServicedraft',
|
||||
builder: (context, params) => CompsecServicedraftWidget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'setting1',
|
||||
path: '/setting1',
|
||||
builder: (context, params) => Setting1Widget(),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'editUser',
|
||||
path: '/editUser',
|
||||
builder: (context, params) => EditUserWidget(
|
||||
firstName: params.getParam('firstName', ParamType.String),
|
||||
lastName: params.getParam('lastName', ParamType.String),
|
||||
phone: params.getParam('phone', ParamType.int),
|
||||
email: params.getParam('email', ParamType.String),
|
||||
userID: params.getParam('userID', ParamType.int),
|
||||
checkPermission23:
|
||||
params.getParam('checkPermission23', ParamType.bool),
|
||||
checkPremission22:
|
||||
params.getParam('checkPremission22', ParamType.bool),
|
||||
roleID: params.getParam('roleID', ParamType.String),
|
||||
),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'bk_view',
|
||||
path: '/bkView',
|
||||
builder: (context, params) => BkViewWidget(
|
||||
fileUrl: params.getParam('fileUrl', ParamType.JSON),
|
||||
),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'search1',
|
||||
path: '/search1',
|
||||
builder: (context, params) => Search1Widget(
|
||||
search: params.getParam('search', ParamType.String),
|
||||
status: params.getParam('status', ParamType.String),
|
||||
category: params.getParam('category', ParamType.String),
|
||||
fixDate: params.getParam('fixDate', ParamType.String),
|
||||
dateFrom: params.getParam('dateFrom', ParamType.String),
|
||||
dateTo: params.getParam('dateTo', ParamType.String),
|
||||
),
|
||||
),
|
||||
FFRoute(
|
||||
name: 'search_page',
|
||||
path: '/searchPage',
|
||||
builder: (context, params) => SearchPageWidget(),
|
||||
)
|
||||
].map((r) => r.toRoute(appStateNotifier)).toList(),
|
||||
);
|
||||
|
||||
extension NavParamExtensions on Map<String, String?> {
|
||||
Map<String, String> get withoutNulls => Map.fromEntries(
|
||||
entries
|
||||
.where((e) => e.value != null)
|
||||
.map((e) => MapEntry(e.key, e.value!)),
|
||||
);
|
||||
}
|
||||
|
||||
extension NavigationExtensions on BuildContext {
|
||||
void goNamedAuth(
|
||||
String name,
|
||||
bool mounted, {
|
||||
Map<String, String> pathParameters = const <String, String>{},
|
||||
Map<String, String> queryParameters = const <String, String>{},
|
||||
Object? extra,
|
||||
bool ignoreRedirect = false,
|
||||
}) =>
|
||||
!mounted || GoRouter.of(this).shouldRedirect(ignoreRedirect)
|
||||
? null
|
||||
: goNamed(
|
||||
name,
|
||||
pathParameters: pathParameters,
|
||||
queryParameters: queryParameters,
|
||||
extra: extra,
|
||||
);
|
||||
|
||||
void pushNamedAuth(
|
||||
String name,
|
||||
bool mounted, {
|
||||
Map<String, String> pathParameters = const <String, String>{},
|
||||
Map<String, String> queryParameters = const <String, String>{},
|
||||
Object? extra,
|
||||
bool ignoreRedirect = false,
|
||||
}) =>
|
||||
!mounted || GoRouter.of(this).shouldRedirect(ignoreRedirect)
|
||||
? null
|
||||
: pushNamed(
|
||||
name,
|
||||
pathParameters: pathParameters,
|
||||
queryParameters: queryParameters,
|
||||
extra: extra,
|
||||
);
|
||||
|
||||
void safePop() {
|
||||
// If there is only one route on the stack, navigate to the initial
|
||||
// page instead of popping.
|
||||
if (canPop()) {
|
||||
pop();
|
||||
} else {
|
||||
go('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GoRouterExtensions on GoRouter {
|
||||
AppStateNotifier get appState => AppStateNotifier.instance;
|
||||
void prepareAuthEvent([bool ignoreRedirect = false]) =>
|
||||
appState.hasRedirect() && !ignoreRedirect
|
||||
? null
|
||||
: appState.updateNotifyOnAuthChange(false);
|
||||
bool shouldRedirect(bool ignoreRedirect) =>
|
||||
!ignoreRedirect && appState.hasRedirect();
|
||||
void clearRedirectLocation() => appState.clearRedirectLocation();
|
||||
void setRedirectLocationIfUnset(String location) =>
|
||||
appState.updateNotifyOnAuthChange(false);
|
||||
}
|
||||
|
||||
extension _GoRouterStateExtensions on GoRouterState {
|
||||
Map<String, dynamic> get extraMap =>
|
||||
extra != null ? extra as Map<String, dynamic> : {};
|
||||
Map<String, dynamic> get allParams => <String, dynamic>{}
|
||||
..addAll(pathParameters)
|
||||
..addAll(queryParameters)
|
||||
..addAll(extraMap);
|
||||
TransitionInfo get transitionInfo => extraMap.containsKey(kTransitionInfoKey)
|
||||
? extraMap[kTransitionInfoKey] as TransitionInfo
|
||||
: TransitionInfo.appDefault();
|
||||
}
|
||||
|
||||
class FFParameters {
|
||||
FFParameters(this.state, [this.asyncParams = const {}]);
|
||||
|
||||
final GoRouterState state;
|
||||
final Map<String, Future<dynamic> Function(String)> asyncParams;
|
||||
|
||||
Map<String, dynamic> futureParamValues = {};
|
||||
|
||||
// Parameters are empty if the params map is empty or if the only parameter
|
||||
// present is the special extra parameter reserved for the transition info.
|
||||
bool get isEmpty =>
|
||||
state.allParams.isEmpty ||
|
||||
(state.extraMap.length == 1 &&
|
||||
state.extraMap.containsKey(kTransitionInfoKey));
|
||||
bool isAsyncParam(MapEntry<String, dynamic> param) =>
|
||||
asyncParams.containsKey(param.key) && param.value is String;
|
||||
bool get hasFutures => state.allParams.entries.any(isAsyncParam);
|
||||
Future<bool> completeFutures() => Future.wait(
|
||||
state.allParams.entries.where(isAsyncParam).map(
|
||||
(param) async {
|
||||
final doc = await asyncParams[param.key]!(param.value)
|
||||
.onError((_, __) => null);
|
||||
if (doc != null) {
|
||||
futureParamValues[param.key] = doc;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
),
|
||||
).onError((_, __) => [false]).then((v) => v.every((e) => e));
|
||||
|
||||
dynamic getParam<T>(
|
||||
String paramName,
|
||||
ParamType type, [
|
||||
bool isList = false,
|
||||
]) {
|
||||
if (futureParamValues.containsKey(paramName)) {
|
||||
return futureParamValues[paramName];
|
||||
}
|
||||
if (!state.allParams.containsKey(paramName)) {
|
||||
return null;
|
||||
}
|
||||
final param = state.allParams[paramName];
|
||||
// Got parameter from `extras`, so just directly return it.
|
||||
if (param is! String) {
|
||||
return param;
|
||||
}
|
||||
// Return serialized value.
|
||||
return deserializeParam<T>(
|
||||
param,
|
||||
type,
|
||||
isList,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FFRoute {
|
||||
const FFRoute({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.builder,
|
||||
this.requireAuth = false,
|
||||
this.asyncParams = const {},
|
||||
this.routes = const [],
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final bool requireAuth;
|
||||
final Map<String, Future<dynamic> Function(String)> asyncParams;
|
||||
final Widget Function(BuildContext, FFParameters) builder;
|
||||
final List<GoRoute> routes;
|
||||
|
||||
GoRoute toRoute(AppStateNotifier appStateNotifier) => GoRoute(
|
||||
name: name,
|
||||
path: path,
|
||||
redirect: (context, state) {
|
||||
if (appStateNotifier.shouldRedirect) {
|
||||
final redirectLocation = appStateNotifier.getRedirectLocation();
|
||||
appStateNotifier.clearRedirectLocation();
|
||||
return redirectLocation;
|
||||
}
|
||||
|
||||
if (requireAuth && !appStateNotifier.loggedIn) {
|
||||
appStateNotifier.setRedirectLocationIfUnset(state.location);
|
||||
return '/login';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final ffParams = FFParameters(state, asyncParams);
|
||||
final page = ffParams.hasFutures
|
||||
? FutureBuilder(
|
||||
future: ffParams.completeFutures(),
|
||||
builder: (context, _) => builder(context, ffParams),
|
||||
)
|
||||
: builder(context, ffParams);
|
||||
final child = appStateNotifier.loading
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 50.0,
|
||||
height: 50.0,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
FlutterTheme.of(context).info,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: page;
|
||||
|
||||
final transitionInfo = state.transitionInfo;
|
||||
return transitionInfo.hasTransition
|
||||
? CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionDuration: transitionInfo.duration,
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) =>
|
||||
PageTransition(
|
||||
type: transitionInfo.transitionType,
|
||||
duration: transitionInfo.duration,
|
||||
reverseDuration: transitionInfo.duration,
|
||||
alignment: transitionInfo.alignment,
|
||||
child: child,
|
||||
).buildTransitions(
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
child,
|
||||
),
|
||||
)
|
||||
: MaterialPage(key: state.pageKey, child: child);
|
||||
},
|
||||
routes: routes,
|
||||
);
|
||||
}
|
||||
|
||||
class TransitionInfo {
|
||||
const TransitionInfo({
|
||||
required this.hasTransition,
|
||||
this.transitionType = PageTransitionType.fade,
|
||||
this.duration = const Duration(milliseconds: 300),
|
||||
this.alignment,
|
||||
});
|
||||
|
||||
final bool hasTransition;
|
||||
final PageTransitionType transitionType;
|
||||
final Duration duration;
|
||||
final Alignment? alignment;
|
||||
|
||||
static TransitionInfo appDefault() => TransitionInfo(hasTransition: false);
|
||||
}
|
||||
|
||||
class RootPageContext {
|
||||
const RootPageContext(this.isRootPage, [this.errorRoute]);
|
||||
final bool isRootPage;
|
||||
final String? errorRoute;
|
||||
|
||||
static bool isInactiveRootPage(BuildContext context) {
|
||||
final rootPageContext = context.read<RootPageContext?>();
|
||||
final isRootPage = rootPageContext?.isRootPage ?? false;
|
||||
final location = GoRouter.of(context).location;
|
||||
return isRootPage &&
|
||||
location != '/' &&
|
||||
location != rootPageContext?.errorRoute;
|
||||
}
|
||||
|
||||
static Widget wrap(Widget child, {String? errorRoute}) => Provider.value(
|
||||
value: RootPageContext(true, errorRoute),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
213
lib/flutterlib/nav/serialization_util.dart
Normal file
213
lib/flutterlib/nav/serialization_util.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:from_css_color/from_css_color.dart';
|
||||
|
||||
import '/backend/schema/enums/enums.dart';
|
||||
|
||||
import '../../flutterlib/lat_lng.dart';
|
||||
import '../../flutterlib/place.dart';
|
||||
import '../../flutterlib/uploaded_file.dart';
|
||||
|
||||
/// SERIALIZATION HELPERS
|
||||
|
||||
String dateTimeRangeToString(DateTimeRange dateTimeRange) {
|
||||
final startStr = dateTimeRange.start.millisecondsSinceEpoch.toString();
|
||||
final endStr = dateTimeRange.end.millisecondsSinceEpoch.toString();
|
||||
return '$startStr|$endStr';
|
||||
}
|
||||
|
||||
String placeToString(FFPlace place) => jsonEncode({
|
||||
'latLng': place.latLng.serialize(),
|
||||
'name': place.name,
|
||||
'address': place.address,
|
||||
'city': place.city,
|
||||
'state': place.state,
|
||||
'country': place.country,
|
||||
'zipCode': place.zipCode,
|
||||
});
|
||||
|
||||
String uploadedFileToString(FFUploadedFile uploadedFile) =>
|
||||
uploadedFile.serialize();
|
||||
|
||||
String? serializeParam(
|
||||
dynamic param,
|
||||
ParamType paramType, [
|
||||
bool isList = false,
|
||||
]) {
|
||||
try {
|
||||
if (param == null) {
|
||||
return null;
|
||||
}
|
||||
if (isList) {
|
||||
final serializedValues = (param as Iterable)
|
||||
.map((p) => serializeParam(p, paramType, false))
|
||||
.where((p) => p != null)
|
||||
.map((p) => p!)
|
||||
.toList();
|
||||
return json.encode(serializedValues);
|
||||
}
|
||||
switch (paramType) {
|
||||
case ParamType.int:
|
||||
return param.toString();
|
||||
case ParamType.double:
|
||||
return param.toString();
|
||||
case ParamType.String:
|
||||
return param;
|
||||
case ParamType.bool:
|
||||
return param ? 'true' : 'false';
|
||||
case ParamType.DateTime:
|
||||
return (param as DateTime).millisecondsSinceEpoch.toString();
|
||||
case ParamType.DateTimeRange:
|
||||
return dateTimeRangeToString(param as DateTimeRange);
|
||||
case ParamType.LatLng:
|
||||
return (param as LatLng).serialize();
|
||||
case ParamType.Color:
|
||||
return (param as Color).toCssString();
|
||||
case ParamType.FFPlace:
|
||||
return placeToString(param as FFPlace);
|
||||
case ParamType.FFUploadedFile:
|
||||
return uploadedFileToString(param as FFUploadedFile);
|
||||
case ParamType.JSON:
|
||||
return json.encode(param);
|
||||
|
||||
case ParamType.Enum:
|
||||
return (param is Enum) ? param.serialize() : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error serializing parameter: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// END SERIALIZATION HELPERS
|
||||
|
||||
/// DESERIALIZATION HELPERS
|
||||
|
||||
DateTimeRange? dateTimeRangeFromString(String dateTimeRangeStr) {
|
||||
final pieces = dateTimeRangeStr.split('|');
|
||||
if (pieces.length != 2) {
|
||||
return null;
|
||||
}
|
||||
return DateTimeRange(
|
||||
start: DateTime.fromMillisecondsSinceEpoch(int.parse(pieces.first)),
|
||||
end: DateTime.fromMillisecondsSinceEpoch(int.parse(pieces.last)),
|
||||
);
|
||||
}
|
||||
|
||||
LatLng? latLngFromString(String latLngStr) {
|
||||
final pieces = latLngStr.split(',');
|
||||
if (pieces.length != 2) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(
|
||||
double.parse(pieces.first.trim()),
|
||||
double.parse(pieces.last.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
FFPlace placeFromString(String placeStr) {
|
||||
final serializedData = jsonDecode(placeStr) as Map<String, dynamic>;
|
||||
final data = {
|
||||
'latLng': serializedData.containsKey('latLng')
|
||||
? latLngFromString(serializedData['latLng'] as String)
|
||||
: const LatLng(0.0, 0.0),
|
||||
'name': serializedData['name'] ?? '',
|
||||
'address': serializedData['address'] ?? '',
|
||||
'city': serializedData['city'] ?? '',
|
||||
'state': serializedData['state'] ?? '',
|
||||
'country': serializedData['country'] ?? '',
|
||||
'zipCode': serializedData['zipCode'] ?? '',
|
||||
};
|
||||
return FFPlace(
|
||||
latLng: data['latLng'] as LatLng,
|
||||
name: data['name'] as String,
|
||||
address: data['address'] as String,
|
||||
city: data['city'] as String,
|
||||
state: data['state'] as String,
|
||||
country: data['country'] as String,
|
||||
zipCode: data['zipCode'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
FFUploadedFile uploadedFileFromString(String uploadedFileStr) =>
|
||||
FFUploadedFile.deserialize(uploadedFileStr);
|
||||
|
||||
enum ParamType {
|
||||
int,
|
||||
double,
|
||||
String,
|
||||
bool,
|
||||
DateTime,
|
||||
DateTimeRange,
|
||||
LatLng,
|
||||
Color,
|
||||
FFPlace,
|
||||
FFUploadedFile,
|
||||
JSON,
|
||||
Enum,
|
||||
}
|
||||
|
||||
dynamic deserializeParam<T>(
|
||||
String? param,
|
||||
ParamType paramType,
|
||||
bool isList,
|
||||
) {
|
||||
try {
|
||||
if (param == null) {
|
||||
return null;
|
||||
}
|
||||
if (isList) {
|
||||
final paramValues = json.decode(param);
|
||||
if (paramValues is! Iterable || paramValues.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return paramValues
|
||||
.where((p) => p is String)
|
||||
.map((p) => p as String)
|
||||
.map((p) => deserializeParam<T>(p, paramType, false))
|
||||
.where((p) => p != null)
|
||||
.map((p) => p! as T)
|
||||
.toList();
|
||||
}
|
||||
switch (paramType) {
|
||||
case ParamType.int:
|
||||
return int.tryParse(param);
|
||||
case ParamType.double:
|
||||
return double.tryParse(param);
|
||||
case ParamType.String:
|
||||
return param;
|
||||
case ParamType.bool:
|
||||
return param == 'true';
|
||||
case ParamType.DateTime:
|
||||
final milliseconds = int.tryParse(param);
|
||||
return milliseconds != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(milliseconds)
|
||||
: null;
|
||||
case ParamType.DateTimeRange:
|
||||
return dateTimeRangeFromString(param);
|
||||
case ParamType.LatLng:
|
||||
return latLngFromString(param);
|
||||
case ParamType.Color:
|
||||
return fromCssColor(param);
|
||||
case ParamType.FFPlace:
|
||||
return placeFromString(param);
|
||||
case ParamType.FFUploadedFile:
|
||||
return uploadedFileFromString(param);
|
||||
case ParamType.JSON:
|
||||
return json.decode(param);
|
||||
|
||||
case ParamType.Enum:
|
||||
return deserializeEnum<T>(param);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error deserializing parameter: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
46
lib/flutterlib/place.dart
Normal file
46
lib/flutterlib/place.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'lat_lng.dart';
|
||||
|
||||
class FFPlace {
|
||||
const FFPlace({
|
||||
this.latLng = const LatLng(0.0, 0.0),
|
||||
this.name = '',
|
||||
this.address = '',
|
||||
this.city = '',
|
||||
this.state = '',
|
||||
this.country = '',
|
||||
this.zipCode = '',
|
||||
});
|
||||
|
||||
final LatLng latLng;
|
||||
final String name;
|
||||
final String address;
|
||||
final String city;
|
||||
final String state;
|
||||
final String country;
|
||||
final String zipCode;
|
||||
|
||||
@override
|
||||
String toString() => '''FFPlace(
|
||||
latLng: $latLng,
|
||||
name: $name,
|
||||
address: $address,
|
||||
city: $city,
|
||||
state: $state,
|
||||
country: $country,
|
||||
zipCode: $zipCode,
|
||||
)''';
|
||||
|
||||
@override
|
||||
int get hashCode => latLng.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(other) =>
|
||||
other is FFPlace &&
|
||||
latLng == other.latLng &&
|
||||
name == other.name &&
|
||||
address == other.address &&
|
||||
city == other.city &&
|
||||
state == other.state &&
|
||||
country == other.country &&
|
||||
zipCode == other.zipCode;
|
||||
}
|
||||
378
lib/flutterlib/upload_data.dart
Normal file
378
lib/flutterlib/upload_data.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mime_type/mime_type.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import 'flutter_theme.dart';
|
||||
import 'flutter_util.dart';
|
||||
|
||||
const allowedFormats = {'image/png', 'image/jpeg', 'video/mp4', 'image/gif'};
|
||||
|
||||
class SelectedFile {
|
||||
const SelectedFile({
|
||||
this.storagePath = '',
|
||||
this.filePath,
|
||||
required this.bytes,
|
||||
this.dimensions,
|
||||
this.blurHash,
|
||||
});
|
||||
final String storagePath;
|
||||
final String? filePath;
|
||||
final Uint8List bytes;
|
||||
final MediaDimensions? dimensions;
|
||||
final String? blurHash;
|
||||
}
|
||||
|
||||
class MediaDimensions {
|
||||
const MediaDimensions({
|
||||
this.height,
|
||||
this.width,
|
||||
});
|
||||
final double? height;
|
||||
final double? width;
|
||||
}
|
||||
|
||||
enum MediaSource {
|
||||
photoGallery,
|
||||
videoGallery,
|
||||
camera,
|
||||
}
|
||||
|
||||
Future<List<SelectedFile>?> selectMediaWithSourceBottomSheet({
|
||||
required BuildContext context,
|
||||
String? storageFolderPath,
|
||||
double? maxWidth,
|
||||
double? maxHeight,
|
||||
int? imageQuality,
|
||||
required bool allowPhoto,
|
||||
bool allowVideo = false,
|
||||
String pickerFontFamily = 'Roboto',
|
||||
Color textColor = const Color(0xFF111417),
|
||||
Color backgroundColor = const Color(0xFFF5F5F5),
|
||||
bool includeDimensions = false,
|
||||
bool includeBlurHash = false,
|
||||
}) async {
|
||||
final createUploadMediaListTile =
|
||||
(String label, MediaSource mediaSource) => ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.getFont(
|
||||
pickerFontFamily,
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
tileColor: backgroundColor,
|
||||
dense: false,
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
mediaSource,
|
||||
),
|
||||
);
|
||||
final mediaSource = await showModalBottomSheet<MediaSource>(
|
||||
context: context,
|
||||
backgroundColor: backgroundColor,
|
||||
builder: (context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!kIsWeb) ...[
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'Choose Source',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.getFont(
|
||||
pickerFontFamily,
|
||||
color: textColor.withOpacity(0.65),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
tileColor: backgroundColor,
|
||||
dense: false,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
if (allowPhoto && allowVideo) ...[
|
||||
createUploadMediaListTile(
|
||||
'Gallery (Photo)',
|
||||
MediaSource.photoGallery,
|
||||
),
|
||||
const Divider(),
|
||||
createUploadMediaListTile(
|
||||
'Gallery (Video)',
|
||||
MediaSource.videoGallery,
|
||||
),
|
||||
] else if (allowPhoto)
|
||||
createUploadMediaListTile(
|
||||
'Gallery',
|
||||
MediaSource.photoGallery,
|
||||
)
|
||||
else
|
||||
createUploadMediaListTile(
|
||||
'Gallery',
|
||||
MediaSource.videoGallery,
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
const Divider(),
|
||||
createUploadMediaListTile('Camera', MediaSource.camera),
|
||||
const Divider(),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
);
|
||||
});
|
||||
if (mediaSource == null) {
|
||||
return null;
|
||||
}
|
||||
return selectMedia(
|
||||
storageFolderPath: storageFolderPath,
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
imageQuality: imageQuality,
|
||||
isVideo: mediaSource == MediaSource.videoGallery ||
|
||||
(mediaSource == MediaSource.camera && allowVideo && !allowPhoto),
|
||||
mediaSource: mediaSource,
|
||||
includeDimensions: includeDimensions,
|
||||
includeBlurHash: includeBlurHash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SelectedFile>?> selectMedia({
|
||||
String? storageFolderPath,
|
||||
double? maxWidth,
|
||||
double? maxHeight,
|
||||
int? imageQuality,
|
||||
bool isVideo = false,
|
||||
MediaSource mediaSource = MediaSource.camera,
|
||||
bool multiImage = false,
|
||||
bool includeDimensions = false,
|
||||
bool includeBlurHash = false,
|
||||
}) async {
|
||||
final picker = ImagePicker();
|
||||
|
||||
if (multiImage) {
|
||||
final pickedMediaFuture = picker.pickMultiImage(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
imageQuality: imageQuality,
|
||||
);
|
||||
final pickedMedia = await pickedMediaFuture;
|
||||
if (pickedMedia.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Future.wait(pickedMedia.asMap().entries.map((e) async {
|
||||
final index = e.key;
|
||||
final media = e.value;
|
||||
final mediaBytes = await media.readAsBytes();
|
||||
final path = _getStoragePath(storageFolderPath, media.name, false, index);
|
||||
final dimensions = includeDimensions
|
||||
? isVideo
|
||||
? _getVideoDimensions(media.path)
|
||||
: _getImageDimensions(mediaBytes)
|
||||
: null;
|
||||
|
||||
return SelectedFile(
|
||||
storagePath: path,
|
||||
filePath: media.path,
|
||||
bytes: mediaBytes,
|
||||
dimensions: await dimensions,
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
final source = mediaSource == MediaSource.camera
|
||||
? ImageSource.camera
|
||||
: ImageSource.gallery;
|
||||
final pickedMediaFuture = isVideo
|
||||
? picker.pickVideo(source: source)
|
||||
: picker.pickImage(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
imageQuality: imageQuality,
|
||||
source: source,
|
||||
);
|
||||
final pickedMedia = await pickedMediaFuture;
|
||||
final mediaBytes = await pickedMedia?.readAsBytes();
|
||||
if (mediaBytes == null) {
|
||||
return null;
|
||||
}
|
||||
final path = _getStoragePath(storageFolderPath, pickedMedia!.name, isVideo);
|
||||
final dimensions = includeDimensions
|
||||
? isVideo
|
||||
? _getVideoDimensions(pickedMedia.path)
|
||||
: _getImageDimensions(mediaBytes)
|
||||
: null;
|
||||
|
||||
return [
|
||||
SelectedFile(
|
||||
storagePath: path,
|
||||
filePath: pickedMedia.path,
|
||||
bytes: mediaBytes,
|
||||
dimensions: await dimensions,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
bool validateFileFormat(String filePath, BuildContext context) {
|
||||
if (allowedFormats.contains(mime(filePath))) {
|
||||
return true;
|
||||
}
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text('Invalid file format: ${mime(filePath)}'),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<SelectedFile?> selectFile({
|
||||
String? storageFolderPath,
|
||||
List<String>? allowedExtensions,
|
||||
}) =>
|
||||
selectFiles(
|
||||
storageFolderPath: storageFolderPath,
|
||||
allowedExtensions: allowedExtensions,
|
||||
multiFile: false,
|
||||
).then((value) => value?.first);
|
||||
|
||||
Future<List<SelectedFile>?> selectFiles({
|
||||
String? storageFolderPath,
|
||||
List<String>? allowedExtensions,
|
||||
bool multiFile = false,
|
||||
}) async {
|
||||
final pickedFiles = await FilePicker.platform.pickFiles(
|
||||
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
||||
allowedExtensions: allowedExtensions,
|
||||
withData: true,
|
||||
allowMultiple: multiFile,
|
||||
);
|
||||
if (pickedFiles == null || pickedFiles.files.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (multiFile) {
|
||||
return Future.wait(pickedFiles.files.asMap().entries.map((e) async {
|
||||
final index = e.key;
|
||||
final file = e.value;
|
||||
final storagePath =
|
||||
_getStoragePath(storageFolderPath, file.name, false, index);
|
||||
return SelectedFile(
|
||||
storagePath: storagePath,
|
||||
filePath: isWeb ? null : file.path,
|
||||
bytes: file.bytes!,
|
||||
);
|
||||
}));
|
||||
}
|
||||
final file = pickedFiles.files.first;
|
||||
if (file.bytes == null) {
|
||||
return null;
|
||||
}
|
||||
final storagePath = _getStoragePath(storageFolderPath, file.name, false);
|
||||
return [
|
||||
SelectedFile(
|
||||
storagePath: storagePath,
|
||||
filePath: isWeb ? null : file.path,
|
||||
bytes: file.bytes!,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
List<SelectedFile> selectedFilesFromUploadedFiles(
|
||||
List<FFUploadedFile> uploadedFiles, {
|
||||
String? storageFolderPath,
|
||||
bool isMultiData = false,
|
||||
}) =>
|
||||
uploadedFiles.asMap().entries.map(
|
||||
(entry) {
|
||||
final index = entry.key;
|
||||
final file = entry.value;
|
||||
return SelectedFile(
|
||||
storagePath: _getStoragePath(
|
||||
storageFolderPath != null ? storageFolderPath : null,
|
||||
file.name!,
|
||||
false,
|
||||
isMultiData ? index : null,
|
||||
),
|
||||
bytes: file.bytes!);
|
||||
},
|
||||
).toList();
|
||||
|
||||
Future<MediaDimensions> _getImageDimensions(Uint8List mediaBytes) async {
|
||||
final image = await decodeImageFromList(mediaBytes);
|
||||
return MediaDimensions(
|
||||
width: image.width.toDouble(),
|
||||
height: image.height.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<MediaDimensions> _getVideoDimensions(String path) async {
|
||||
final VideoPlayerController videoPlayerController =
|
||||
VideoPlayerController.asset(path);
|
||||
await videoPlayerController.initialize();
|
||||
final size = videoPlayerController.value.size;
|
||||
return MediaDimensions(width: size.width, height: size.height);
|
||||
}
|
||||
|
||||
String _getStoragePath(
|
||||
String? pathPrefix,
|
||||
String filePath,
|
||||
bool isVideo, [
|
||||
int? index,
|
||||
]) {
|
||||
pathPrefix = _removeTrailingSlash(pathPrefix);
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
// Workaround fixed by https://github.com/flutter/plugins/pull/3685
|
||||
// (not yet in stable).
|
||||
final ext = isVideo ? 'mp4' : filePath.split('.').last;
|
||||
final indexStr = index != null ? '_$index' : '';
|
||||
return '$pathPrefix/$timestamp$indexStr.$ext';
|
||||
}
|
||||
|
||||
String getSignatureStoragePath([String? pathPrefix]) {
|
||||
pathPrefix = _removeTrailingSlash(pathPrefix);
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
return '$pathPrefix/signature_$timestamp.png';
|
||||
}
|
||||
|
||||
void showUploadMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
bool showLoading = false,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (showLoading)
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 10.0),
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: Theme.of(context).brightness == Brightness.dark
|
||||
? AlwaysStoppedAnimation<Color>(
|
||||
FlutterTheme.of(context).accent4)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
duration: showLoading ? Duration(days: 1) : Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _removeTrailingSlash(String? path) => path != null && path.endsWith('/')
|
||||
? path.substring(0, path.length - 1)
|
||||
: path;
|
||||
68
lib/flutterlib/uploaded_file.dart
Normal file
68
lib/flutterlib/uploaded_file.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
|
||||
class FFUploadedFile {
|
||||
const FFUploadedFile({
|
||||
this.name,
|
||||
this.bytes,
|
||||
this.height,
|
||||
this.width,
|
||||
this.blurHash,
|
||||
});
|
||||
|
||||
final String? name;
|
||||
final Uint8List? bytes;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final String? blurHash;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'FFUploadedFile(name: $name, bytes: ${bytes?.length ?? 0}, height: $height, width: $width, blurHash: $blurHash,)';
|
||||
|
||||
String serialize() => jsonEncode(
|
||||
{
|
||||
'name': name,
|
||||
'bytes': bytes,
|
||||
'height': height,
|
||||
'width': width,
|
||||
'blurHash': blurHash,
|
||||
},
|
||||
);
|
||||
|
||||
static FFUploadedFile deserialize(String val) {
|
||||
final serializedData = jsonDecode(val) as Map<String, dynamic>;
|
||||
final data = {
|
||||
'name': serializedData['name'] ?? '',
|
||||
'bytes': serializedData['bytes'] ?? Uint8List.fromList([]),
|
||||
'height': serializedData['height'],
|
||||
'width': serializedData['width'],
|
||||
'blurHash': serializedData['blurHash'],
|
||||
};
|
||||
return FFUploadedFile(
|
||||
name: data['name'] as String,
|
||||
bytes: Uint8List.fromList(data['bytes'].cast<int>().toList()),
|
||||
height: data['height'] as double?,
|
||||
width: data['width'] as double?,
|
||||
blurHash: data['blurHash'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
name,
|
||||
bytes,
|
||||
height,
|
||||
width,
|
||||
blurHash,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(other) =>
|
||||
other is FFUploadedFile &&
|
||||
name == other.name &&
|
||||
bytes == other.bytes &&
|
||||
height == other.height &&
|
||||
width == other.width &&
|
||||
blurHash == other.blurHash;
|
||||
}
|
||||
Reference in New Issue
Block a user