2026-04-11
This commit is contained in:
commit
27e27fb70b
270 files changed
+30296
No files matched your search
@@ -0,0 +1,12 @@
|
||||
class AppAssets {
|
||||
static const String taxgildelogo = 'assets/images/taxglidelogo.png';
|
||||
static const String taxgildelogoauth = 'assets/images/taxglidelogoauth.png';
|
||||
static const String homepageheader = 'assets/images/haeder_image.jpg';
|
||||
static const String profilebanner = 'assets/images/profilebanner.jpg';
|
||||
static const String backgroundimages = 'assets/images/backgroundimages.png';
|
||||
static const String back = "assets/images/homepagebackground.jpg";
|
||||
static const String image = "assets/images/home.png";
|
||||
static const String generalchatimage = "assets/images/generalchatimage.png";
|
||||
static const String maincontroller = "assets/images/taxglidewhitelogo.png";
|
||||
static const String generalchatsvg = "assets/images/backgroudimagesvg.svg";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:ui';
|
||||
|
||||
class AppColors {
|
||||
static const Color backgroundgrainetflashtop = Color(0xFFEBC894);
|
||||
static const Color backgroundgrainetflashbottom = Color(0xFFB49EF4);
|
||||
static const Color authheading = Color(0xFF111827);
|
||||
static const Color authleading = Color(0xFF6C7278);
|
||||
static const Color authtermsandcondition = Color(0xFFC9861D);
|
||||
static const Color authsignup = Color(0xFF4DA526);
|
||||
static const Color authchange = Color(0xFFFF0004);
|
||||
static const Color commanbutton = Color(0xFF5F297B);
|
||||
static const Color homepagekycdetails = Color(0xFF239F5B);
|
||||
static const Color homepagekycdetailsorange = Color(0xFFF68945);
|
||||
static const Color homepageserivcetext = Color(0xFF3F3F3F);
|
||||
static const Color black = Color(0xFF000000);
|
||||
static const Color white = Color(0xFFFFFFFF);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:taxglide/consts/app_colors.dart';
|
||||
|
||||
class CommanButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
final String? backgroundImage; // optional bg image
|
||||
final Widget? prefixIcon; // optional prefix icon
|
||||
|
||||
const CommanButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.backgroundImage,
|
||||
this.prefixIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 57,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppColors.commanbutton, // base color (visible always)
|
||||
|
||||
image: backgroundImage != null
|
||||
? DecorationImage(
|
||||
image: AssetImage(backgroundImage!),
|
||||
fit: BoxFit.cover,
|
||||
|
||||
// 🔥 THIS MAKES BOTH IMAGE + COLOR VISIBLE
|
||||
colorFilter: ColorFilter.mode(
|
||||
AppColors.commanbutton.withOpacity(0.5),
|
||||
BlendMode.srcATop,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (prefixIcon != null) ...[
|
||||
prefixIcon!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-Medium',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.03 * 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// ignore_for_file: use_super_parameters
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CommonContainerAuth extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const CommonContainerAuth({Key? key, required this.child}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(left: 16, right: 16),
|
||||
padding: const EdgeInsets.only(top: 31, right: 18, bottom: 31, left: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x99FFFFFF), // #FFFFFF with 60% opacity (99 in hex)
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:dropdown_textfield/dropdown_textfield.dart';
|
||||
|
||||
class CommanDropdown extends StatefulWidget {
|
||||
final List<String> items;
|
||||
final String? value;
|
||||
final Function(String?) onChanged;
|
||||
final String hint;
|
||||
final bool useTextFieldType;
|
||||
|
||||
const CommanDropdown({
|
||||
Key? key,
|
||||
required this.items,
|
||||
this.value,
|
||||
required this.onChanged,
|
||||
this.hint = 'Select an option',
|
||||
this.useTextFieldType = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CommanDropdown> createState() => _CommanDropdownState();
|
||||
}
|
||||
|
||||
class _CommanDropdownState extends State<CommanDropdown> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useTextFieldType) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: _boxDecoration(),
|
||||
child: DropDownTextField(
|
||||
textFieldDecoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 16),
|
||||
),
|
||||
clearOption: false,
|
||||
dropDownList: widget.items
|
||||
.map((e) => DropDownValueModel(name: e, value: e))
|
||||
.toList(),
|
||||
dropDownItemCount: widget.items.length > 6 ? 6 : widget.items.length,
|
||||
// dropdownHeight: 200, // Limit the dropdown height
|
||||
isEnabled: true,
|
||||
onChanged: (value) {
|
||||
widget.onChanged(value?.value);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: _boxDecoration(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<String>(
|
||||
value: widget.value,
|
||||
isExpanded: true,
|
||||
hint: Text(widget.hint, style: const TextStyle(color: Colors.grey)),
|
||||
items: widget.items
|
||||
.map(
|
||||
(item) => DropdownMenuItem(
|
||||
value: item,
|
||||
child: Text(item, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: widget.onChanged,
|
||||
buttonStyleData: const ButtonStyleData(height: 56),
|
||||
iconStyleData: const IconStyleData(
|
||||
icon: Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
dropdownStyleData: DropdownStyleData(
|
||||
maxHeight: 300, // Limit dropdown menu height
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 48, // Set individual item height
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxDecoration _boxDecoration() {
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFDFDFDF), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x40BDBDBD),
|
||||
blurRadius: 7,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CommanImageBox extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final String text;
|
||||
final IconData icon;
|
||||
|
||||
const CommanImageBox({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.text = 'Upload Image',
|
||||
this.icon = Icons.cloud_upload_outlined,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 117,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFCFCFCF), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 7,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 32, color: const Color(0xFF5F297B)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5F297B),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CommonInfoPopup {
|
||||
/// 🔹 Show a reusable popup dialog
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
}) {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-Bold',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: ScrollbarTheme(
|
||||
data: ScrollbarThemeData(
|
||||
thumbColor: WidgetStateProperty.all(
|
||||
const Color(0xFF6A4BFC),
|
||||
), // 💜 Custom scrollbar color
|
||||
trackColor: WidgetStateProperty.all(Colors.transparent),
|
||||
radius: const Radius.circular(8),
|
||||
thickness: WidgetStateProperty.all(4),
|
||||
),
|
||||
child: Scrollbar(
|
||||
controller: scrollController,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Text(
|
||||
content,
|
||||
textAlign: TextAlign.justify,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-Medium',
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.only(right: 10, bottom: 8),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text(
|
||||
"Close",
|
||||
style: TextStyle(
|
||||
fontFamily: 'Gilroy-SemiBold',
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6A4BFC),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:taxglide/consts/app_colors.dart';
|
||||
import 'package:taxglide/model/serivce_list_model.dart';
|
||||
import 'package:taxglide/view/Main_controller/main_controller.dart';
|
||||
import 'package:taxglide/view/screens/serivce_request_screen.dart';
|
||||
|
||||
class CommonServiceItem extends StatelessWidget {
|
||||
final ServiceListModel service;
|
||||
final int sourceTabIndex; // ✅ New param to know from where user clicked
|
||||
|
||||
const CommonServiceItem({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.sourceTabIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Get.offAll(
|
||||
() => MainController(
|
||||
child: ServiceRequestScreen(
|
||||
id: service.id,
|
||||
service: service.service,
|
||||
icon: service.icon,
|
||||
),
|
||||
initialIndex: sourceTabIndex, // 0 for Home, 1 for Services
|
||||
sourceTabIndex:
|
||||
sourceTabIndex, // to remember where it came from
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 110,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFE1E1E1), width: 1),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x66757575),
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 7.3,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Center(
|
||||
child: Image.network(
|
||||
service.icon,
|
||||
width: 42,
|
||||
height: 42,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
service.service,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-SemiBold',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
height: 1.3,
|
||||
letterSpacing: 0.02,
|
||||
color: AppColors.homepageserivcetext,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:taxglide/consts/app_colors.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CommanTextFormField extends StatelessWidget {
|
||||
final String? hintText;
|
||||
final TextEditingController? controller;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final bool readOnly;
|
||||
final String? Function(String?)? validator;
|
||||
final IconData? prefixIcon;
|
||||
final bool hasError;
|
||||
final Function(String)? onChanged;
|
||||
final FocusNode? focusNode;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// ✅ NEW
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final TextCapitalization textCapitalization;
|
||||
|
||||
const CommanTextFormField({
|
||||
Key? key,
|
||||
this.hintText,
|
||||
this.controller,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.readOnly = false,
|
||||
this.validator,
|
||||
this.prefixIcon,
|
||||
this.hasError = false,
|
||||
this.onChanged,
|
||||
this.focusNode,
|
||||
this.onTap,
|
||||
|
||||
/// ✅ DEFAULTS
|
||||
this.inputFormatters,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: TextFormField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
onTap: onTap,
|
||||
readOnly: readOnly,
|
||||
|
||||
/// ✅ APPLIED HERE
|
||||
inputFormatters: inputFormatters,
|
||||
textCapitalization: textCapitalization,
|
||||
|
||||
cursorColor: readOnly ? Colors.transparent : AppColors.authleading,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
filled: true,
|
||||
fillColor: readOnly ? const Color(0xFFF6F6F6) : Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: AppColors.authleading)
|
||||
: null,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Gilroy-Medium',
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 11.31,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.03,
|
||||
color: hasError
|
||||
? Colors.red.withOpacity(0.6)
|
||||
: Colors.grey.shade500,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: hasError
|
||||
? Colors.red
|
||||
: readOnly
|
||||
? Colors.grey.shade300
|
||||
: const Color(0xFFDFDFDF),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: hasError
|
||||
? Colors.red
|
||||
: readOnly
|
||||
? Colors.grey.shade300
|
||||
: const Color(0xFFDFDFDF),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: hasError
|
||||
? Colors.red
|
||||
: readOnly
|
||||
? Colors.grey.shade400
|
||||
: Colors.blue,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Gilroy-Medium',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11.31,
|
||||
height: 1.4,
|
||||
letterSpacing: 0.03,
|
||||
color: readOnly ? Colors.grey.shade600 : AppColors.authleading,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Common WebSocket for user-based communication (not chatId-based)
|
||||
class CommonWebSocketService {
|
||||
static final CommonWebSocketService _instance =
|
||||
CommonWebSocketService._internal();
|
||||
factory CommonWebSocketService() => _instance;
|
||||
CommonWebSocketService._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _pingTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _isIntentionalClose = false;
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||
|
||||
// Callbacks
|
||||
Function(Map<String, dynamic>)? onMessageReceived;
|
||||
Function()? onConnected;
|
||||
Function()? onDisconnected;
|
||||
Function(dynamic error)? onError;
|
||||
|
||||
String? _userId;
|
||||
String? _wsUrl;
|
||||
|
||||
/// Check connection status
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Current user ID
|
||||
String? get currentUserId => _userId;
|
||||
|
||||
/// Connect WS using LOCAL USER ID
|
||||
Future<void> connect({
|
||||
required String userId,
|
||||
String baseUrl = "wss://taxglide.amrithaa.net:443/reverb",
|
||||
String appKey = "2yj0lyzc9ylw2h03ts6i",
|
||||
}) async {
|
||||
try {
|
||||
await disconnect();
|
||||
|
||||
_userId = userId;
|
||||
_wsUrl = "$baseUrl/app/$appKey?protocol=7&client=js&version=1.0";
|
||||
_isIntentionalClose = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
await _establishConnection();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Connection error: $e");
|
||||
onError?.call(e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _establishConnection() async {
|
||||
try {
|
||||
debugPrint("🔌 Connecting to: $_wsUrl");
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDisconnection,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
debugPrint("✅ WebSocket connected");
|
||||
|
||||
_subscribeToUserChannel();
|
||||
_startPingTimer();
|
||||
onConnected?.call();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Failed to establish connection: $e");
|
||||
_isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming messages
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
debugPrint("📨 Received: $data");
|
||||
final Map<String, dynamic> message = json.decode(data);
|
||||
|
||||
if (message['event'] == 'pusher:ping') {
|
||||
_sendPong();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] == 'pusher:connection_established') {
|
||||
debugPrint("🔗 Pusher connection established");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] == 'pusher_internal:subscription_succeeded') {
|
||||
debugPrint("✅ User channel subscribed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] != null &&
|
||||
!message['event'].toString().startsWith('pusher')) {
|
||||
onMessageReceived?.call(message);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("❌ Error parsing message: $e");
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
debugPrint("❌ WebSocket error: $error");
|
||||
_isConnected = false;
|
||||
onError?.call(error);
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnection() {
|
||||
debugPrint("🔌 Disconnected");
|
||||
_isConnected = false;
|
||||
_stopPingTimer();
|
||||
onDisconnected?.call();
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to USER CHANNEL
|
||||
void _subscribeToUserChannel() {
|
||||
if (_userId == null) return;
|
||||
|
||||
final subscribeMessage = {
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'user-chat-notification.$_userId'},
|
||||
};
|
||||
|
||||
_sendMessage(subscribeMessage);
|
||||
debugPrint("📡 Subscribing to user.$_userId");
|
||||
}
|
||||
|
||||
void _sendPong() {
|
||||
final pong = {'event': 'pusher:pong', 'data': {}};
|
||||
_sendMessage(pong);
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isConnected) debugPrint("💓 WS heartbeat");
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_isIntentionalClose) return;
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
onError?.call(
|
||||
"Failed to reconnect after $_maxReconnectAttempts attempts",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts++;
|
||||
|
||||
debugPrint(
|
||||
"🔄 Retry $_reconnectAttempts in ${_reconnectDelay.inSeconds}s...",
|
||||
);
|
||||
|
||||
_reconnectTimer = Timer(_reconnectDelay, () async {
|
||||
if (!_isIntentionalClose && _wsUrl != null && _userId != null) {
|
||||
await _establishConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send message
|
||||
void _sendMessage(Map<String, dynamic> message) {
|
||||
if (!_isConnected || _channel == null) {
|
||||
debugPrint("⚠️ Cannot send: Not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonMsg = json.encode(message);
|
||||
_channel!.sink.add(jsonMsg);
|
||||
debugPrint("📤 Sent: $jsonMsg");
|
||||
} catch (e) {
|
||||
debugPrint("❌ Send error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send event to backend
|
||||
void sendEvent({
|
||||
required String eventName,
|
||||
required Map<String, dynamic> data,
|
||||
}) {
|
||||
final msg = {
|
||||
'event': eventName,
|
||||
'data': data,
|
||||
'channel': 'user-chat-notification.$_userId',
|
||||
};
|
||||
_sendMessage(msg);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isIntentionalClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_stopPingTimer();
|
||||
|
||||
await _subscription?.cancel();
|
||||
await _channel?.sink.close();
|
||||
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_isConnected = false;
|
||||
_userId = null;
|
||||
|
||||
debugPrint("🔌 WS disconnected intentionally");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Common WebSocket for user-based communication (not chatId-based)
|
||||
class DetailsWebscokect {
|
||||
static final DetailsWebscokect _instance =
|
||||
DetailsWebscokect._internal();
|
||||
factory DetailsWebscokect() => _instance;
|
||||
DetailsWebscokect._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _pingTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _isIntentionalClose = false;
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||
|
||||
// Callbacks
|
||||
Function(Map<String, dynamic>)? onMessageReceived;
|
||||
Function()? onConnected;
|
||||
Function()? onDisconnected;
|
||||
Function(dynamic error)? onError;
|
||||
|
||||
String? _userId;
|
||||
String? _wsUrl;
|
||||
|
||||
/// Check connection status
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Current user ID
|
||||
String? get currentUserId => _userId;
|
||||
|
||||
/// Connect WS using LOCAL USER ID
|
||||
Future<void> connect({
|
||||
required String userId,
|
||||
String baseUrl = "wss://taxglide.amrithaa.net:443/reverb",
|
||||
String appKey = "2yj0lyzc9ylw2h03ts6i",
|
||||
}) async {
|
||||
try {
|
||||
await disconnect();
|
||||
|
||||
_userId = userId;
|
||||
_wsUrl = "$baseUrl/app/$appKey?protocol=7&client=js&version=1.0";
|
||||
_isIntentionalClose = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
await _establishConnection();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Connection error: $e");
|
||||
onError?.call(e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _establishConnection() async {
|
||||
try {
|
||||
debugPrint("🔌 Connecting to: $_wsUrl");
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDisconnection,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
debugPrint("✅ WebSocket connected");
|
||||
|
||||
_subscribeToUserChannel();
|
||||
_startPingTimer();
|
||||
onConnected?.call();
|
||||
} catch (e) {
|
||||
debugPrint("❌ Failed to establish connection: $e");
|
||||
_isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming messages
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
debugPrint("📨 Received: $data");
|
||||
final Map<String, dynamic> message = json.decode(data);
|
||||
|
||||
if (message['event'] == 'pusher:ping') {
|
||||
_sendPong();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] == 'pusher:connection_established') {
|
||||
debugPrint("🔗 Pusher connection established");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] == 'pusher_internal:subscription_succeeded') {
|
||||
debugPrint("✅ User channel subscribed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['event'] != null &&
|
||||
!message['event'].toString().startsWith('pusher')) {
|
||||
onMessageReceived?.call(message);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("❌ Error parsing message: $e");
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
debugPrint("❌ WebSocket error: $error");
|
||||
_isConnected = false;
|
||||
onError?.call(error);
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnection() {
|
||||
debugPrint("🔌 Disconnected");
|
||||
_isConnected = false;
|
||||
_stopPingTimer();
|
||||
onDisconnected?.call();
|
||||
|
||||
if (!_isIntentionalClose) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to USER CHANNEL
|
||||
void _subscribeToUserChannel() {
|
||||
if (_userId == null) return;
|
||||
|
||||
final subscribeMessage = {
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'user-chat-notification.$_userId'},
|
||||
};
|
||||
|
||||
_sendMessage(subscribeMessage);
|
||||
debugPrint("📡 Subscribing to user.$_userId");
|
||||
}
|
||||
|
||||
void _sendPong() {
|
||||
final pong = {'event': 'pusher:pong', 'data': {}};
|
||||
_sendMessage(pong);
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isConnected) debugPrint("💓 WS heartbeat");
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_isIntentionalClose) return;
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
onError?.call(
|
||||
"Failed to reconnect after $_maxReconnectAttempts attempts",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts++;
|
||||
|
||||
debugPrint(
|
||||
"🔄 Retry $_reconnectAttempts in ${_reconnectDelay.inSeconds}s...",
|
||||
);
|
||||
|
||||
_reconnectTimer = Timer(_reconnectDelay, () async {
|
||||
if (!_isIntentionalClose && _wsUrl != null && _userId != null) {
|
||||
await _establishConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send message
|
||||
void _sendMessage(Map<String, dynamic> message) {
|
||||
if (!_isConnected || _channel == null) {
|
||||
debugPrint("⚠️ Cannot send: Not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonMsg = json.encode(message);
|
||||
_channel!.sink.add(jsonMsg);
|
||||
debugPrint("📤 Sent: $jsonMsg");
|
||||
} catch (e) {
|
||||
debugPrint("❌ Send error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send event to backend
|
||||
void sendEvent({
|
||||
required String eventName,
|
||||
required Map<String, dynamic> data,
|
||||
}) {
|
||||
final msg = {
|
||||
'event': eventName,
|
||||
'data': data,
|
||||
'channel': 'user-chat-notification.$_userId',
|
||||
};
|
||||
_sendMessage(msg);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isIntentionalClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_stopPingTimer();
|
||||
|
||||
await _subscription?.cancel();
|
||||
await _channel?.sink.close();
|
||||
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_isConnected = false;
|
||||
_userId = null;
|
||||
|
||||
debugPrint("🔌 WS disconnected intentionally");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
|
||||
class DownloadHelper {
|
||||
static const String API_BASE_URL = "https://www.taxglide.amrithaa.net/api/";
|
||||
|
||||
/// Request storage permission based on Android version
|
||||
static Future<bool> requestStoragePermission() async {
|
||||
if (!Platform.isAndroid) return true;
|
||||
|
||||
try {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final sdkInt = androidInfo.version.sdkInt;
|
||||
|
||||
print("📱 Android SDK: $sdkInt");
|
||||
|
||||
// Android 13+ (API 33+)
|
||||
if (sdkInt >= 33) {
|
||||
final photoStatus = await Permission.photos.request();
|
||||
final videoStatus = await Permission.videos.request();
|
||||
|
||||
print("✅ Android 13+ - Media permissions requested");
|
||||
return photoStatus.isGranted || videoStatus.isGranted;
|
||||
}
|
||||
|
||||
// Android 11-12 (API 30-32)
|
||||
if (sdkInt >= 30) {
|
||||
if (await Permission.manageExternalStorage.isGranted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var status = await Permission.manageExternalStorage.request();
|
||||
if (status.isGranted) return true;
|
||||
|
||||
status = await Permission.storage.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
// Android 10 and below (API 29 and below)
|
||||
final status = await Permission.storage.request();
|
||||
return status.isGranted;
|
||||
} catch (e) {
|
||||
print("❌ Permission error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// ⭐ NEW: Get TaxGlide folder in PUBLIC Downloads
|
||||
static Future<Directory> getTaxGlideFolder() async {
|
||||
try {
|
||||
await requestStoragePermission();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
// Use public Downloads folder for all Android versions
|
||||
final publicTaxGlide = Directory(
|
||||
'/storage/emulated/0/Download/TaxGlide',
|
||||
);
|
||||
|
||||
if (!await publicTaxGlide.exists()) {
|
||||
await publicTaxGlide.create(recursive: true);
|
||||
print(
|
||||
"✅ TaxGlide folder created in public Downloads: ${publicTaxGlide.path}",
|
||||
);
|
||||
}
|
||||
|
||||
return publicTaxGlide;
|
||||
} else {
|
||||
// iOS - use app documents
|
||||
final baseDirectory = await getApplicationDocumentsDirectory();
|
||||
final taxGlideFolder = Directory('${baseDirectory.path}/TaxGlide');
|
||||
|
||||
if (!await taxGlideFolder.exists()) {
|
||||
await taxGlideFolder.create(recursive: true);
|
||||
}
|
||||
|
||||
return taxGlideFolder;
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ Error creating TaxGlide folder: $e");
|
||||
// Fallback to app-specific storage if public storage fails
|
||||
final fallbackDir = await getExternalStorageDirectory();
|
||||
final taxGlideFolder = Directory('${fallbackDir!.path}/TaxGlide');
|
||||
|
||||
if (!await taxGlideFolder.exists()) {
|
||||
await taxGlideFolder.create(recursive: true);
|
||||
print("⚠️ Using app-specific storage as fallback");
|
||||
}
|
||||
|
||||
return taxGlideFolder;
|
||||
}
|
||||
}
|
||||
|
||||
/// ⭐ Get Received folder in PUBLIC Downloads
|
||||
static Future<Directory> getReceivedFolder() async {
|
||||
final taxGlideFolder = await getTaxGlideFolder();
|
||||
final receivedFolder = Directory('${taxGlideFolder.path}/Received');
|
||||
|
||||
if (!await receivedFolder.exists()) {
|
||||
await receivedFolder.create(recursive: true);
|
||||
print("✅ Received folder created: ${receivedFolder.path}");
|
||||
}
|
||||
|
||||
return receivedFolder;
|
||||
}
|
||||
|
||||
/// ⭐ Get Send folder in PUBLIC Downloads
|
||||
static Future<Directory> getSendFolder() async {
|
||||
final taxGlideFolder = await getTaxGlideFolder();
|
||||
final sendFolder = Directory('${taxGlideFolder.path}/Send');
|
||||
|
||||
if (!await sendFolder.exists()) {
|
||||
await sendFolder.create(recursive: true);
|
||||
print("✅ Send folder created: ${sendFolder.path}");
|
||||
}
|
||||
|
||||
return sendFolder;
|
||||
}
|
||||
|
||||
/// Save file to Gallery (for images/videos only)
|
||||
static Future<bool> saveToGallery(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
print("❌ File does not exist: $filePath");
|
||||
return false;
|
||||
}
|
||||
|
||||
final fileName = filePath.split('/').last;
|
||||
|
||||
// Request permissions
|
||||
final hasPermission = await requestStoragePermission();
|
||||
if (!hasPermission) {
|
||||
print("⚠️ No permission to save to gallery");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only save images/videos to Gallery
|
||||
if (isImageFile(filePath)) {
|
||||
await Gal.putImage(filePath, album: 'TaxGlide');
|
||||
print("✅ Image saved to Gallery: $fileName");
|
||||
return true;
|
||||
} else if (isVideoFile(filePath)) {
|
||||
await Gal.putVideo(filePath, album: 'TaxGlide');
|
||||
print("✅ Video saved to Gallery: $fileName");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
print("❌ Error saving to gallery: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build download URL
|
||||
static String buildDownloadUrl(String filePath) {
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
return filePath;
|
||||
}
|
||||
return '${API_BASE_URL}chat/download/$filePath';
|
||||
}
|
||||
|
||||
/// Check if file exists in Received folder
|
||||
static Future<String?> checkFileExistsInReceived(String url) async {
|
||||
try {
|
||||
final fileName = url.contains('/')
|
||||
? url.split('/').last.split('?').first
|
||||
: url.split('?').first;
|
||||
|
||||
final receivedFolder = await getReceivedFolder();
|
||||
final filePath = '${receivedFolder.path}/$fileName';
|
||||
final file = File(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
print("✅ File already exists in Received: $filePath");
|
||||
return filePath;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print("❌ Error checking file existence: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward compatibility
|
||||
static Future<String?> checkFileExists(String url) async {
|
||||
return await checkFileExistsInReceived(url);
|
||||
}
|
||||
|
||||
/// ⭐ Download file to Received folder (now in public Downloads) and Gallery
|
||||
static Future<Map<String, dynamic>> downloadFileToReceived(String url) async {
|
||||
try {
|
||||
final fullUrl = buildDownloadUrl(url);
|
||||
print("🔗 Downloading from: $fullUrl");
|
||||
|
||||
// Check if file already exists
|
||||
final existingFilePath = await checkFileExistsInReceived(fullUrl);
|
||||
if (existingFilePath != null) {
|
||||
print("📂 File already exists, opening it...");
|
||||
await openDownloadedFile(existingFilePath);
|
||||
return {
|
||||
'filePath': existingFilePath,
|
||||
'isNewDownload': false,
|
||||
'success': true,
|
||||
};
|
||||
}
|
||||
|
||||
// Request permissions
|
||||
await requestStoragePermission();
|
||||
|
||||
// Get filename and prepare save path
|
||||
String fileName = fullUrl.split('/').last.split('?').first;
|
||||
fileName = fileName.replaceAll(RegExp(r'[^\w\s\-\.]'), '_');
|
||||
|
||||
final receivedFolder = await getReceivedFolder();
|
||||
final savePath = "${receivedFolder.path}/$fileName";
|
||||
|
||||
print("Download Started: Downloading $fileName...");
|
||||
|
||||
// Download file to Received folder (now in public Downloads)
|
||||
Dio dio = Dio();
|
||||
await dio.download(
|
||||
fullUrl,
|
||||
savePath,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total != -1) {
|
||||
final progress = (received / total * 100).toStringAsFixed(0);
|
||||
print("📊 Download progress: $progress%");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Also save images/videos to Gallery
|
||||
bool savedToGallery = false;
|
||||
try {
|
||||
savedToGallery = await saveToGallery(savePath);
|
||||
} catch (e) {
|
||||
print("⚠️ Could not save to gallery: $e");
|
||||
}
|
||||
|
||||
if (savedToGallery) {
|
||||
print(
|
||||
"Download Complete: $fileName saved to Gallery & Downloads/TaxGlide/Received",
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
"Download Complete: $fileName saved to Downloads/TaxGlide/Received",
|
||||
);
|
||||
}
|
||||
|
||||
print("✅ File downloaded to: $savePath");
|
||||
|
||||
// Open the downloaded file
|
||||
await openDownloadedFile(savePath);
|
||||
|
||||
return {
|
||||
'filePath': savePath,
|
||||
'isNewDownload': true,
|
||||
'success': true,
|
||||
'savedToGallery': savedToGallery,
|
||||
};
|
||||
} catch (e) {
|
||||
print("❌ Download error: $e");
|
||||
print("Download Failed: Error: ${e.toString()}");
|
||||
return {'success': false, 'error': e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward compatibility
|
||||
static Future<Map<String, dynamic>> downloadFile(String url) async {
|
||||
return await downloadFileToReceived(url);
|
||||
}
|
||||
|
||||
/// ⭐ Save file to Send folder (now in public Downloads)
|
||||
static Future<String> saveToSendFolder(File file) async {
|
||||
try {
|
||||
if (!await file.exists()) {
|
||||
throw Exception("Source file does not exist: ${file.path}");
|
||||
}
|
||||
|
||||
final sendFolder = await getSendFolder();
|
||||
final originalFileName = file.path.split('/').last;
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final fileName = '${timestamp}_$originalFileName';
|
||||
final savePath = '${sendFolder.path}/$fileName';
|
||||
|
||||
final savedFile = await file.copy(savePath);
|
||||
|
||||
print("✅ File saved to Send folder (Downloads/TaxGlide/Send): $savePath");
|
||||
|
||||
// Also save images/videos to Gallery
|
||||
try {
|
||||
await saveToGallery(savePath);
|
||||
} catch (e) {
|
||||
print("⚠️ Could not save to gallery: $e");
|
||||
}
|
||||
|
||||
return savedFile.path;
|
||||
} catch (e) {
|
||||
print("❌ Error saving to Send folder: $e");
|
||||
print("⚠️ Using original file path: ${file.path}");
|
||||
return file.path;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save multiple files to Send folder
|
||||
static Future<List<File>> saveMultipleToSendFolder(List<File> files) async {
|
||||
List<File> savedFiles = [];
|
||||
|
||||
for (File file in files) {
|
||||
try {
|
||||
final savedPath = await saveToSendFolder(file);
|
||||
savedFiles.add(File(savedPath));
|
||||
} catch (e) {
|
||||
print("❌ Error saving file: ${file.path}, using original");
|
||||
savedFiles.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return savedFiles;
|
||||
}
|
||||
|
||||
/// Open file with appropriate viewer
|
||||
static Future<void> openDownloadedFile(String path) async {
|
||||
print("🔓 Opening file: $path");
|
||||
|
||||
try {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
print("❌ File not found at $path");
|
||||
print("Error: File not found");
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await OpenFilex.open(path);
|
||||
|
||||
if (result.type != ResultType.done) {
|
||||
print("⚠️ Could not open file: ${result.message}");
|
||||
print("Notice: File saved but no app found to open it");
|
||||
}
|
||||
} catch (e) {
|
||||
print("❌ Error opening file: $e");
|
||||
print("Error: Could not open file");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all files from Send folder
|
||||
static Future<List<File>> getSentFiles() async {
|
||||
try {
|
||||
final sendFolder = await getSendFolder();
|
||||
if (await sendFolder.exists()) {
|
||||
return sendFolder.listSync().whereType<File>().toList()..sort(
|
||||
(a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
print("❌ Error getting sent files: $e");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all files from Received folder
|
||||
static Future<List<File>> getReceivedFiles() async {
|
||||
try {
|
||||
final receivedFolder = await getReceivedFolder();
|
||||
if (await receivedFolder.exists()) {
|
||||
return receivedFolder.listSync().whereType<File>().toList()..sort(
|
||||
(a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
print("❌ Error getting received files: $e");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a file
|
||||
static Future<bool> deleteFile(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
print("✅ File deleted: $filePath");
|
||||
print("File Deleted: File removed successfully");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print("❌ Error deleting file: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all files from Send folder
|
||||
static Future<void> clearSendFolder() async {
|
||||
try {
|
||||
final files = await getSentFiles();
|
||||
for (final file in files) {
|
||||
await file.delete();
|
||||
}
|
||||
print("✅ Send folder cleared: ${files.length} files deleted");
|
||||
} catch (e) {
|
||||
print("❌ Error clearing Send folder: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all files from Received folder
|
||||
static Future<void> clearReceivedFolder() async {
|
||||
try {
|
||||
final files = await getReceivedFiles();
|
||||
for (final file in files) {
|
||||
await file.delete();
|
||||
}
|
||||
print("✅ Received folder cleared: ${files.length} files deleted");
|
||||
} catch (e) {
|
||||
print("❌ Error clearing Received folder: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get file size in human-readable format
|
||||
static Future<String> getFileSize(File file) async {
|
||||
try {
|
||||
final bytes = await file.length();
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(2)} KB';
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
} catch (e) {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get file extension
|
||||
static String getFileExtension(String path) {
|
||||
return path.split('.').last.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if file is image
|
||||
static bool isImageFile(String path) {
|
||||
final ext = getFileExtension(path);
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].contains(ext);
|
||||
}
|
||||
|
||||
/// Check if file is document
|
||||
static bool isDocumentFile(String path) {
|
||||
final ext = getFileExtension(path);
|
||||
return [
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'txt',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'rtf',
|
||||
'odt',
|
||||
].contains(ext);
|
||||
}
|
||||
|
||||
/// Check if file is video
|
||||
static bool isVideoFile(String path) {
|
||||
final ext = getFileExtension(path);
|
||||
return ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].contains(ext);
|
||||
}
|
||||
|
||||
/// Check if file is audio
|
||||
static bool isAudioFile(String path) {
|
||||
final ext = getFileExtension(path);
|
||||
return ['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].contains(ext);
|
||||
}
|
||||
|
||||
/// Get file icon based on type
|
||||
static String getFileIcon(String path) {
|
||||
if (isImageFile(path)) return '🖼️';
|
||||
if (isVideoFile(path)) return '🎥';
|
||||
if (isAudioFile(path)) return '🎵';
|
||||
if (isDocumentFile(path)) {
|
||||
final ext = getFileExtension(path);
|
||||
if (ext == 'pdf') return '📄';
|
||||
if (['doc', 'docx'].contains(ext)) return '📝';
|
||||
if (['xls', 'xlsx'].contains(ext)) return '📊';
|
||||
if (['ppt', 'pptx'].contains(ext)) return '📽️';
|
||||
}
|
||||
return '📎';
|
||||
}
|
||||
|
||||
/// Get storage info
|
||||
static Future<Map<String, String>> getStorageInfo() async {
|
||||
try {
|
||||
final taxGlideFolder = await getTaxGlideFolder();
|
||||
final sentFiles = await getSentFiles();
|
||||
final receivedFiles = await getReceivedFiles();
|
||||
|
||||
int totalSentSize = 0;
|
||||
int totalReceivedSize = 0;
|
||||
|
||||
for (final file in sentFiles) {
|
||||
totalSentSize += await file.length();
|
||||
}
|
||||
|
||||
for (final file in receivedFiles) {
|
||||
totalReceivedSize += await file.length();
|
||||
}
|
||||
|
||||
return {
|
||||
'taxGlidePath': taxGlideFolder.path,
|
||||
'sentFiles': sentFiles.length.toString(),
|
||||
'receivedFiles': receivedFiles.length.toString(),
|
||||
'sentSize': _formatBytes(totalSentSize),
|
||||
'receivedSize': _formatBytes(totalReceivedSize),
|
||||
'totalSize': _formatBytes(totalSentSize + totalReceivedSize),
|
||||
};
|
||||
} catch (e) {
|
||||
print("❌ Error getting storage info: $e");
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:io';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 🔥 FCM Token Manager - Force regenerates token on logout/login
|
||||
class FcmTokenManager {
|
||||
static final FcmTokenManager _instance = FcmTokenManager._internal();
|
||||
factory FcmTokenManager() => _instance;
|
||||
FcmTokenManager._internal();
|
||||
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
|
||||
/// 🔑 Get current FCM token
|
||||
Future<String?> getCurrentToken() async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
final apns = await _messaging.getAPNSToken();
|
||||
if (apns == null) {
|
||||
debugPrint('⚠️ APIs Token missing on iOS - Skipping FCM getToken');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('🔄 Getting current FCM token...');
|
||||
final token = await _messaging.getToken();
|
||||
debugPrint('✅ Current FCM Token: $token');
|
||||
return token;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting FCM token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔄 Force regenerate FCM token (deletes old and creates new)
|
||||
/// Call this BEFORE login to ensure fresh token
|
||||
/// 🔄 Force regenerate FCM token (deletes old and creates new)
|
||||
Future<String?> regenerateToken() async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
final apns = await _messaging.getAPNSToken();
|
||||
if (apns == null) {
|
||||
debugPrint(
|
||||
'⚠️ APIs Token missing on iOS - Cannot regenerate FCM token',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('🔄 Starting FCM token regeneration...');
|
||||
|
||||
// Step 1: Get old token
|
||||
String? oldToken;
|
||||
try {
|
||||
oldToken = await _messaging.getToken();
|
||||
debugPrint('📋 OLD FCM Token: $oldToken');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Could not get old token: $e');
|
||||
}
|
||||
|
||||
// Step 2: Delete the old token
|
||||
try {
|
||||
debugPrint('🗑️ Deleting old FCM token...');
|
||||
await _messaging.deleteToken();
|
||||
debugPrint('✅ Old token deleted');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Error deleting old token: $e');
|
||||
}
|
||||
|
||||
// Step 3: Wait
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// Step 4: Request permission
|
||||
await _messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
|
||||
// Step 5: Generate new token
|
||||
debugPrint('🔄 Generating new FCM token...');
|
||||
final newToken = await _messaging.getToken();
|
||||
|
||||
if (newToken != null) {
|
||||
debugPrint('✅ NEW FCM Token: $newToken');
|
||||
} else {
|
||||
debugPrint('⚠️ Failed to generate new token');
|
||||
}
|
||||
|
||||
return newToken;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error regenerating FCM token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🗑️ Delete FCM token (call on logout)
|
||||
Future<bool> deleteToken() async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
final apns = await _messaging.getAPNSToken();
|
||||
if (apns == null) return false;
|
||||
}
|
||||
|
||||
// Print token before deletion
|
||||
final currentToken = await _messaging.getToken();
|
||||
debugPrint('📋 Current FCM Token (before delete): $currentToken');
|
||||
|
||||
debugPrint('🗑️ Deleting FCM token...');
|
||||
await _messaging.deleteToken();
|
||||
debugPrint('✅ FCM token deleted successfully');
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error deleting FCM token: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 📡 Listen to token refresh events
|
||||
void listenToTokenRefresh(Function(String) onTokenRefreshed) {
|
||||
_messaging.onTokenRefresh.listen((newToken) {
|
||||
debugPrint('🔔 FCM Token auto-refreshed by Firebase: $newToken');
|
||||
onTokenRefreshed(newToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// 🔄 Reset and get fresh token (comprehensive reset)
|
||||
Future<String?> resetAndGetFreshToken() async {
|
||||
try {
|
||||
// Delete existing token
|
||||
await deleteToken();
|
||||
|
||||
// Wait longer to ensure complete reset
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Regenerate
|
||||
return await regenerateToken();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error in resetAndGetFreshToken: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:taxglide/consts/app_asstes.dart';
|
||||
import 'package:taxglide/view/screens/notification_screen.dart';
|
||||
import 'package:taxglide/controller/api_contoller.dart';
|
||||
|
||||
final serviceSearchProvider = StateProvider<String>((ref) => "");
|
||||
|
||||
class HomePageHeader extends ConsumerWidget {
|
||||
final String userName;
|
||||
|
||||
const HomePageHeader({super.key, required this.userName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
|
||||
final headerHeight = screenHeight * 0.21;
|
||||
final profileSize = screenWidth * 0.14;
|
||||
final iconSize = screenWidth * 0.07;
|
||||
final horizontalPadding = screenWidth * 0.04;
|
||||
final searchBoxHeight = screenHeight * 0.07;
|
||||
|
||||
final welcomeFontSize = screenWidth * 0.035;
|
||||
final userFontSize = screenWidth * 0.045;
|
||||
final initialFontSize = screenWidth * 0.045;
|
||||
|
||||
// 🔴 Fetch Notification Count
|
||||
final notifCountAsync = ref.watch(notificationCountProvider);
|
||||
// 🔴 Fetch Profile Data for Image
|
||||
final profileAsync = ref.watch(profileProvider);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Background Header
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
child: Image.asset(
|
||||
AppAssets.homepageheader,
|
||||
width: double.infinity,
|
||||
height: headerHeight,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
// Profile + Welcome + Notification Icon
|
||||
Positioned(
|
||||
top: headerHeight * 0.15,
|
||||
left: horizontalPadding,
|
||||
right: horizontalPadding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Profile Avatar
|
||||
profileAsync.when(
|
||||
data: (profileModel) {
|
||||
final String? logoUrl = profileModel.data?.logo;
|
||||
if (logoUrl != null && logoUrl.isNotEmpty) {
|
||||
return Container(
|
||||
width: profileSize,
|
||||
height: profileSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(logoUrl),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Fallback to initial
|
||||
return Container(
|
||||
width: profileSize,
|
||||
height: profileSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
userName.isNotEmpty ? userName[0].toUpperCase() : "M",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: initialFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => Container(
|
||||
width: profileSize,
|
||||
height: profileSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey[300],
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
error: (e, st) => Container(
|
||||
width: profileSize,
|
||||
height: profileSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
userName.isNotEmpty ? userName[0].toUpperCase() : "M",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: initialFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: screenWidth * 0.03),
|
||||
|
||||
// Welcome & Username
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Welcome to TaxGlide",
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: welcomeFontSize,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
userName,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: userFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Notifications with Badge
|
||||
GestureDetector(
|
||||
onTap: () => Get.to(NotificationScreen()),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black,
|
||||
),
|
||||
padding: EdgeInsets.all(screenWidth * 0.025),
|
||||
child: Icon(
|
||||
Icons.notifications,
|
||||
color: Colors.white,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
|
||||
// 🔴 Notification Badge
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: notifCountAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (e, st) => const SizedBox.shrink(),
|
||||
data: (count) {
|
||||
if (count.count == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
count.count.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar
|
||||
Positioned(
|
||||
top: headerHeight - (searchBoxHeight * 0.6),
|
||||
left: screenWidth * 0.05,
|
||||
right: screenWidth * 0.05,
|
||||
child: Container(
|
||||
height: searchBoxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: Border.all(color: const Color(0xFFE4E4E4), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
textAlign: TextAlign.start, // ✅ Start (left) alignment
|
||||
style: TextStyle(fontSize: screenWidth * 0.04),
|
||||
onChanged: (value) {
|
||||
ref.read(serviceSearchProvider.notifier).state = value;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search services...",
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: screenWidth * 0.04,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.black54,
|
||||
size: screenWidth * 0.06,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
|
||||
// ✅ Only vertical center
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
vertical: searchBoxHeight * 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:taxglide/consts/comman_image_box.dart';
|
||||
import 'package:taxglide/consts/comman_textformfileds.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// A reusable helper class for image picking with safe permission handling.
|
||||
class ImagePickerHelper {
|
||||
static final ImagePicker _picker = ImagePicker();
|
||||
static bool _isPicking = false;
|
||||
|
||||
/// Request permission depending on platform and SDK version
|
||||
static Future<bool> _requestPermission(
|
||||
BuildContext context,
|
||||
ImageSource source,
|
||||
) async {
|
||||
try {
|
||||
Permission permission;
|
||||
String name;
|
||||
|
||||
if (source == ImageSource.camera) {
|
||||
permission = Permission.camera;
|
||||
name = 'Camera';
|
||||
} else {
|
||||
if (Platform.isAndroid) {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
if (info.version.sdkInt >= 33) {
|
||||
permission = Permission.photos;
|
||||
name = 'Photos';
|
||||
} else {
|
||||
permission = Permission.storage;
|
||||
name = 'Storage';
|
||||
}
|
||||
} else {
|
||||
permission = Permission.photos; // iOS gallery
|
||||
name = 'Gallery';
|
||||
}
|
||||
}
|
||||
|
||||
var status = await permission.status;
|
||||
if (status.isGranted || status.isLimited) return true;
|
||||
|
||||
// On iOS, if already denied, request() won't show anything.
|
||||
// On Android, permanently denied also won't show anything.
|
||||
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
|
||||
await _showSettingsDialog(context, name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now request permission
|
||||
status = await permission.request();
|
||||
|
||||
if (status.isGranted || status.isLimited) {
|
||||
return true;
|
||||
} else {
|
||||
// If user denied it just now, or it was already denied (iOS)
|
||||
await _showSettingsDialog(context, name);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Permission request failed: $e');
|
||||
return Platform.isIOS;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _showSettingsDialog(
|
||||
BuildContext context,
|
||||
String name,
|
||||
) async {
|
||||
final openSettings =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('$name Permission Required'),
|
||||
content: Text(
|
||||
'Allow Taxglide to access your $name to capture and upload files easily. You can enable this in the app settings.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Go to Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
if (openSettings) await openAppSettings();
|
||||
}
|
||||
|
||||
/// Pick single image safely
|
||||
static Future<void> pickSingleImage({
|
||||
required BuildContext context,
|
||||
required void Function(File?) onImageSelected,
|
||||
required void Function(String?) setError,
|
||||
}) async {
|
||||
if (_isPicking) return;
|
||||
_isPicking = true;
|
||||
|
||||
try {
|
||||
// Ask user: Camera or Gallery
|
||||
final source = await showDialog<ImageSource>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: const Text('Select Image Source'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt, color: Colors.blue),
|
||||
title: const Text('Camera'),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo, color: Colors.blue),
|
||||
title: const Text('Gallery'),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (source == null) {
|
||||
_isPicking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission check
|
||||
final hasPermission = await _requestPermission(context, source);
|
||||
if (!hasPermission) {
|
||||
_isPicking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Image selection
|
||||
final pickedFile = await _picker.pickImage(source: source);
|
||||
if (pickedFile != null) {
|
||||
onImageSelected(File(pickedFile.path));
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Image pick error: $e');
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"Failed to pick image: $e",
|
||||
backgroundColor: Colors.red,
|
||||
colorText: Colors.white,
|
||||
);
|
||||
} finally {
|
||||
_isPicking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 NEW: Pick Image or PDF
|
||||
static Future<void> pickImageOrPdf({
|
||||
required BuildContext context,
|
||||
required void Function(File?) onFileSelected,
|
||||
required void Function(String?) setError,
|
||||
}) async {
|
||||
if (_isPicking) return;
|
||||
_isPicking = true;
|
||||
|
||||
try {
|
||||
// Ask user: Camera, Gallery, or PDF
|
||||
final choice = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: const Text('Select File Source'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt, color: Colors.blue),
|
||||
title: const Text('Camera'),
|
||||
onTap: () => Navigator.pop(ctx, 'camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo, color: Colors.blue),
|
||||
title: const Text('Gallery (Image)'),
|
||||
onTap: () => Navigator.pop(ctx, 'gallery'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.picture_as_pdf, color: Colors.red),
|
||||
title: const Text('Select PDF'),
|
||||
onTap: () => Navigator.pop(ctx, 'pdf'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (choice == null) {
|
||||
_isPicking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice == 'pdf') {
|
||||
// Pick PDF using file_picker
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
|
||||
// Check file size (max 10MB)
|
||||
final fileSizeInBytes = await file.length();
|
||||
final fileSizeInMB = fileSizeInBytes / (1024 * 1024);
|
||||
|
||||
if (fileSizeInMB > 10) {
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"PDF file size should not exceed 10MB",
|
||||
backgroundColor: Colors.red,
|
||||
colorText: Colors.white,
|
||||
);
|
||||
_isPicking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onFileSelected(file);
|
||||
setError(null);
|
||||
}
|
||||
} else {
|
||||
// Pick Image (Camera or Gallery)
|
||||
final source = choice == 'camera'
|
||||
? ImageSource.camera
|
||||
: ImageSource.gallery;
|
||||
|
||||
// Permission check
|
||||
final hasPermission = await _requestPermission(context, source);
|
||||
if (!hasPermission) {
|
||||
_isPicking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Image selection
|
||||
final pickedFile = await _picker.pickImage(source: source);
|
||||
if (pickedFile != null) {
|
||||
onFileSelected(File(pickedFile.path));
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('File pick error: $e');
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"Failed to pick file: $e",
|
||||
backgroundColor: Colors.red,
|
||||
colorText: Colors.white,
|
||||
);
|
||||
} finally {
|
||||
_isPicking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LabeledTextField extends StatelessWidget {
|
||||
final GlobalKey fieldKey;
|
||||
final String label;
|
||||
final String keyName;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final TextInputType keyboardType;
|
||||
final Map<String, String?> errors;
|
||||
final Function(String) onChanged;
|
||||
final bool readOnly;
|
||||
|
||||
/// ✅ ADD THESE
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final TextCapitalization textCapitalization;
|
||||
|
||||
const LabeledTextField({
|
||||
super.key,
|
||||
required this.fieldKey,
|
||||
required this.label,
|
||||
required this.keyName,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.errors,
|
||||
required this.onChanged,
|
||||
this.readOnly = false,
|
||||
this.keyboardType = TextInputType.text,
|
||||
|
||||
/// ✅ DEFAULT VALUES
|
||||
this.inputFormatters,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: fieldKey,
|
||||
padding: const EdgeInsets.only(bottom: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-SemiBold',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: 0.64,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
/// ✅ PASS FORMATTERS & CAPITALIZATION HERE
|
||||
CommanTextFormField(
|
||||
controller: controller,
|
||||
hintText: hint,
|
||||
keyboardType: keyboardType,
|
||||
focusNode: focusNode,
|
||||
hasError: errors[keyName] != null,
|
||||
onChanged: onChanged,
|
||||
readOnly: readOnly,
|
||||
inputFormatters: inputFormatters,
|
||||
textCapitalization: textCapitalization,
|
||||
),
|
||||
|
||||
if (errors[keyName] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 6),
|
||||
child: Text(
|
||||
errors[keyName]!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Updated SingleImageSectionField with URL support (Image only - for Logo)
|
||||
class SingleImageSectionField extends StatelessWidget {
|
||||
final GlobalKey imageKey;
|
||||
final String title;
|
||||
final File? imageFile;
|
||||
final String? imageUrl;
|
||||
final String? errorText;
|
||||
final Function(String?) setError;
|
||||
final Function(File?) onImageSelected;
|
||||
final VoidCallback onImageRemoved;
|
||||
|
||||
const SingleImageSectionField({
|
||||
super.key,
|
||||
required this.imageKey,
|
||||
required this.title,
|
||||
required this.imageFile,
|
||||
required this.imageUrl,
|
||||
required this.errorText,
|
||||
required this.setError,
|
||||
required this.onImageSelected,
|
||||
required this.onImageRemoved,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasImage = imageFile != null || imageUrl != null;
|
||||
|
||||
return Padding(
|
||||
key: imageKey,
|
||||
padding: const EdgeInsets.only(bottom: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-SemiBold',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: 0.64,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CommanImageBox(
|
||||
onTap: () => ImagePickerHelper.pickSingleImage(
|
||||
context: context,
|
||||
onImageSelected: onImageSelected,
|
||||
setError: setError,
|
||||
),
|
||||
text: hasImage ? 'Change Image' : 'Upload Image',
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 6),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
if (hasImage)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageFile != null
|
||||
? Image.file(imageFile!, fit: BoxFit.cover)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: onImageRemoved,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 NEW: File Section Field (Supports both Image and PDF)
|
||||
class FileUploadSectionField extends StatelessWidget {
|
||||
final GlobalKey fileKey;
|
||||
final String title;
|
||||
final File? fileData;
|
||||
final String? fileUrl;
|
||||
final String? errorText;
|
||||
final Function(String?) setError;
|
||||
final Function(File?) onFileSelected;
|
||||
final VoidCallback onFileRemoved;
|
||||
|
||||
const FileUploadSectionField({
|
||||
super.key,
|
||||
required this.fileKey,
|
||||
required this.title,
|
||||
required this.fileData,
|
||||
required this.fileUrl,
|
||||
required this.errorText,
|
||||
required this.setError,
|
||||
required this.onFileSelected,
|
||||
required this.onFileRemoved,
|
||||
});
|
||||
|
||||
bool _isPdfFile(String? filePath) {
|
||||
if (filePath == null) return false;
|
||||
return path.extension(filePath).toLowerCase() == '.pdf';
|
||||
}
|
||||
|
||||
bool _isImageFile(String? filePath) {
|
||||
if (filePath == null) return false;
|
||||
final ext = path.extension(filePath).toLowerCase();
|
||||
return ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].contains(ext);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasFile = fileData != null || fileUrl != null;
|
||||
final isPdf = _isPdfFile(fileData?.path ?? fileUrl);
|
||||
final isImage = _isImageFile(fileData?.path ?? fileUrl);
|
||||
|
||||
return Padding(
|
||||
key: fileKey,
|
||||
padding: const EdgeInsets.only(bottom: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Gilroy-SemiBold',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: 0.64,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CommanImageBox(
|
||||
onTap: () => ImagePickerHelper.pickImageOrPdf(
|
||||
context: context,
|
||||
onFileSelected: onFileSelected,
|
||||
setError: setError,
|
||||
),
|
||||
text: hasFile ? 'Change File' : 'Upload Image/PDF',
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 6),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// Display file preview
|
||||
if (hasFile)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: isPdf
|
||||
// Show PDF icon
|
||||
? Container(
|
||||
color: Colors.red.shade50,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 48,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'PDF',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: isImage && fileData != null
|
||||
// Show local image
|
||||
? Image.file(fileData!, fit: BoxFit.cover)
|
||||
: isImage && fileUrl != null
|
||||
// Show network image
|
||||
? CachedNetworkImage(
|
||||
imageUrl: fileUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
)
|
||||
// Unknown file type
|
||||
: Container(
|
||||
color: Colors.grey.shade100,
|
||||
child: const Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: onFileRemoved,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class LocalStore {
|
||||
// Secure storage instance
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
/// --------------------------------------------------------
|
||||
/// 🔐 SAVE LOGIN / SIGNUP DATA
|
||||
/// --------------------------------------------------------
|
||||
Future<void> saveLoginData(Map<String, dynamic> data) async {
|
||||
final token = data['access_token'] ?? '';
|
||||
final role = data['role'] ?? '';
|
||||
|
||||
// Keep existing FCM token (do not overwrite)
|
||||
String? existingFcmToken = await _storage.read(key: 'fcm_token');
|
||||
|
||||
// Save token & role
|
||||
await _storage.write(key: 'access_token', value: token.toString());
|
||||
await _storage.write(key: 'role', value: role.toString());
|
||||
|
||||
// ⭐ Save general_chat_id if available
|
||||
if (data['general_chat_id'] != null) {
|
||||
await _storage.write(
|
||||
key: 'general_chat_id',
|
||||
value: data['general_chat_id'].toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// ⭐ Save USER ID if available
|
||||
if (data['user'] != null && data['user']['id'] != null) {
|
||||
await _storage.write(
|
||||
key: 'user_id',
|
||||
value: data['user']['id'].toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// ⭐ If root level ID exists (not in your JSON but future-safe)
|
||||
if (data['id'] != null) {
|
||||
await _storage.write(key: 'main_id', value: data['id'].toString());
|
||||
}
|
||||
|
||||
// ⭐ Save/Persist FCM token safely
|
||||
if (existingFcmToken != null && existingFcmToken.isNotEmpty) {
|
||||
// Keep old FCM
|
||||
await _storage.write(key: 'fcm_token', value: existingFcmToken);
|
||||
} else if (data['fcm_token'] != null &&
|
||||
data['fcm_token'].toString().isNotEmpty) {
|
||||
// Save new FCM token
|
||||
await _storage.write(
|
||||
key: 'fcm_token',
|
||||
value: data['fcm_token'].toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// --------------------------------------------------------
|
||||
/// 🔥 SAVE FCM TOKEN ALONE
|
||||
/// --------------------------------------------------------
|
||||
Future<void> saveFcmToken(String token) async {
|
||||
await _storage.write(key: 'fcm_token', value: token);
|
||||
}
|
||||
|
||||
/// --------------------------------------------------------
|
||||
/// 🔍 GETTERS
|
||||
/// --------------------------------------------------------
|
||||
Future<String?> getToken() async {
|
||||
return await _storage.read(key: 'access_token');
|
||||
}
|
||||
|
||||
Future<String?> getRole() async {
|
||||
return await _storage.read(key: 'role');
|
||||
}
|
||||
|
||||
Future<String?> getGeneralChatId() async {
|
||||
return await _storage.read(key: 'general_chat_id');
|
||||
}
|
||||
|
||||
Future<String?> getUserId() async {
|
||||
return await _storage.read(key: 'user_id');
|
||||
}
|
||||
|
||||
Future<String?> getMainId() async {
|
||||
return await _storage.read(key: 'main_id');
|
||||
}
|
||||
|
||||
/// --------------------------------------------------------
|
||||
/// 🧹 CLEAR ALL (OPTIONAL KEEP FCM TOKEN)
|
||||
/// --------------------------------------------------------
|
||||
Future<void> clearAll({bool keepFcm = true}) async {
|
||||
if (keepFcm) {
|
||||
// Preserve FCM token
|
||||
String? fcmToken = await _storage.read(key: 'fcm_token');
|
||||
await _storage.deleteAll();
|
||||
if (fcmToken != null) {
|
||||
await _storage.write(key: 'fcm_token', value: fcmToken);
|
||||
}
|
||||
} else {
|
||||
// Full wipe
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:taxglide/services/notification_service.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:taxglide/controller/api_contoller.dart';
|
||||
|
||||
class NotificationWebSocket {
|
||||
static final NotificationWebSocket _instance =
|
||||
NotificationWebSocket._internal();
|
||||
factory NotificationWebSocket() => _instance;
|
||||
NotificationWebSocket._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _isIntentionalClose = false;
|
||||
|
||||
String? _userId;
|
||||
String? _wsUrl;
|
||||
ProviderContainer? _container;
|
||||
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 10;
|
||||
|
||||
/// ChatId the user is currently VIEWING.
|
||||
String? _activeChatId;
|
||||
|
||||
/// ChatId whose `chat.<id>` channel we are subscribed to on this WS.
|
||||
String? _subscribedChatChannelId;
|
||||
|
||||
// ─────────────────────────── Public API ────────────────────────────────────
|
||||
|
||||
/// Call from LiveChatScreen.initState().
|
||||
void setActiveChatId(String chatId) {
|
||||
_activeChatId = chatId;
|
||||
debugPrint('NotificationWS: viewing chat $chatId');
|
||||
_subscribeToChatChannel(chatId);
|
||||
}
|
||||
|
||||
/// Call from LiveChatScreen.dispose()
|
||||
void clearActiveChatId() {
|
||||
debugPrint('NotificationWS: left chat $_activeChatId');
|
||||
_activeChatId = null;
|
||||
}
|
||||
|
||||
/// Subscribe to a chat channel for updates.
|
||||
void watchChatChannel(String chatId) {
|
||||
debugPrint('Eye NotificationWS: watchChatChannel($chatId)');
|
||||
_subscribeToChatChannel(chatId);
|
||||
}
|
||||
|
||||
/// Connect (or skip if already connected for the same user).
|
||||
Future<void> connect({
|
||||
required String userId,
|
||||
required ProviderContainer container,
|
||||
String baseUrl = 'wss://taxglide.amrithaa.net:443/reverb',
|
||||
String appKey = '2yj0lyzc9ylw2h03ts6i',
|
||||
}) async {
|
||||
if (_isConnected && _userId == userId) {
|
||||
debugPrint('⚡ NotificationWS: already connected for user $userId');
|
||||
return;
|
||||
}
|
||||
|
||||
await disconnect();
|
||||
|
||||
_userId = userId;
|
||||
_container = container;
|
||||
_wsUrl = '$baseUrl/app/$appKey?protocol=7&client=js&version=1.0';
|
||||
_isIntentionalClose = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_openSocket();
|
||||
}
|
||||
|
||||
// ──────────────────────── Socket lifecycle ──────────────────────────────────
|
||||
|
||||
void _openSocket() {
|
||||
try {
|
||||
debugPrint('🔌 NotificationWS connecting…');
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDone,
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
|
||||
_subscribeToUserChannel();
|
||||
|
||||
// Re-subscribe to chat channel after reconnect if we had one before.
|
||||
final chatId = _subscribedChatChannelId;
|
||||
if (chatId != null) {
|
||||
_sendRaw({
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'chat.$chatId'},
|
||||
});
|
||||
debugPrint('📡 NotificationWS: re-subscribed to chat.$chatId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ NotificationWS connect error: $e');
|
||||
_isConnected = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToUserChannel() {
|
||||
if (_userId == null) return;
|
||||
_sendRaw({
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'user-chat-notification.$_userId'},
|
||||
});
|
||||
debugPrint(
|
||||
'📡 NotificationWS: subscribed to user-chat-notification.$_userId',
|
||||
);
|
||||
}
|
||||
|
||||
void _subscribeToChatChannel(String chatId) {
|
||||
if (_subscribedChatChannelId == chatId) return; // already subscribed
|
||||
|
||||
// Unsubscribe previous chat channel
|
||||
if (_subscribedChatChannelId != null) {
|
||||
_sendRaw({
|
||||
'event': 'pusher:unsubscribe',
|
||||
'data': {'channel': 'chat.$_subscribedChatChannelId'},
|
||||
});
|
||||
debugPrint(
|
||||
'📡 NotificationWS: unsubscribed from chat.$_subscribedChatChannelId',
|
||||
);
|
||||
}
|
||||
|
||||
_subscribedChatChannelId = chatId;
|
||||
_sendRaw({
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'chat.$chatId'},
|
||||
});
|
||||
debugPrint('📡 NotificationWS: subscribed to chat.$chatId');
|
||||
}
|
||||
|
||||
// ──────────────────────── Message handler ──────────────────────────────────
|
||||
|
||||
void _handleMessage(dynamic raw) {
|
||||
debugPrint('🔔 NotificationWS raw: $raw');
|
||||
|
||||
try {
|
||||
final msg = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final event = msg['event']?.toString() ?? '';
|
||||
|
||||
// ── Pusher connection established → re-subscribe ──────────────────────
|
||||
if (event == 'pusher:connection_established') {
|
||||
debugPrint('🔗 NotificationWS: connection established');
|
||||
_subscribeToUserChannel();
|
||||
final chatId = _subscribedChatChannelId;
|
||||
if (chatId != null) {
|
||||
_sendRaw({
|
||||
'event': 'pusher:subscribe',
|
||||
'data': {'channel': 'chat.$chatId'},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Skip all other Pusher internal events ─────────────────────────────
|
||||
if (event.startsWith('pusher')) return;
|
||||
if (event.startsWith('pusher_internal')) return;
|
||||
|
||||
// ── Refresh Riverpod badge count ──────────────────────────────────────
|
||||
try {
|
||||
_container?.read(notificationTriggerProvider.notifier).state++;
|
||||
} catch (_) {}
|
||||
|
||||
// ── Determine which chat this message belongs to ──────────────────────
|
||||
String? incomingChatId;
|
||||
final channelStr = msg['channel']?.toString() ?? '';
|
||||
if (channelStr.startsWith('chat.')) {
|
||||
incomingChatId = channelStr.split('.').last;
|
||||
}
|
||||
|
||||
// ── Parse the payload ─────────────────────────────────────────────────
|
||||
final rawData = msg['data'];
|
||||
if (rawData == null) return;
|
||||
|
||||
dynamic data;
|
||||
try {
|
||||
data = rawData is String ? jsonDecode(rawData) : rawData;
|
||||
} catch (_) {
|
||||
data = rawData;
|
||||
}
|
||||
|
||||
// Flatten list → use first element
|
||||
if (data is List && data.isNotEmpty) {
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (data is! Map) {
|
||||
debugPrint('⚠️ NotificationWS: unrecognised data format, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
final dataMap = Map<String, dynamic>.from(data);
|
||||
debugPrint('🔔 NotificationWS data keys: ${dataMap.keys.toList()}');
|
||||
|
||||
// ── Skip ping-only payloads {status, reload} ──────────────────────────
|
||||
// The backend first fires a lightweight notification-trigger event that
|
||||
// only contains `status` and `reload`. There is nothing to display yet;
|
||||
// the full message list arrives in the next event on the same channel.
|
||||
final onlyStatusReload =
|
||||
dataMap.containsKey('status') &&
|
||||
dataMap.containsKey('reload') &&
|
||||
!dataMap.containsKey('message') &&
|
||||
!dataMap.containsKey('chat_by');
|
||||
if (onlyStatusReload) {
|
||||
debugPrint('🔔 NotificationWS: reload ping received, badge refreshed');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Skip user's own messages ──────────────────────────────────────────
|
||||
final chatBy = dataMap['chat_by']?.toString().toLowerCase() ?? '';
|
||||
if (chatBy == 'user' || chatBy == 'customer') {
|
||||
debugPrint('🔕 NotificationWS: own message, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Extract routing info if needed for internal state ─────────────
|
||||
final String? page =
|
||||
dataMap['page']?.toString() ?? dataMap['type']?.toString();
|
||||
final String? pageId =
|
||||
dataMap['page_id']?.toString() ?? dataMap['pageId']?.toString();
|
||||
|
||||
if (page == null || page.isEmpty || pageId == null || pageId.isEmpty) {
|
||||
// Chat Notification fallback
|
||||
incomingChatId ??=
|
||||
dataMap['chat_id']?.toString() ??
|
||||
dataMap['chatId']?.toString() ??
|
||||
(dataMap['parent_tag'] != null && dataMap['parent_tag'] is Map
|
||||
? dataMap['parent_tag']['id']?.toString()
|
||||
: null) ??
|
||||
dataMap['id']?.toString();
|
||||
|
||||
if (incomingChatId != null && incomingChatId != '0') {
|
||||
_subscribeToChatChannel(incomingChatId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show Foreground Notification ─────────────────────────────────────
|
||||
// Only show if it's NOT a message for the currently active chat screen.
|
||||
// Pass incomingChatId as the dedup tag so that if FCM already fired a
|
||||
// notification for this same chat, this one *replaces* it on Android
|
||||
// instead of showing a second notification.
|
||||
if (incomingChatId != _activeChatId) {
|
||||
// Prefer the sender name from dataMap for the title.
|
||||
final String senderTitle =
|
||||
dataMap['sender_name']?.toString() ??
|
||||
dataMap['name']?.toString() ??
|
||||
dataMap['chat_by']?.toString() ??
|
||||
'New Message';
|
||||
|
||||
final RemoteMessage fcmLikeMessage = RemoteMessage(
|
||||
data: dataMap,
|
||||
notification: RemoteNotification(
|
||||
title: senderTitle,
|
||||
body: dataMap['message']?.toString() ?? 'You have a new message',
|
||||
),
|
||||
);
|
||||
|
||||
Get.find<NotificationService>().showForegroundNotification(
|
||||
fcmLikeMessage,
|
||||
tag: incomingChatId, // dedup key – same chat collapses into 1 notif
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ NotificationWS _handleMessage error: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
void _sendRaw(Map<String, dynamic> message) {
|
||||
try {
|
||||
_channel?.sink.add(jsonEncode(message));
|
||||
} catch (e) {
|
||||
debugPrint('❌ NotificationWS send error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────── Reconnect ────────────────────────────────────────
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
debugPrint('❌ NotificationWS error: $error');
|
||||
_isConnected = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _handleDone() {
|
||||
debugPrint('🔌 NotificationWS: connection closed');
|
||||
_isConnected = false;
|
||||
if (!_isIntentionalClose) _scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_isIntentionalClose || _reconnectAttempts >= _maxReconnectAttempts) {
|
||||
return;
|
||||
}
|
||||
_reconnectAttempts++;
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2);
|
||||
debugPrint(
|
||||
'🔄 NotificationWS: reconnect #$_reconnectAttempts in ${delay.inSeconds}s',
|
||||
);
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
if (!_isIntentionalClose) _openSocket();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isIntentionalClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
await _subscription?.cancel();
|
||||
await _channel?.sink.close();
|
||||
_isConnected = false;
|
||||
_subscribedChatChannelId = null;
|
||||
debugPrint('🔌 NotificationWS: disconnected');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Responsive utility class for all screens
|
||||
class ResponsiveUtils {
|
||||
final BuildContext context;
|
||||
|
||||
ResponsiveUtils(this.context);
|
||||
|
||||
// Get screen width
|
||||
double get screenWidth => MediaQuery.of(context).size.width;
|
||||
|
||||
// Get screen height
|
||||
double get screenHeight => MediaQuery.of(context).size.height;
|
||||
|
||||
// Device type checks
|
||||
bool get isMobile => screenWidth < 600;
|
||||
bool get isTablet => screenWidth >= 600 && screenWidth < 900;
|
||||
bool get isDesktop => screenWidth >= 900;
|
||||
|
||||
// Responsive width percentage
|
||||
double widthPercent(double percent) => screenWidth * (percent / 100);
|
||||
|
||||
// Responsive height percentage
|
||||
double heightPercent(double percent) => screenHeight * (percent / 100);
|
||||
|
||||
// Responsive font size
|
||||
double fontSize({required double mobile, double? tablet, double? desktop}) {
|
||||
if (isDesktop && desktop != null) return desktop;
|
||||
if (isTablet && tablet != null) return tablet;
|
||||
return mobile;
|
||||
}
|
||||
|
||||
// Responsive spacing
|
||||
double spacing({required double mobile, double? tablet, double? desktop}) {
|
||||
if (isDesktop && desktop != null) return desktop;
|
||||
if (isTablet && tablet != null) return tablet;
|
||||
return mobile;
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
EdgeInsets padding({
|
||||
required double mobile,
|
||||
double? tablet,
|
||||
double? desktop,
|
||||
}) {
|
||||
final value = spacing(mobile: mobile, tablet: tablet, desktop: desktop);
|
||||
return EdgeInsets.all(value);
|
||||
}
|
||||
|
||||
// Responsive horizontal padding
|
||||
EdgeInsets horizontalPadding({
|
||||
required double mobile,
|
||||
double? tablet,
|
||||
double? desktop,
|
||||
}) {
|
||||
final value = spacing(mobile: mobile, tablet: tablet, desktop: desktop);
|
||||
return EdgeInsets.symmetric(horizontal: value);
|
||||
}
|
||||
|
||||
// Responsive vertical padding
|
||||
EdgeInsets verticalPadding({
|
||||
required double mobile,
|
||||
double? tablet,
|
||||
double? desktop,
|
||||
}) {
|
||||
final value = spacing(mobile: mobile, tablet: tablet, desktop: desktop);
|
||||
return EdgeInsets.symmetric(vertical: value);
|
||||
}
|
||||
|
||||
// Responsive border radius
|
||||
double borderRadius({
|
||||
required double mobile,
|
||||
double? tablet,
|
||||
double? desktop,
|
||||
}) {
|
||||
if (isDesktop && desktop != null) return desktop;
|
||||
if (isTablet && tablet != null) return tablet;
|
||||
return mobile;
|
||||
}
|
||||
|
||||
// Card width for list items
|
||||
double cardWidth({
|
||||
double mobilePercent = 70,
|
||||
double tabletPercent = 45,
|
||||
double desktopPercent = 30,
|
||||
}) {
|
||||
if (isDesktop) return widthPercent(desktopPercent);
|
||||
if (isTablet) return widthPercent(tabletPercent);
|
||||
return widthPercent(mobilePercent);
|
||||
}
|
||||
|
||||
// Get value based on screen size
|
||||
T getValue<T>({required T mobile, T? tablet, T? desktop}) {
|
||||
if (isDesktop && desktop != null) return desktop;
|
||||
if (isTablet && tablet != null) return tablet;
|
||||
return mobile;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension on BuildContext for easy access
|
||||
extension ResponsiveExtension on BuildContext {
|
||||
ResponsiveUtils get responsive => ResponsiveUtils(this);
|
||||
|
||||
// Quick access methods
|
||||
double get screenWidth => MediaQuery.of(this).size.width;
|
||||
double get screenHeight => MediaQuery.of(this).size.height;
|
||||
bool get isMobile => screenWidth < 600;
|
||||
bool get isTablet => screenWidth >= 600 && screenWidth < 900;
|
||||
bool get isDesktop => screenWidth >= 900;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:taxglide/consts/app_asstes.dart';
|
||||
import 'package:taxglide/consts/app_colors.dart';
|
||||
|
||||
class ValidationPopup {
|
||||
// Show SnackBar
|
||||
void _showSnackBar(BuildContext context, String msg, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: isError
|
||||
? Colors.red
|
||||
: AppColors.commanbutton, // 🔴 Error red
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 70, vertical: 25),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
content: SizedBox(
|
||||
width: 200,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(AppAssets.taxgildelogoauth, height: 18, width: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
msg,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate Mobile Number
|
||||
bool validateMobileNumber(BuildContext context, String mobileNumber) {
|
||||
// Check if empty
|
||||
if (mobileNumber.isEmpty) {
|
||||
_showSnackBar(context, "Please enter mobile number", isError: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove spaces and special characters
|
||||
String cleanNumber = mobileNumber.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Check if contains only digits
|
||||
if (!RegExp(r'^[0-9]+$').hasMatch(cleanNumber)) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
"Mobile number must contain only digits",
|
||||
isError: true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check length (Indian mobile numbers are 10 digits)
|
||||
if (cleanNumber.length != 10) {
|
||||
_showSnackBar(context, "Mobile number must be 10 digits", isError: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if starts with valid digit (6-9 for Indian numbers)
|
||||
if (!RegExp(r'^[6-9]').hasMatch(cleanNumber)) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
"Mobile number must start with 6, 7, 8, or 9",
|
||||
isError: true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// All validations passed
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add this method to your ValidationPopup class
|
||||
bool validateEmail(BuildContext context, String email) {
|
||||
// Email regex pattern
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
if (!emailRegex.hasMatch(email)) {
|
||||
showErrorMessage(context, "Please enter a valid email address");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
void showSuccessMessage(BuildContext context, String msg) {
|
||||
_showSnackBar(context, msg, isError: false);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
void showErrorMessage(BuildContext context, String msg) {
|
||||
_showSnackBar(context, msg, isError: true);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user