2026-04-11

This commit is contained in:
Sarankumar committed 2026-04-11 10:21:31 +05:30
commit 27e27fb70b
270 files changed
+30296

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/comman_textformfileds.dart';
import 'package:taxglide/consts/responsive_helper.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
class EmployeeLoginScreen extends ConsumerStatefulWidget {
const EmployeeLoginScreen({super.key});
@override
ConsumerState<EmployeeLoginScreen> createState() =>
_EmployeeLoginScreenState();
}
class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
final ValidationPopup _validationPopup = ValidationPopup();
final TextEditingController _mobileController = TextEditingController();
@override
void initState() {
super.initState();
final args = Get.arguments;
if (args != null && args is Map<String, dynamic>) {
final mobile = args['mobile'] ?? '';
if (mobile.isNotEmpty) {
_mobileController.text = mobile;
}
}
}
@override
void dispose() {
_mobileController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final mobile = _mobileController.text.trim();
if (!_validationPopup.validateMobileNumber(context, mobile)) {
return;
}
await ref.read(employeeloginProvider.notifier).login(mobile);
final state = ref.read(employeeloginProvider);
state.when(
data: (data) {
if (data['success'] == true) {
Get.toNamed(ConstRouters.employeeotp, arguments: {'mobile': mobile});
_validationPopup.showSuccessMessage(
context,
"OTP sent successfully!",
);
} else if (data['error'] != null) {
_validationPopup.showErrorMessage(context, data['error'].toString());
}
},
loading: () {},
error: (err, _) {
_validationPopup.showErrorMessage(context, "Error: $err");
},
);
}
@override
Widget build(BuildContext context) {
final loginState = ref.watch(employeeloginProvider);
// Initialize responsive utils
final r = ResponsiveUtils(context);
// Responsive values
final logoWidth = r.getValue<double>(
mobile: 120,
tablet: 141,
desktop: 160,
);
final logoHeight = r.getValue<double>(
mobile: 85,
tablet: 100,
desktop: 115,
);
final titleFontSize = r.fontSize(mobile: 26, tablet: 32, desktop: 36);
final subtitleFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final termsFontSize = r.fontSize(mobile: 10.5, tablet: 11.34, desktop: 12);
final linkFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final otherLoginFontSize = r.fontSize(mobile: 12, tablet: 13, desktop: 14);
final spacingXS = r.spacing(mobile: 10, tablet: 10, desktop: 12);
final spacingSM = r.spacing(mobile: 15, tablet: 20, desktop: 24);
final spacingMD = r.spacing(mobile: 20, tablet: 20, desktop: 24);
final spacingLG = r.spacing(mobile: 20, tablet: 22, desktop: 28);
return Scaffold(
resizeToAvoidBottomInset: true,
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: r.screenHeight),
child: IntrinsicHeight(
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo
Image.asset(
AppAssets.taxgildelogoauth,
width: logoWidth,
height: logoHeight,
fit: BoxFit.contain,
),
SizedBox(height: spacingMD),
// Login Title
Text(
"Login",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: titleFontSize,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: 0.01 * titleFontSize,
color: AppColors.authheading,
),
),
SizedBox(height: spacingMD),
// Subtitle
Text(
"Enter your registered Mobile Number",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: subtitleFontSize,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: 0.03 * subtitleFontSize,
color: AppColors.authleading,
),
),
SizedBox(height: spacingLG),
// Mobile Number Input
CommanTextFormField(
controller: _mobileController,
hintText: 'Enter your Mobile Number',
keyboardType: TextInputType.phone,
prefixIcon: Icons.phone_android,
),
SizedBox(height: spacingLG),
// Terms and Conditions
Text.rich(
TextSpan(
text: "By signing up, you agree to the ",
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: termsFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authleading,
),
children: [
TextSpan(
text: "Terms of Service ",
style: TextStyle(
fontFamily: 'Gilroy-Bold',
fontWeight: FontWeight.w700,
fontSize: termsFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authtermsandcondition,
),
),
TextSpan(
text: "and ",
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: termsFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authleading,
),
),
TextSpan(
text: "Data Processing Agreement",
style: TextStyle(
fontFamily: 'Gilroy-Bold',
fontWeight: FontWeight.w700,
fontSize: termsFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authtermsandcondition,
),
),
],
),
textAlign: TextAlign.center,
),
SizedBox(height: spacingLG),
// Login Button or Loading
loginState.isLoading
? const CircularProgressIndicator()
: CommanButton(
text: "Login",
onPressed: _handleLogin,
),
SizedBox(height: spacingSM),
Text(
"Other Login",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
fontSize: otherLoginFontSize,
height: 1.8,
letterSpacing: 0.13,
color: const Color(0xFF6C7278),
),
),
SizedBox(height: spacingXS),
Text.rich(
TextSpan(
text: "User Login? ",
style: TextStyle(fontSize: linkFontSize),
children: [
TextSpan(
text: "Click Here",
style: TextStyle(
fontSize: linkFontSize,
color: AppColors.authsignup,
fontWeight: FontWeight.bold,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(ConstRouters.login);
},
),
],
),
),
],
),
),
),
),
),
),
),
);
}
}
+420
View File
@@ -0,0 +1,420 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/responsive_helper.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
import '../router/consts_routers.dart';
class EmployeeOtpScreen extends ConsumerStatefulWidget {
const EmployeeOtpScreen({super.key});
@override
ConsumerState<EmployeeOtpScreen> createState() => _EmployeeOtpScreenState();
}
class _EmployeeOtpScreenState extends ConsumerState<EmployeeOtpScreen> {
final ValidationPopup _validationPopup = ValidationPopup();
final int otpLength = 4;
final List<TextEditingController> _controllers = [];
final List<FocusNode> _focusNodes = [];
String otpValue = '';
Timer? _timer;
int _remainingSeconds = 600;
bool _canResend = false;
late String mobile;
@override
void initState() {
super.initState();
final args = Get.arguments as Map<String, dynamic>?;
mobile = args?['mobile'] ?? '';
for (int i = 0; i < otpLength; i++) {
_controllers.add(TextEditingController());
_focusNodes.add(FocusNode());
}
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
for (var c in _controllers) c.dispose();
for (var f in _focusNodes) f.dispose();
super.dispose();
}
void _startTimer() {
_canResend = false;
_remainingSeconds = 30;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_remainingSeconds > 0) {
setState(() => _remainingSeconds--);
} else {
setState(() => _canResend = true);
timer.cancel();
}
});
}
String _formatTime(int seconds) {
int minutes = seconds ~/ 60;
int remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
Future<void> _resendOtp() async {
if (!_canResend) return;
for (var controller in _controllers) controller.clear();
setState(() => otpValue = '');
_focusNodes[0].requestFocus();
try {
await ref.read(employeeloginProvider.notifier).login(mobile);
final loginState = ref.read(employeeloginProvider);
loginState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"OTP has been resent successfully!",
);
_startTimer();
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Failed to resend OTP",
);
}
},
loading: () {},
error: (error, _) {
_validationPopup.showErrorMessage(
context,
"Failed to resend OTP. Please try again.",
);
},
);
} catch (e) {
_validationPopup.showErrorMessage(
context,
"An error occurred. Please try again.",
);
}
}
Future<void> _verifyOtp() async {
if (otpValue.length != otpLength) {
_validationPopup.showErrorMessage(context, "Please enter all OTP digits");
return;
}
await ref.read(employeeloginProvider.notifier).verifyOtp(mobile, otpValue);
final loginState = ref.read(employeeloginProvider);
loginState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"OTP Verified Successfully!",
);
Future.delayed(const Duration(seconds: 1), () {
Get.offAll(() => const MainController());
});
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Invalid OTP. Please try again.",
);
}
},
loading: () {},
error: (error, _) {
_validationPopup.showErrorMessage(
context,
"Failed to verify OTP. Please try again.",
);
},
);
}
void _onOtpChange(int index, String value) {
if (value.isNotEmpty && index < otpLength - 1) {
_focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
setState(() {
otpValue = _controllers.map((c) => c.text).join();
});
}
@override
Widget build(BuildContext context) {
final loginState = ref.watch(employeeloginProvider);
final isLoading = loginState.isLoading;
// Initialize responsive utils
final r = ResponsiveUtils(context);
// Responsive values
final logoWidth = r.getValue<double>(
mobile: 120,
tablet: 141,
desktop: 160,
);
final logoHeight = r.getValue<double>(
mobile: 85,
tablet: 100,
desktop: 115,
);
final titleFontSize = r.fontSize(mobile: 26, tablet: 32, desktop: 36);
final subtitleFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final mobileFontSize = r.fontSize(mobile: 12, tablet: 13, desktop: 14);
final resendFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final timerFontSize = r.fontSize(mobile: 14, tablet: 16, desktop: 18);
final otpBoxWidth = r.getValue<double>(mobile: 52, tablet: 60, desktop: 68);
final otpBoxHeight = r.getValue<double>(
mobile: 48,
tablet: 56,
desktop: 64,
);
final otpFontSize = r.fontSize(mobile: 22, tablet: 28, desktop: 32);
final otpBoxMargin = r.getValue<double>(mobile: 5, tablet: 6, desktop: 8);
final otpBoxRadius = r.getValue<double>(mobile: 5, tablet: 6, desktop: 8);
final spacingXS = r.spacing(mobile: 10, tablet: 10, desktop: 12);
final spacingSM = r.spacing(mobile: 20, tablet: 20, desktop: 24);
final spacingMD = r.spacing(mobile: 22, tablet: 22, desktop: 26);
final spacingLG = r.spacing(mobile: 30, tablet: 30, desktop: 36);
return Scaffold(
body: Stack(
children: [
// Gradient background
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
),
// Scrollable content
SingleChildScrollView(
child: SizedBox(
height: r.screenHeight,
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo
Image.asset(
AppAssets.taxgildelogoauth,
width: logoWidth,
height: logoHeight,
fit: BoxFit.contain,
),
SizedBox(height: spacingSM),
// Title
Text(
"Enter OTP",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: titleFontSize,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: 0.01 * titleFontSize,
color: AppColors.authheading,
),
),
SizedBox(height: spacingSM),
// Subtitle
Text(
"OTP has been sent to your registered mobile number",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: subtitleFontSize,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: 0.03 * subtitleFontSize,
color: AppColors.authleading,
),
),
SizedBox(height: spacingMD),
// Mobile + Change Number
Text.rich(
TextSpan(
text: mobile,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: mobileFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authleading,
),
children: [
TextSpan(
text: " ( Change Number )",
style: TextStyle(
fontFamily: 'Gilroy-ExtraBold',
fontWeight: FontWeight.w800,
fontSize: mobileFontSize,
height: 1.8,
letterSpacing: 0.04,
color: AppColors.authchange,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(
ConstRouters.employeelogin,
arguments: {'mobile': mobile},
);
},
),
],
),
textAlign: TextAlign.center,
),
SizedBox(height: spacingLG),
// OTP Input Fields
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(otpLength, (index) {
bool isFilled = _controllers[index].text.isNotEmpty;
return Container(
margin: EdgeInsets.symmetric(
horizontal: otpBoxMargin,
),
width: otpBoxWidth,
height: otpBoxHeight,
decoration: BoxDecoration(
color: isFilled
? AppColors.commanbutton
: Colors.white,
borderRadius: BorderRadius.circular(otpBoxRadius),
border: Border.all(
color: const Color(0xFFDFDFDF),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFFBDBDBD,
).withOpacity(0.25),
blurRadius: 7,
offset: const Offset(0, 1),
),
],
),
child: Center(
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
enabled: !isLoading,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w400,
fontSize: otpFontSize,
letterSpacing: 0.03 * otpFontSize,
color: isFilled ? Colors.white : Colors.black,
),
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
),
onChanged: (value) =>
_onOtpChange(index, value),
),
),
);
}),
),
SizedBox(height: spacingXS),
// Resend OTP
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: _canResend && !isLoading ? _resendOtp : null,
child: Text(
"Resend",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: resendFontSize,
height: 1.4,
letterSpacing: 0.02 * resendFontSize,
color: _canResend && !isLoading
? AppColors.authchange
: Colors.grey,
),
),
),
),
SizedBox(height: spacingSM),
// Timer
Text(
_formatTime(_remainingSeconds),
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w600,
fontSize: timerFontSize,
color: _remainingSeconds > 0
? AppColors.authheading
: Colors.red,
),
),
SizedBox(height: spacingLG),
// Verify Button
isLoading
? const CircularProgressIndicator()
: CommanButton(text: "Verify", onPressed: _verifyOtp),
],
),
),
),
),
),
],
),
);
}
}
+343
View File
@@ -0,0 +1,343 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/comman_textformfileds.dart';
import 'package:taxglide/consts/comman_popup.dart';
import 'package:taxglide/consts/responsive_helper.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final TextEditingController _mobileController = TextEditingController();
final ValidationPopup _validationPopup = ValidationPopup();
@override
void initState() {
super.initState();
final args = Get.arguments;
if (args != null && args is Map<String, dynamic>) {
final mobile = args['mobile'] ?? '';
if (mobile.isNotEmpty) {
_mobileController.text = mobile;
}
}
}
@override
void dispose() {
_mobileController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final mobile = _mobileController.text.trim();
if (!_validationPopup.validateMobileNumber(context, mobile)) {
return;
}
await ref.read(loginProvider.notifier).login(mobile);
final state = ref.read(loginProvider);
state.when(
data: (data) {
if (data['success'] == true) {
Get.toNamed(ConstRouters.otp, arguments: {'mobile': mobile});
_validationPopup.showSuccessMessage(
context,
"OTP sent successfully!",
);
} else if (data['error'] != null) {
_validationPopup.showErrorMessage(context, data['error'].toString());
}
},
loading: () {},
error: (err, _) {
_validationPopup.showErrorMessage(context, "Error: $err");
},
);
}
@override
Widget build(BuildContext context) {
final loginState = ref.watch(loginProvider);
final policyAsync = ref.watch(policyProvider);
final termsAsync = ref.watch(termsProvider);
// Initialize responsive utils
final r = ResponsiveUtils(context);
// Responsive values
final logoWidth = r.getValue<double>(
mobile: 120,
tablet: 141,
desktop: 160,
);
final logoHeight = r.getValue<double>(
mobile: 85,
tablet: 100,
desktop: 115,
);
final titleFontSize = r.fontSize(mobile: 26, tablet: 32, desktop: 36);
final subtitleFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final termsFontSize = r.fontSize(mobile: 10.5, tablet: 11.34, desktop: 12);
final signupFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final otherLoginFontSize = r.fontSize(mobile: 12, tablet: 13, desktop: 14);
final spacingXS = r.spacing(mobile: 10, tablet: 15, desktop: 18);
final spacingSM = r.spacing(mobile: 15, tablet: 20, desktop: 24);
final spacingMD = r.spacing(mobile: 20, tablet: 22, desktop: 26);
final spacingLG = r.spacing(mobile: 20, tablet: 22, desktop: 28);
return Scaffold(
resizeToAvoidBottomInset: true,
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: r.screenHeight),
child: IntrinsicHeight(
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo
Image.asset(
AppAssets.taxgildelogoauth,
width: logoWidth,
height: logoHeight,
),
SizedBox(height: spacingSM),
// Title
Text(
"Login",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: titleFontSize,
fontWeight: FontWeight.w600,
color: AppColors.authheading,
),
),
SizedBox(height: spacingSM),
// Subtitle
Text(
"Enter your Mobile Number",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: subtitleFontSize,
color: AppColors.authleading,
),
),
SizedBox(height: spacingLG),
// Mobile TextField
CommanTextFormField(
controller: _mobileController,
hintText: 'Enter your Mobile Number',
keyboardType: TextInputType.phone,
prefixIcon: Icons.mobile_screen_share,
),
SizedBox(height: spacingLG),
// Terms and Policy
Text.rich(
TextSpan(
text: "By signing up, you agree to the ",
style: TextStyle(
fontSize: termsFontSize,
color: AppColors.authleading,
),
children: [
TextSpan(
text: "Terms of Service ",
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: termsFontSize,
color: AppColors.authtermsandcondition,
),
recognizer: TapGestureRecognizer()
..onTap = () {
termsAsync.when(
data: (terms) {
CommonInfoPopup.show(
context: context,
title:
terms.data?.title ??
"Terms of Service",
content:
terms.data?.content ??
"No terms found.",
);
},
loading: () {
_validationPopup.showErrorMessage(
context,
"Loading Terms...",
);
},
error: (err, _) {
_validationPopup.showErrorMessage(
context,
"Failed to load Terms",
);
},
);
},
),
const TextSpan(text: "and "),
TextSpan(
text: "Data Processing Agreement",
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: termsFontSize,
color: AppColors.authtermsandcondition,
),
recognizer: TapGestureRecognizer()
..onTap = () {
policyAsync.when(
data: (policy) {
CommonInfoPopup.show(
context: context,
title:
policy.data?.title ??
"Data Processing Agreement",
content:
policy.data?.content ??
"No policy found.",
);
},
loading: () {
_validationPopup.showErrorMessage(
context,
"Loading Policy...",
);
},
error: (err, _) {
_validationPopup.showErrorMessage(
context,
"Failed to load Policy",
);
},
);
},
),
],
),
textAlign: TextAlign.center,
),
SizedBox(height: spacingSM),
const Text("Or"),
SizedBox(height: spacingSM),
// Sign up
Text.rich(
TextSpan(
text: "Didn't Have account? ",
style: TextStyle(fontSize: signupFontSize),
children: [
TextSpan(
text: "Sign Up",
style: TextStyle(
fontSize: signupFontSize,
color: AppColors.authsignup,
fontWeight: FontWeight.bold,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(ConstRouters.signup);
},
),
],
),
),
SizedBox(height: spacingLG),
// Login Button
loginState.isLoading
? const CircularProgressIndicator()
: CommanButton(
text: "Login",
onPressed: _handleLogin,
),
SizedBox(height: spacingMD),
Text(
"Other Login",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
fontSize: otherLoginFontSize,
height: 1.8,
letterSpacing: 0.13,
color: const Color(0xFF6C7278),
),
),
SizedBox(height: spacingXS),
Text.rich(
TextSpan(
text: "Staff Login? ",
style: TextStyle(fontSize: signupFontSize),
children: [
TextSpan(
text: "Click Here",
style: TextStyle(
fontSize: signupFontSize,
color: AppColors.authsignup,
fontWeight: FontWeight.bold,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(ConstRouters.employeelogin);
},
),
],
),
),
],
),
),
),
),
),
),
),
);
}
}
+422
View File
@@ -0,0 +1,422 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/responsive_helper.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
class OtpScreen extends ConsumerStatefulWidget {
const OtpScreen({super.key});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final ValidationPopup _validationPopup = ValidationPopup();
final int otpLength = 4;
final List<TextEditingController> _controllers = [];
final List<FocusNode> _focusNodes = [];
String otpValue = '';
Timer? _timer;
int _remainingSeconds = 600;
bool _canResend = false;
late String mobile;
@override
void initState() {
super.initState();
final args = Get.arguments as Map<String, dynamic>;
mobile = args['mobile'] ?? '';
for (int i = 0; i < otpLength; i++) {
_controllers.add(TextEditingController());
_focusNodes.add(FocusNode());
}
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
for (var c in _controllers) c.dispose();
for (var f in _focusNodes) f.dispose();
super.dispose();
}
void _startTimer() {
_canResend = false;
_remainingSeconds = 30;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_remainingSeconds > 0) {
setState(() => _remainingSeconds--);
} else {
setState(() => _canResend = true);
timer.cancel();
}
});
}
String _formatTime(int seconds) {
int minutes = seconds ~/ 60;
int remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
void _resendOtp() async {
if (!_canResend) return;
for (var controller in _controllers) controller.clear();
setState(() => otpValue = '');
_focusNodes[0].requestFocus();
try {
await ref.read(loginProvider.notifier).login(mobile);
final loginState = ref.read(loginProvider);
loginState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"OTP has been resent successfully!",
);
_startTimer();
for (var controller in _controllers) controller.clear();
setState(() => otpValue = '');
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Failed to resend OTP",
);
}
},
loading: () {},
error: (error, stack) {
_validationPopup.showErrorMessage(
context,
"Failed to resend OTP. Please try again.",
);
},
);
} catch (e) {
_validationPopup.showErrorMessage(
context,
"An error occurred. Please try again.",
);
}
}
void _verifyOtp() async {
if (otpValue.length != otpLength) {
_validationPopup.showErrorMessage(context, "Please enter all OTP digits");
return;
}
await ref.read(loginProvider.notifier).verifyOtp(mobile, otpValue);
final loginState = ref.read(loginProvider);
loginState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"OTP Verified Successfully!",
);
Future.delayed(const Duration(seconds: 1), () {
Get.offAll(() => MainController());
});
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Invalid OTP. Please try again.",
);
}
},
loading: () {},
error: (error, stack) {
_validationPopup.showErrorMessage(
context,
"Failed to verify OTP. Please try again.",
);
},
);
}
void _onOtpChange(int index, String value) {
if (value.isNotEmpty && index < otpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
setState(() {
otpValue = _controllers.map((c) => c.text).join();
});
}
@override
Widget build(BuildContext context) {
final loginState = ref.watch(loginProvider);
final isLoading = loginState is AsyncLoading;
// Initialize responsive utils
final r = ResponsiveUtils(context);
// Responsive values
final logoWidth = r.getValue<double>(
mobile: 120,
tablet: 141,
desktop: 160,
);
final logoHeight = r.getValue<double>(
mobile: 85,
tablet: 100,
desktop: 115,
);
final titleFontSize = r.fontSize(mobile: 26, tablet: 32, desktop: 36);
final subtitleFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final mobileFontSize = r.fontSize(mobile: 12, tablet: 13, desktop: 14);
final resendFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final timerFontSize = r.fontSize(mobile: 14, tablet: 16, desktop: 18);
final otpBoxWidth = r.getValue<double>(mobile: 52, tablet: 60, desktop: 68);
final otpBoxHeight = r.getValue<double>(
mobile: 48,
tablet: 56,
desktop: 64,
);
final otpFontSize = r.fontSize(mobile: 22, tablet: 28, desktop: 32);
final otpBoxMargin = r.getValue<double>(mobile: 5, tablet: 6, desktop: 8);
final otpBoxRadius = r.getValue<double>(mobile: 5, tablet: 6, desktop: 8);
final spacingXS = r.spacing(mobile: 10, tablet: 10, desktop: 12);
final spacingSM = r.spacing(mobile: 20, tablet: 20, desktop: 24);
final spacingMD = r.spacing(mobile: 22, tablet: 22, desktop: 26);
final spacingLG = r.spacing(mobile: 30, tablet: 30, desktop: 36);
return Scaffold(
body: Stack(
children: [
// Background gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
),
// Scrollable content
SingleChildScrollView(
child: SizedBox(
height: r.screenHeight,
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo
Image.asset(
AppAssets.taxgildelogoauth,
width: logoWidth,
height: logoHeight,
fit: BoxFit.contain,
),
SizedBox(height: spacingSM),
// Title
Text(
"Enter OTP",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: titleFontSize,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: 0.01 * titleFontSize,
color: AppColors.authheading,
),
),
SizedBox(height: spacingSM),
// Subtitle
Text(
"OTP has been sent to your registered mobile number",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: subtitleFontSize,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: 0.03 * subtitleFontSize,
color: AppColors.authleading,
),
),
SizedBox(height: spacingMD),
// Mobile number + Change
Text.rich(
TextSpan(
text: mobile,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: mobileFontSize,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authleading,
),
children: [
TextSpan(
text: " ( Change Number )",
style: TextStyle(
fontFamily: 'Gilroy-ExtraBold',
fontWeight: FontWeight.w800,
fontSize: mobileFontSize,
height: 1.8,
letterSpacing: 0.04,
color: AppColors.authchange,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(
ConstRouters.login,
arguments: {'mobile': mobile},
);
},
),
],
),
textAlign: TextAlign.center,
),
SizedBox(height: spacingLG),
// OTP Boxes
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(otpLength, (index) {
bool isFilled = _controllers[index].text.isNotEmpty;
return Container(
margin: EdgeInsets.symmetric(
horizontal: otpBoxMargin,
),
width: otpBoxWidth,
height: otpBoxHeight,
decoration: BoxDecoration(
color: isFilled
? AppColors.commanbutton
: Colors.white,
borderRadius: BorderRadius.circular(otpBoxRadius),
border: Border.all(
color: const Color(0xFFDFDFDF),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFFBDBDBD,
).withOpacity(0.25),
blurRadius: 7,
offset: const Offset(0, 1),
),
],
),
child: Center(
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
enabled: !isLoading,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w400,
fontSize: otpFontSize,
letterSpacing: 0.03 * otpFontSize,
color: isFilled ? Colors.white : Colors.black,
),
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
),
onChanged: (value) =>
_onOtpChange(index, value),
),
),
);
}),
),
SizedBox(height: spacingXS),
// Resend
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: _canResend && !isLoading ? _resendOtp : null,
child: Text(
"Resend",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: resendFontSize,
height: 1.4,
letterSpacing: 0.02 * resendFontSize,
color: _canResend && !isLoading
? AppColors.authchange
: Colors.grey,
),
),
),
),
SizedBox(height: spacingSM),
// Timer
Text(
_formatTime(_remainingSeconds),
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w600,
fontSize: timerFontSize,
color: _remainingSeconds > 0
? AppColors.authheading
: Colors.red,
),
),
SizedBox(height: spacingLG),
// Verify Button
isLoading
? const CircularProgressIndicator()
: CommanButton(text: "Verify", onPressed: _verifyOtp),
],
),
),
),
),
),
],
),
);
}
}
+419
View File
@@ -0,0 +1,419 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
class RegisterOtpScreen extends ConsumerStatefulWidget {
const RegisterOtpScreen({super.key});
@override
ConsumerState<RegisterOtpScreen> createState() => _RegisterOtpScreenState();
}
class _RegisterOtpScreenState extends ConsumerState<RegisterOtpScreen> {
final ValidationPopup _validationPopup = ValidationPopup();
final int otpLength = 4;
final List<TextEditingController> _controllers = [];
final List<FocusNode> _focusNodes = [];
String otpValue = '';
// Timer variables
Timer? _timer;
int _remainingSeconds = 600; // 10 minutes = 600 seconds
bool _canResend = false;
// Get arguments passed from signup
late String name;
late String email;
late String mobile;
@override
void initState() {
super.initState();
// Get arguments from navigation
final args = Get.arguments as Map<String, dynamic>;
name = args['name'] ?? '';
email = args['email'] ?? '';
mobile = args['mobile'] ?? '';
for (int i = 0; i < otpLength; i++) {
_controllers.add(TextEditingController());
_focusNodes.add(FocusNode());
}
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
for (var c in _controllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
super.dispose();
}
void _startTimer() {
_canResend = false;
_remainingSeconds = 30; // Reset to 30 seconds
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_remainingSeconds > 0) {
setState(() {
_remainingSeconds--;
});
} else {
setState(() {
_canResend = true;
});
timer.cancel();
}
});
}
String _formatTime(int seconds) {
int minutes = seconds ~/ 60;
int remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
void _resendOtp() async {
if (!_canResend) return;
for (var controller in _controllers) {
controller.clear();
}
setState(() {
otpValue = '';
});
// Move focus to first field
_focusNodes[0].requestFocus();
try {
// Call signup API again to resend OTP
await ref.read(signupProvider.notifier).signup(name, mobile, email);
final signupState = ref.read(signupProvider);
signupState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"OTP has been resent successfully!",
);
_startTimer();
// Clear OTP fields
for (var controller in _controllers) {
controller.clear();
}
setState(() {
otpValue = '';
});
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Failed to resend OTP",
);
}
},
loading: () {
// Show loading indicator
},
error: (error, stack) {
_validationPopup.showErrorMessage(
context,
"Failed to resend OTP. Please try again.",
);
},
);
} catch (e) {
_validationPopup.showErrorMessage(
context,
"An error occurred. Please try again.",
);
}
}
void _verifyOtp() async {
if (otpValue.length != otpLength) {
_validationPopup.showErrorMessage(context, "Please enter all OTP digits");
return;
}
// Call verify OTP API for signup
await ref.read(signupProvider.notifier).verifySignupOtp(mobile, otpValue);
final signupState = ref.read(signupProvider);
signupState.when(
data: (result) {
if (result['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"Registration Successful!",
);
// Navigate to main screen after successful verification
Future.delayed(const Duration(seconds: 1), () {
Get.offAll(() => MainController());
});
} else {
_validationPopup.showErrorMessage(
context,
result['error'] ?? "Invalid OTP. Please try again.",
);
}
},
loading: () {
// Loading is handled by the provider
},
error: (error, stack) {
_validationPopup.showErrorMessage(
context,
"Failed to verify OTP. Please try again.",
);
},
);
}
void _onOtpChange(int index, String value) {
if (value.isNotEmpty && index < otpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
setState(() {
otpValue = _controllers.map((c) => c.text).join();
});
}
@override
Widget build(BuildContext context) {
final signupState = ref.watch(signupProvider);
final isLoading = signupState is AsyncLoading;
return Scaffold(
body: Stack(
children: [
// Background gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
),
// Scrollable content
SingleChildScrollView(
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
AppAssets.taxgildelogoauth,
width: 141,
height: 100,
fit: BoxFit.contain,
),
const SizedBox(height: 20),
Text(
"Enter OTP",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: 32,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: 0.01 * 32,
color: AppColors.authheading,
),
),
const SizedBox(height: 20),
Text(
"OTP has been sent to your registered mobile number",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: 0.03 * 14,
color: AppColors.authleading,
),
),
const SizedBox(height: 22),
Text.rich(
TextSpan(
text: mobile,
style: const TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: 13,
height: 1.8,
letterSpacing: 0.01,
color: AppColors.authleading,
),
children: [
TextSpan(
text: " ( Change Number )",
style: const TextStyle(
fontFamily: 'Gilroy-ExtraBold',
fontWeight: FontWeight.w800,
fontSize: 13,
height: 1.8,
letterSpacing: 0.04,
color: AppColors.authchange,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Get.offNamed(
ConstRouters.signup,
arguments: {
'name': name,
'email': email,
'mobile': mobile,
},
);
},
),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// OTP Boxes
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(otpLength, (index) {
bool isFilled = _controllers[index].text.isNotEmpty;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 6),
width: 60,
height: 56,
decoration: BoxDecoration(
color: isFilled
? AppColors.commanbutton
: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: const Color(0xFFDFDFDF),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFFBDBDBD,
).withOpacity(0.25),
blurRadius: 7,
offset: const Offset(0, 1),
),
],
),
child: Center(
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
enabled: !isLoading,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w400,
fontSize: 28,
letterSpacing: 0.03 * 28,
color: isFilled ? Colors.white : Colors.black,
),
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
),
onChanged: (value) =>
_onOtpChange(index, value),
),
),
);
}),
),
const SizedBox(height: 10),
// Resend OTP Button
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: _canResend && !isLoading ? _resendOtp : null,
child: Text(
"Resend",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: 14,
height: 1.4,
letterSpacing: 0.02 * 14,
color: _canResend && !isLoading
? AppColors.authchange
: Colors.grey,
),
),
),
),
const SizedBox(height: 20),
// Timer Display
Text(
_formatTime(_remainingSeconds),
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w600,
fontSize: 16,
color: _remainingSeconds > 0
? AppColors.authheading
: Colors.red,
),
),
const SizedBox(height: 30),
// Verify Button
isLoading
? const CircularProgressIndicator()
: CommanButton(text: "Verify", onPressed: _verifyOtp),
],
),
),
),
),
),
],
),
);
}
}
+329
View File
@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/comman_container_auth.dart';
import 'package:taxglide/consts/comman_textformfileds.dart';
import 'package:taxglide/consts/validation_popup.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
class SignupScreen extends ConsumerStatefulWidget {
const SignupScreen({super.key});
@override
ConsumerState<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends ConsumerState<SignupScreen> {
final ValidationPopup _validationPopup = ValidationPopup();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _mobileController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
@override
void initState() {
super.initState();
// Get arguments from navigation if available
final args = Get.arguments;
if (args != null && args is Map<String, dynamic>) {
_nameController.text = args['name'] ?? '';
_emailController.text = args['email'] ?? '';
_mobileController.text = args['mobile'] ?? '';
}
}
// Error state tracking
bool _nameError = false;
bool _mobileError = false;
bool _emailError = false;
@override
Widget build(BuildContext context) {
// Listen to signup state
final signupState = ref.watch(signupProvider);
// Handle API response
ref.listen<AsyncValue<Map<String, dynamic>>>(signupProvider, (
previous,
next,
) {
next.when(
data: (response) {
if (response.isNotEmpty) {
if (response['success'] == true) {
_validationPopup.showSuccessMessage(
context,
"Sign up successful! OTP sent to your mobile.",
);
// Navigate to RegisterOtpScreen with all data
Future.delayed(const Duration(milliseconds: 500), () {
Get.toNamed(
ConstRouters.registerOtp,
arguments: {
'name': _nameController.text,
'email': _emailController.text,
'mobile': _mobileController.text,
},
);
});
} else {
_validationPopup.showErrorMessage(
context,
response['error'] ?? 'Signup failed',
);
}
}
},
error: (error, stackTrace) {
_validationPopup.showErrorMessage(
context,
'An error occurred: ${error.toString()}',
);
},
loading: () {},
);
});
return Scaffold(
body: Stack(
children: [
// 🌈 Background gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF8F0),
Color(0xFFEBC894),
Color(0xFFE8DAF2),
Color(0xFFB49EF4),
],
stops: [0.0, 0.3, 0.6, 1.0],
),
),
),
// 📜 Scrollable content
SingleChildScrollView(
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Center(
child: CommonContainerAuth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
AppAssets.taxgildelogoauth,
width: 141,
height: 100,
fit: BoxFit.contain,
),
const SizedBox(height: 20),
// 🟣 Title
Text(
"Sign Up",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: 32,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: 0.01 * 32,
color: AppColors.authheading,
),
),
const SizedBox(height: 25),
// 👤 Name Field
Align(
alignment: Alignment.centerLeft,
child: Text(
"Name*",
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: 14,
color: AppColors.authleading,
),
),
),
const SizedBox(height: 8),
CommanTextFormField(
hintText: 'Enter Company Name',
controller: _nameController,
hasError: _nameError,
onChanged: (value) {
if (_nameError && value.isNotEmpty) {
setState(() {
_nameError = false;
});
}
},
),
const SizedBox(height: 10),
// 📱 Mobile Number Field
Align(
alignment: Alignment.centerLeft,
child: Text(
"Contact Number *",
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: 14,
color: AppColors.authleading,
),
),
),
const SizedBox(height: 8),
CommanTextFormField(
hintText: 'Enter Contact Number',
controller: _mobileController,
hasError: _mobileError,
keyboardType: TextInputType.phone,
onChanged: (value) {
if (_mobileError && value.isNotEmpty) {
setState(() {
_mobileError = false;
});
}
},
),
const SizedBox(height: 10),
// 📧 Email Field
Align(
alignment: Alignment.centerLeft,
child: Text(
"Email *",
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: 14,
color: AppColors.authleading,
),
),
),
const SizedBox(height: 8),
CommanTextFormField(
hintText: 'Enter Email ID',
controller: _emailController,
hasError: _emailError,
keyboardType: TextInputType.emailAddress,
onChanged: (value) {
if (_emailError && value.isNotEmpty) {
setState(() {
_emailError = false;
});
}
},
),
const SizedBox(height: 30),
// ✅ Submit Button
signupState.isLoading
? const CircularProgressIndicator()
: CommanButton(
text: "Submit",
onPressed: () {
_handleSignup();
},
),
],
),
),
),
),
),
],
),
);
}
void _handleSignup() {
// Reset all errors first
setState(() {
_nameError = false;
_mobileError = false;
_emailError = false;
});
bool hasError = false;
// Validate name
if (_nameController.text.isEmpty) {
setState(() {
_nameError = true;
});
hasError = true;
}
// Validate mobile
if (_mobileController.text.isEmpty) {
setState(() {
_mobileError = true;
});
hasError = true;
} else if (!_validationPopup.validateMobileNumber(
context,
_mobileController.text,
)) {
setState(() {
_mobileError = true;
});
hasError = true;
}
// Validate email - NOW WITH FORMAT CHECK
if (_emailController.text.isEmpty) {
setState(() {
_emailError = true;
});
hasError = true;
} else if (!_validationPopup.validateEmail(
context,
_emailController.text,
)) {
setState(() {
_emailError = true;
});
hasError = true;
}
// Show error message if any field has error
if (hasError) {
_validationPopup.showErrorMessage(
context,
"Please fill all required fields correctly",
);
return;
}
// All validations passed - call API
ref
.read(signupProvider.notifier)
.signup(
_nameController.text,
_mobileController.text,
_emailController.text,
);
}
@override
void dispose() {
_nameController.dispose();
_mobileController.dispose();
_emailController.dispose();
super.dispose();
}
}
+12
View File
@@ -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";
}
+17
View File
@@ -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);
}
+70
View File
@@ -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,
),
),
],
),
),
),
),
);
}
}
+31
View File
@@ -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,
);
}
}
+101
View File
@@ -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),
),
],
);
}
}
+54
View File
@@ -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,
),
),
],
),
),
),
);
}
}
+80
View File
@@ -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),
),
),
),
],
);
},
);
}
}
+82
View File
@@ -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,
),
),
],
);
}
}
+127
View File
@@ -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,
),
),
);
}
}
+240
View File
@@ -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");
}
}
+240
View File
@@ -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");
}
}
+539
View File
@@ -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';
}
}
+139
View File
@@ -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;
}
}
}
+262
View File
@@ -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,
),
),
),
),
),
],
);
}
}
+651
View File
@@ -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,
),
),
),
),
],
),
),
],
),
);
}
}
+102
View File
@@ -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();
}
}
}
+328
View File
@@ -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');
}
}
+110
View File
@@ -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;
}
+105
View File
@@ -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);
}
}
+30
View File
@@ -0,0 +1,30 @@
class ConstsApi {
static const String baseUrl = "https://www.taxglide.amrithaa.net";
static const String login = "$baseUrl/api/otp/sms/request";
static const String verifyOtp = "$baseUrl/api/otp/sms/verify";
static const String signup = "$baseUrl/api/register";
static const String terms = "$baseUrl/api/get_terms_conditions";
static const String policy = "$baseUrl/api/get_privacy_policy";
static const String getprofile = "$baseUrl/api/get_profile";
static const String employeelogin = "$baseUrl/api/otp/employee/sms/request";
static const String serivcelist = "$baseUrl/api/get_services_list";
static const String conturystate = "$baseUrl/api/kyc_master";
static const String city = "$baseUrl/api/state_based_districts";
static const String kycupdate = "$baseUrl/api/profile_update";
static const String serivcerequest = "$baseUrl/api/service_register";
static const String serivcehistory = "$baseUrl/api/get_service_requests_list";
static const String serivcedetails =
"$baseUrl/api/get_service_request_detail";
static const String logout = "$baseUrl/api/logout";
static const String chatList = "$baseUrl/api/chat";
static const String sendChatMessage = "$baseUrl/api/chat/message/send";
static const String stafflist = "$baseUrl/api/get_employee_list";
static const String count = "$baseUrl/api/chat/un_read_message_count";
static const String chatdocument = "$baseUrl/api/chat/document_lists";
static const String notificationList = "$baseUrl/api/notification/lists";
static const String notificationcount =
"$baseUrl/api/notification/un_read_count";
static const String dashboard = "$baseUrl/api/get_dashboard_data";
static const String proformaaccept = "$baseUrl/api/proforma/accept";
}
+536
View File
@@ -0,0 +1,536 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:taxglide/controller/api_consts.dart';
import 'package:taxglide/controller/api_repository.dart';
import 'package:taxglide/model/chat_model.dart';
import 'package:taxglide/model/chat_profile_model.dart';
import 'package:taxglide/model/city_model.dart';
import 'package:taxglide/model/count_model.dart';
import 'package:taxglide/model/country_model.dart';
import 'package:taxglide/model/dash_board_model.dart';
import 'package:taxglide/model/detail_model.dart';
import 'package:taxglide/model/employeeprofile_model.dart';
import 'package:taxglide/model/login_model.dart';
import 'package:taxglide/model/notification_model.dart';
import 'package:taxglide/model/notificationcount_model.dart';
import 'package:taxglide/model/profile_get_model.dart';
import 'package:taxglide/model/serivce_list_model.dart';
import 'package:taxglide/model/service_list_history_model.dart';
import 'package:taxglide/model/signup_model.dart';
import 'package:taxglide/model/staff_model.dart';
import 'package:taxglide/model/terms_model.dart';
final apiRepositoryProvider = Provider((ref) => ApiRepository());
//login controller
final loginProvider =
StateNotifierProvider<LoginNotifier, AsyncValue<Map<String, dynamic>>>(
(ref) => LoginNotifier(ref.read(apiRepositoryProvider)),
);
class LoginNotifier extends StateNotifier<AsyncValue<Map<String, dynamic>>> {
final ApiRepository _apiRepository;
LoginNotifier(this._apiRepository) : super(const AsyncValue.data({}));
Future<void> login(String mobile) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final model = LoginModel(mobile: mobile);
final result = await _apiRepository.loginUser(model);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
Future<void> verifyOtp(String mobile, String otp) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final model = LoginModel(mobile: mobile, otp: otp);
final result = await _apiRepository.verifyOtp(model);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
}
//signup controller
final signupProvider =
StateNotifierProvider<SignupNotifier, AsyncValue<Map<String, dynamic>>>(
(ref) => SignupNotifier(ref.read(apiRepositoryProvider)),
);
class SignupNotifier extends StateNotifier<AsyncValue<Map<String, dynamic>>> {
final ApiRepository _apiRepository;
SignupNotifier(this._apiRepository) : super(const AsyncValue.data({}));
Future<void> signup(String name, String contactNumber, String email) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final model = SignupModel(
name: name,
contactNumber: contactNumber,
email: email,
);
final result = await _apiRepository.signupUser(model);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
// Verify OTP for signup
Future<void> verifySignupOtp(String mobile, String otp) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final result = await _apiRepository.verifySignupOtp(mobile, otp);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
// Reset state
void reset() {
state = const AsyncValue.data({});
}
}
final employeeloginProvider =
StateNotifierProvider<
EmployeeLoginNotifier,
AsyncValue<Map<String, dynamic>>
>((ref) => EmployeeLoginNotifier(ref.read(apiRepositoryProvider)));
class EmployeeLoginNotifier
extends StateNotifier<AsyncValue<Map<String, dynamic>>> {
final ApiRepository _apiRepository;
EmployeeLoginNotifier(this._apiRepository) : super(const AsyncValue.data({}));
Future<void> login(String mobile) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final result = await _apiRepository.employeeLogin(mobile);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
Future<void> verifyOtp(String mobile, String otp) async {
if (!mounted) return;
state = const AsyncValue.loading();
try {
final model = LoginModel(mobile: mobile, otp: otp);
final result = await _apiRepository.verifyOtp(model);
if (mounted) state = AsyncValue.data(result);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
}
final termsProvider = FutureProvider<TermsModel>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchTermsAndConditions();
});
final dashboardProvider = FutureProvider<DashBoardModel>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchDashboard();
});
final policyProvider = FutureProvider<TermsModel>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchpolicy();
});
final profileProvider = FutureProvider<ProfileGetModel>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchProfile();
});
final employeeProfileProvider = FutureProvider<EmployeeProfileModel>((
ref,
) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchEmployeeProfile();
});
final serviceHistoryNotifierProvider =
StateNotifierProvider.family<
ServiceHistoryNotifier,
AsyncValue<ServiceListHistoryModel>,
String
>((ref, type) {
return ServiceHistoryNotifier(ref: ref, type: type);
});
class ServiceHistoryNotifier
extends StateNotifier<AsyncValue<ServiceListHistoryModel>> {
final Ref ref;
final String type;
String? fromDate;
String? toDate;
ServiceHistoryNotifier({required this.ref, required this.type})
: super(const AsyncValue.loading()) {
fetchServiceHistory();
}
Future<void> fetchServiceHistory() async {
try {
if (!mounted) return;
state = const AsyncValue.loading();
final repo = ref.read(apiRepositoryProvider);
final result = await repo.fetchServiceHistory(
type,
fromDate: fromDate,
toDate: toDate,
);
if (!mounted) return;
state = AsyncValue.data(result);
} catch (e, st) {
if (!mounted) return;
state = AsyncValue.error(e, st);
}
}
// ✅ APPLY FILTER
void applyDateFilter({String? from, String? to}) {
fromDate = from;
toDate = to;
fetchServiceHistory();
}
// ✅ CLEAR FILTER
void clearFilter() {
fromDate = null;
toDate = null;
fetchServiceHistory();
}
// ✅ MANUAL REFRESH
Future<void> refresh() async {
await fetchServiceHistory();
}
}
final countProvider = FutureProvider.family<CountModel, String>((
ref,
type,
) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchCount(type);
});
final notificationTriggerProvider = StateProvider<int>((ref) => 0);
// 🔥 fetch notification count API
final notificationCountProvider =
FutureProvider.autoDispose<NotificationCountModel>((ref) async {
ref.watch(notificationTriggerProvider); // listen for refresh trigger
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchNotificationCount();
});
final serviceDetailProvider = FutureProvider.family<DetailModel, int>((
ref,
id,
) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchServiceDetail(id);
});
final chatDocumentProvider = FutureProvider.family<ChatProfileModel, int>((
ref,
id,
) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchChatDocuments(id);
});
final serviceListProvider = FutureProvider<List<ServiceListModel>>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchServiceList();
});
final staffListProvider = FutureProvider<List<StaffModel>>((ref) async {
final repo = ref.read(apiRepositoryProvider);
return await repo.fetchStaffList();
});
final notificationProvider = StateNotifierProvider.family
.autoDispose<
NotificationNotifier,
AsyncValue<List<NotificationModel>>,
int
>((ref, page) {
return NotificationNotifier(ref: ref, page: page);
});
// ---------------- NOTIFIER --------------------
class NotificationNotifier
extends StateNotifier<AsyncValue<List<NotificationModel>>> {
final Ref ref;
final int page;
NotificationNotifier({required this.ref, required this.page})
: super(const AsyncValue.loading()) {
fetchNotifications();
}
Future<void> fetchNotifications() async {
if (!mounted) return;
try {
state = const AsyncValue.loading();
final List<NotificationModel> notifications = await ApiRepository()
.fetchNotificationList(page: page);
if (mounted) state = AsyncValue.data(notifications);
} catch (e, st) {
if (mounted) state = AsyncValue.error(e, st);
}
}
}
final chatMessagesProvider =
StateNotifierProvider.family<
ChatMessagesNotifier,
AsyncValue<List<MessageModel>>,
int
>((ref, chatId) {
return ChatMessagesNotifier(ref: ref, chatId: chatId);
});
class ChatMessagesNotifier
extends StateNotifier<AsyncValue<List<MessageModel>>> {
final Ref ref;
final int chatId;
int _currentPage = 1;
bool _hasNextPage = true;
bool _isFetching = false;
bool _isInitialLoad = true;
final List<MessageModel> _messages = [];
ChatMessagesNotifier({required this.ref, required this.chatId})
: super(const AsyncLoading()) {
loadMessages();
}
/// ⭐ Getter to expose hasNextPage
bool get hasNextPage => _hasNextPage;
/// Load paginated messages (used for scroll pagination)
Future<void> loadMessages() async {
if (_isFetching || !_hasNextPage || !mounted) return;
_isFetching = true;
try {
print("📥 Loading page $_currentPage for chat $chatId");
final result = await ref
.read(apiRepositoryProvider)
.fetchChatMessages(chatId: chatId, page: _currentPage);
if (result.isEmpty) {
print("✅ No more messages - pagination complete");
_hasNextPage = false;
} else {
print("✅ Loaded ${result.length} messages from page $_currentPage");
if (_isInitialLoad) {
// ⭐ FIX: On initial load, sort messages in ascending order (oldest first, newest last)
result.sort((a, b) {
final dateA =
DateTime.tryParse(a.createdAt ?? '') ?? DateTime(1970);
final dateB =
DateTime.tryParse(b.createdAt ?? '') ?? DateTime(1970);
return dateA.compareTo(dateB);
});
_messages.clear();
_messages.addAll(result);
} else {
// ⭐ FIX: On pagination load, insert older messages at beginning
// Make sure pagination result is sorted in ascending order
result.sort((a, b) {
final dateA =
DateTime.tryParse(a.createdAt ?? '') ?? DateTime(1970);
final dateB =
DateTime.tryParse(b.createdAt ?? '') ?? DateTime(1970);
return dateA.compareTo(dateB);
});
_messages.insertAll(0, result);
}
_currentPage++;
if (mounted) state = AsyncData(List.from(_messages));
}
if (_isInitialLoad && result.isNotEmpty) {
_isInitialLoad = false;
}
} catch (e, st) {
print("❌ Error loading messages: $e");
if (mounted) state = AsyncError(e, st);
} finally {
_isFetching = false;
}
}
/// ⭐ Refresh chat (pull to refresh or after sending message)
/// This is a SMOOTH refresh that doesn't show loading screen
Future<void> refresh() async {
print("🔄 Refreshing chat...");
try {
// ⭐ Get the latest messages from page 1
final result = await ref
.read(apiRepositoryProvider)
.fetchChatMessages(chatId: chatId, page: 1);
if (result.isNotEmpty) {
// Sort in ascending order
result.sort((a, b) {
final dateA = DateTime.tryParse(a.createdAt ?? '') ?? DateTime(1970);
final dateB = DateTime.tryParse(b.createdAt ?? '') ?? DateTime(1970);
return dateA.compareTo(dateB);
});
// ⭐ Check if we have any new messages
final lastMessageId = _messages.isNotEmpty ? _messages.last.id : -1;
final latestMessageId = result.last.id;
if (latestMessageId != lastMessageId) {
// ⭐ New messages found - merge them smoothly
print("✅ Found new messages, updating list");
// Add only new messages that don't exist in current list
for (var newMsg in result) {
final exists = _messages.any((m) => m.id == newMsg.id);
if (!exists) {
_messages.add(newMsg);
}
}
// Sort the entire list
_messages.sort((a, b) {
final dateA =
DateTime.tryParse(a.createdAt ?? '') ?? DateTime(1970);
final dateB =
DateTime.tryParse(b.createdAt ?? '') ?? DateTime(1970);
return dateA.compareTo(dateB);
});
if (mounted) state = AsyncData(List.from(_messages));
} else {
print("✅ No new messages");
}
}
} catch (e) {
print("❌ Error refreshing: $e");
// Don't throw error, just continue with existing messages
}
}
/// ⭐ Hard refresh (for pull to refresh gesture)
Future<void> hardRefresh() async {
print("🔄 Hard refreshing chat...");
_currentPage = 1;
_hasNextPage = true;
_messages.clear();
_isInitialLoad = true;
if (mounted) state = const AsyncLoading();
await loadMessages();
}
/// Add new message to list (socket or send API)
void addNewMessage(MessageModel msg) {
print("➕ Adding new message: ${msg.message} (ID: ${msg.id})");
// ⭐ DEDUPLICATION: Avoid adding the same message twice
// 1. Check if ID already exists
final existingIndex = _messages.indexWhere((m) => m.id == msg.id);
if (existingIndex != -1) {
print("♻️ Message ID ${msg.id} already exists, updating instead");
_messages[existingIndex] = msg;
if (mounted) state = AsyncData(List.from(_messages));
return;
}
// 2. Check if this is a server confirmation of an optimistic message
// Optimistic messages have large positive IDs from DateTime.now().millisecondsSinceEpoch
// OR content match for very recent messages
final optimisticIndex = _messages.indexWhere(
(m) =>
// Match by content and sender if it's a very recent "local" message
(m.id > 1000000000000 &&
m.message == msg.message &&
m.chatBy == msg.chatBy),
);
if (optimisticIndex != -1) {
print("🔄 Replacing optimistic message with server version");
_messages[optimisticIndex] = msg;
} else {
// ⭐ Add at the END (newest messages at bottom)
_messages.add(msg);
}
if (mounted) state = AsyncData(List.from(_messages));
}
/// Update existing message (e.g., delivery status, read status)
void updateMessage(MessageModel updatedMsg) {
final index = _messages.indexWhere((m) => m.id == updatedMsg.id);
if (index != -1) {
_messages[index] = updatedMsg;
if (mounted) state = AsyncData(List.from(_messages));
}
}
/// Delete a message
void deleteMessage(int messageId) {
_messages.removeWhere((m) => m.id == messageId);
if (mounted) state = AsyncData(List.from(_messages));
}
}
// ✅ Provider for fetching both country and state data
final countryAndStatesProvider = FutureProvider<CountryModel>((ref) async {
final repo = ref.read(apiRepositoryProvider);
try {
final model = await repo.fetchCountryAndStates(ConstsApi.conturystate);
ref.keepAlive(); // Keep cached until manually disposed
return model;
} catch (e) {
print('❌ Error in countryAndStatesProvider: $e');
throw e;
}
});
final fetchCityProvider = FutureProvider.family<CityModel, int>((
ref,
stateId,
) async {
final repo = ref.read(apiRepositoryProvider);
try {
final cityModel = await repo.fetchCityByState(ConstsApi.city, stateId);
ref.keepAlive();
return cityModel;
} catch (e) {
print('❌ Error in fetchCityProvider: $e');
throw e;
}
});
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyCSsZ4AGPvVs1wzpGFe5QL62bvPc4ki6E0',
appId: '1:355745714219:web:45eae1bcbd26aee5815a6e',
messagingSenderId: '355745714219',
projectId: 'taxglide-bd535',
authDomain: 'taxglide-bd535.firebaseapp.com',
storageBucket: 'taxglide-bd535.firebasestorage.app',
measurementId: 'G-2VQB1FS2MV',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyAfTUgCMmhunZci93dqMuoeGvC5r8NpdhM',
appId: '1:600746935622:android:9c98f0c1dd408b19cad147',
messagingSenderId: '600746935622',
projectId: 'auditpro-16b14',
storageBucket: 'auditpro-16b14.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyBnljOambzS0DdbRuDr6tvBBGvK24jEy3E',
appId: '1:600746935622:ios:b8c6feb054815581cad147',
messagingSenderId: '600746935622',
projectId: 'auditpro-16b14',
storageBucket: 'auditpro-16b14.firebasestorage.app',
iosBundleId: 'com.amrithaa.taxglide',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyCKZwHVfE0Ih5kSUx-mOD25F2tAFeAL18w',
appId: '1:355745714219:ios:4948c6a82d1b7570815a6e',
messagingSenderId: '355745714219',
projectId: 'taxglide-bd535',
storageBucket: 'taxglide-bd535.firebasestorage.app',
iosBundleId: 'com.amrithaa.taxglide',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyCSsZ4AGPvVs1wzpGFe5QL62bvPc4ki6E0',
appId: '1:355745714219:web:1d46121a7639ff32815a6e',
messagingSenderId: '355745714219',
projectId: 'taxglide-bd535',
authDomain: 'taxglide-bd535.firebaseapp.com',
storageBucket: 'taxglide-bd535.firebasestorage.app',
measurementId: 'G-14E4NG37P2',
);
}
+239
View File
@@ -0,0 +1,239 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'firebase_options.dart';
import 'package:taxglide/router/router.dart';
import 'package:taxglide/services/notification_service.dart';
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
/// ---------------------------------------------------------------------------
/// 🚨 BACKGROUND HANDLER - Must be top-level function (outside main)
/// This handles notifications when app is CLOSED/TERMINATED
/// ---------------------------------------------------------------------------
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
debugPrint("📩 Background Notification Received!");
}
Future<void> main() async {
try {
WidgetsFlutterBinding.ensureInitialized();
// 1. Initialize Firebase first (required)
debugPrint("🔥 Initializing Firebase...");
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
debugPrint("✅ Firebase Initialized");
// ✅ Register background handler as early as possible
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// 2. Fetch initial FCM message (terminated → tapped notification)
RemoteMessage? initialMessage;
try {
debugPrint("📩 Fetching initial message...");
initialMessage = await FirebaseMessaging.instance
.getInitialMessage()
.timeout(
const Duration(seconds: 2),
onTimeout: () {
debugPrint("⚠️ Firebase getInitialMessage timed out!");
return null;
},
);
debugPrint("📥 Initial message fetched: ${initialMessage != null}");
} catch (e) {
debugPrint("⚠️ Could not fetch initial message: $e");
}
Get.put<NotificationService>(
NotificationService(initialMessage),
permanent: true,
);
// 3. Initialize local notifications plugin (must happen before runApp)
await initLocalNotifications();
// 4. Start the app
debugPrint("🚀 Calling runApp...");
runApp(const ProviderScope(child: TaxglideApp()));
// 4. Run FCM/permission setup in background (non-blocking)
_runBackgroundTasks();
} catch (e, stack) {
debugPrint("❌ CRITICAL BOOT ERROR: $e");
debugPrint(stack.toString());
runApp(const ProviderScope(child: TaxglideApp()));
}
}
/// Helper to run non-critical startup tasks without blocking the UI
Future<void> _runBackgroundTasks() async {
try {
FirebaseMessaging messaging = FirebaseMessaging.instance;
// Request permissions (Non-blocking)
await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
// Request notification permission for Android 13+
if (Platform.isAndroid) {
await Permission.notification.request();
}
// Setup messaging (tokens, etc)
await _setupMessaging(messaging);
// On iOS, set alert to true – we show foreground FCM messages naturally.
await messaging.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
// Set up FCM foreground listener
_setupFcmListeners();
// Other plugins
await _initializePlugins();
debugPrint("✅ All background initializations complete");
} catch (e) {
debugPrint("⚠️ Background task failed: $e");
}
}
void _setupFcmListeners() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
debugPrint(
"🔔 Foreground FCM received! Notification block: ${message.notification}",
);
debugPrint("🔍 Foreground FCM Data block: ${message.data}");
// Show custom in-app notification for Android (since system doesn't show it in foreground)
// On iOS, setForegroundNotificationPresentationOptions handles this.
if (Platform.isAndroid) {
// Pass a dedup tag (chat/page id) so that if the WebSocket fires for the
// same event, the second notification replaces the first instead of
// showing a double notification.
final String? dedupTag =
message.data['page_id']?.toString() ??
message.data['chat_id']?.toString() ??
message.data['id']?.toString();
Get.find<NotificationService>().showForegroundNotification(
message,
tag: dedupTag,
);
}
});
// Background tap
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
debugPrint("📥 Notification tapped from background!");
Get.find<NotificationService>().handleNavigation(message.data);
});
}
/// ---------------------------------------------------------------------------
/// Non-blocking Messaging Setup
/// ---------------------------------------------------------------------------
Future<void> _setupMessaging(FirebaseMessaging messaging) async {
if (Platform.isIOS) {
debugPrint("🍎 iOS Detected: Requesting notification permissions...");
NotificationSettings settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
debugPrint("✅ User granted notification permissions");
} else if (settings.authorizationStatus ==
AuthorizationStatus.provisional) {
debugPrint("✅ User granted provisional notification permissions");
} else {
debugPrint(
"❌ User declined or has not accepted notification permissions",
);
}
// Checking for APNS token - essential for FCM on iOS
String? apnsToken = await messaging.getAPNSToken();
if (apnsToken == null) {
debugPrint("⏳ APNS Token not ready. Retrying (Max 10s)...");
// Simulators will stay null here. Physical devices might take a few seconds.
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(seconds: 1));
apnsToken = await messaging.getAPNSToken();
if (apnsToken != null) {
debugPrint("✅ APNS Token received after retry");
break;
}
}
}
if (apnsToken != null) {
debugPrint("✅ APNS Token: $apnsToken");
} else {
debugPrint(
"⚠️ APNS Token is NULL. "
"IMPORTANT: Push notifications require a PHYSICAL DEVICE and valid Xcode Push Capability. "
"Tokens are NOT available on Simulators.",
);
return;
}
}
// FCM Token generation is now handled EXCLUSIVELY during OTP verification in ApiRepository.
}
/// ---------------------------------------------------------------------------
/// Plugin Initializer
/// ---------------------------------------------------------------------------
Future<void> _initializePlugins() async {
try {
await FilePicker.platform.clearTemporaryFiles();
debugPrint("✅ Plugins initialized");
} catch (e) {
debugPrint("⚠️ Plugin init failed: $e");
}
}
class TaxglideApp extends StatefulWidget {
const TaxglideApp({super.key});
@override
State<TaxglideApp> createState() => _TaxglideAppState();
}
class _TaxglideAppState extends State<TaxglideApp> {
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Taxglide App',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: rootScaffoldMessengerKey,
initialRoute: '/splash',
getPages: AppRoutes.routes,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
);
}
}
+242
View File
@@ -0,0 +1,242 @@
// ------------------------------------------------------------
// CHAT MODEL
// ------------------------------------------------------------
class ChatModel {
final bool status;
final List<MessageModel> messages;
final String? chatStatus;
final int? draw;
final int? recordsTotal;
final int? recordsFiltered;
final String? profile;
final String? name;
final String? role;
final List<DocumentModel> userUploadedDocuments;
final List<DocumentModel> adminUploadedDocuments;
ChatModel({
required this.status,
required this.messages,
this.chatStatus,
this.draw,
this.recordsTotal,
this.recordsFiltered,
this.profile,
this.name,
this.role,
required this.userUploadedDocuments,
required this.adminUploadedDocuments,
});
factory ChatModel.fromJson(Map<String, dynamic> json) {
final msg = json["messages"];
return ChatModel(
status: json['status'] ?? false,
messages: (msg?['data'] != null && msg['data'] is List)
? List<MessageModel>.from(
msg['data'].map((x) => MessageModel.fromJson(x)),
)
: [],
chatStatus: msg?['status'],
draw: msg?['draw'],
recordsTotal: msg?['recordsTotal'],
recordsFiltered: msg?['recordsFiltered'],
profile: msg?['profile'],
name: msg?['name'],
role: msg?['role'],
userUploadedDocuments:
(msg?['user_uploaded_documents'] != null &&
msg['user_uploaded_documents'] is List)
? List<DocumentModel>.from(
msg['user_uploaded_documents'].map(
(x) => DocumentModel.fromJson(x),
),
)
: [],
adminUploadedDocuments:
(msg?['admin_uploaded_documents'] != null &&
msg['admin_uploaded_documents'] is List)
? List<DocumentModel>.from(
msg['admin_uploaded_documents'].map(
(x) => DocumentModel.fromJson(x),
),
)
: [],
);
}
}
// ------------------------------------------------------------
// MESSAGE MODEL
// ------------------------------------------------------------
class MessageModel {
final int id;
final String chatBy;
final String message;
final String type;
final int isDelivered;
final int isRead;
final String? createdAt;
final String? readAt;
final String? time;
final int userId;
final int? tagId;
final String? fileName;
final List<DocumentModel> uploadedDocuments;
final UserModel? user;
final ParentTagModel? parentTag;
final List<DocumentModel> documents;
MessageModel({
required this.id,
required this.chatBy,
required this.message,
required this.type,
required this.isDelivered,
required this.isRead,
this.createdAt,
this.readAt,
this.time,
required this.userId,
this.tagId,
this.fileName,
required this.uploadedDocuments,
this.user,
this.parentTag,
required this.documents,
});
factory MessageModel.fromJson(Map<String, dynamic> json) {
return MessageModel(
id: json['id'] ?? 0,
chatBy: json['chat_by'] ?? "",
message: json['message'] ?? "",
type: json['type'] ?? "",
isDelivered: json['is_delivered'] ?? 0,
isRead: json['is_read'] ?? 0,
createdAt: json['created_at'],
readAt: json['read_at'],
time: json['time'],
userId: json['user_id'] ?? 0,
tagId: json['tag_id'],
fileName: json['file_name'],
uploadedDocuments:
(json['uploaded_documents'] != null &&
json['uploaded_documents'] is List)
? List<DocumentModel>.from(
json['uploaded_documents'].map((x) => DocumentModel.fromJson(x)),
)
: [],
documents: (json['documents'] != null && json['documents'] is List)
? List<DocumentModel>.from(
json['documents'].map((x) => DocumentModel.fromJson(x)),
)
: [],
user: json['user'] != null ? UserModel.fromJson(json['user']) : null,
parentTag: json['parent_tag'] != null
? ParentTagModel.fromJson(json['parent_tag'])
: null,
);
}
}
// ------------------------------------------------------------
// DOCUMENT MODEL
// ------------------------------------------------------------
class DocumentModel {
final String filePath;
final String fileName;
final String fileType;
final int? id;
final String? type;
DocumentModel({
this.id,
required this.filePath,
required this.fileName,
required this.fileType,
this.type,
});
factory DocumentModel.fromJson(Map<String, dynamic> json) {
return DocumentModel(
id: json['id'],
filePath: json['file_path'] ?? "",
fileName: json['file_name'] ?? "",
fileType: json['file_type'] ?? "",
type: json['type'],
);
}
}
// ------------------------------------------------------------
// USER MODEL
// ------------------------------------------------------------
class UserModel {
final int id;
final String name;
final String role;
final String? profilePicture;
final Map<String, dynamic>? profile;
UserModel({
required this.id,
required this.name,
required this.role,
this.profilePicture,
this.profile,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] ?? 0,
name: json['name'] ?? "",
role: json['role'] ?? "",
profilePicture: json['profile_picture'],
profile: json['profile'],
);
}
}
// ------------------------------------------------------------
// PARENT TAG MODEL
// ------------------------------------------------------------
class ParentTagModel {
final int id;
final String message;
final String type;
final String? fileName;
ParentTagModel({
required this.id,
required this.message,
required this.type,
this.fileName,
});
factory ParentTagModel.fromJson(Map<String, dynamic> json) {
return ParentTagModel(
id: json['id'] ?? 0,
message: json['message'] ?? "",
type: json['type'] ?? "",
fileName: json['file_name'],
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:convert';
ChatProfileModel chatProfileModelFromJson(String str) =>
ChatProfileModel.fromJson(json.decode(str));
String chatProfileModelToJson(ChatProfileModel data) =>
json.encode(data.toJson());
class ChatProfileModel {
final bool status;
final String profile;
final String name;
final String role;
final List<ChatDocument> userUploadedDocuments;
final List<ChatDocument> adminUploadedDocuments;
ChatProfileModel({
required this.status,
required this.profile,
required this.name,
required this.role,
required this.userUploadedDocuments,
required this.adminUploadedDocuments,
});
factory ChatProfileModel.fromJson(Map<String, dynamic> json) =>
ChatProfileModel(
status: json["status"] ?? false,
profile: json["profile"] ?? "",
name: json["name"] ?? "",
role: json["role"] ?? "",
userUploadedDocuments: json["userUploadedDocuments"] == null
? []
: List<ChatDocument>.from(
json["userUploadedDocuments"]
.map((x) => ChatDocument.fromJson(x)),
),
adminUploadedDocuments: json["adminUploadedDocuments"] == null
? []
: List<ChatDocument>.from(
json["adminUploadedDocuments"]
.map((x) => ChatDocument.fromJson(x)),
),
);
Map<String, dynamic> toJson() => {
"status": status,
"profile": profile,
"name": name,
"role": role,
"userUploadedDocuments":
userUploadedDocuments.map((x) => x.toJson()).toList(),
"adminUploadedDocuments":
adminUploadedDocuments.map((x) => x.toJson()).toList(),
};
}
class ChatDocument {
final String fileName;
final String filePath;
final String fileType;
ChatDocument({
required this.fileName,
required this.filePath,
required this.fileType,
});
factory ChatDocument.fromJson(Map<String, dynamic> json) => ChatDocument(
fileName: json["file_name"] ?? "",
filePath: json["file_path"] ?? "",
fileType: json["file_type"] ?? "",
);
Map<String, dynamic> toJson() => {
"file_name": fileName,
"file_path": filePath,
"file_type": fileType,
};
}
+72
View File
@@ -0,0 +1,72 @@
class CityModel {
final bool? status;
final List<DistrictData>? districtsData;
CityModel({this.status, this.districtsData});
factory CityModel.fromJson(Map<String, dynamic> json) {
return CityModel(
status: json['status'],
districtsData: json['districts_data'] != null
? List<DistrictData>.from(
json['districts_data'].map((x) => DistrictData.fromJson(x)),
)
: [],
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'districts_data': districtsData?.map((x) => x.toJson()).toList(),
};
}
}
class DistrictData {
final int? id;
final String? districtName;
final int? active;
final int? stateId;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
DistrictData({
this.id,
this.districtName,
this.active,
this.stateId,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory DistrictData.fromJson(Map<String, dynamic> json) {
return DistrictData(
id: json['id'],
districtName: json['district_name'],
active: json['active'],
stateId: json['state_id'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'district_name': districtName,
'active': active,
'state_id': stateId,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
+23
View File
@@ -0,0 +1,23 @@
class CountModel {
final bool status;
final int count;
CountModel({
required this.status,
required this.count,
});
factory CountModel.fromJson(Map<String, dynamic> json) {
return CountModel(
status: json['status'] ?? false,
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'count': count,
};
}
}
+129
View File
@@ -0,0 +1,129 @@
class CountryModel {
final bool? status;
final List<CountryData>? countriesData;
final List<StateData>? statesData;
CountryModel({
this.status,
this.countriesData,
this.statesData,
});
factory CountryModel.fromJson(Map<String, dynamic> json) {
return CountryModel(
status: json['status'],
countriesData: json['countries_data'] != null
? List<CountryData>.from(
json['countries_data'].map((x) => CountryData.fromJson(x)))
: [],
statesData: json['states_data'] != null
? List<StateData>.from(
json['states_data'].map((x) => StateData.fromJson(x)))
: [],
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'countries_data': countriesData?.map((x) => x.toJson()).toList(),
'states_data': statesData?.map((x) => x.toJson()).toList(),
};
}
}
class CountryData {
final int? id;
final String? countryCode;
final String? countryName;
final int? active;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
CountryData({
this.id,
this.countryCode,
this.countryName,
this.active,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory CountryData.fromJson(Map<String, dynamic> json) {
return CountryData(
id: json['id'],
countryCode: json['country_code'],
countryName: json['country_name'],
active: json['active'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'country_code': countryCode,
'country_name': countryName,
'active': active,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
class StateData {
final int? id;
final String? stateName;
final int? active;
final int? countryId;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
StateData({
this.id,
this.stateName,
this.active,
this.countryId,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory StateData.fromJson(Map<String, dynamic> json) {
return StateData(
id: json['id'],
stateName: json['state_name'],
active: json['active'],
countryId: json['country_id'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'state_name': stateName,
'active': active,
'country_id': countryId,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
+193
View File
@@ -0,0 +1,193 @@
class DashBoardModel {
final String status;
final bool isKycCompleted;
final List<ServiceList> serviceLists;
final String userName;
final String companyName;
final int generalChatId;
final List<Review> reviews;
final ServiceRequestList serviceRequestLists;
final List<ImageSlider> imageSlider;
DashBoardModel({
required this.status,
required this.isKycCompleted,
required this.serviceLists,
required this.userName,
required this.companyName,
required this.generalChatId,
required this.imageSlider,
required this.reviews,
required this.serviceRequestLists,
});
factory DashBoardModel.fromJson(Map<String, dynamic> json) {
return DashBoardModel(
status: json['status'] ?? '',
isKycCompleted: json['is_kyc_completed'] ?? false,
serviceLists:
(json['service_lists'] as List<dynamic>?)
?.map((e) => ServiceList.fromJson(e))
.toList() ??
[],
imageSlider: (json['imageSlider'] as List)
.map((e) => ImageSlider.fromJson(e))
.toList(),
userName: json['user_name'] ?? '',
companyName: json['company_name'] ?? '',
generalChatId: json['general_chat_id'] ?? 0,
reviews:
(json['reviews'] as List<dynamic>?)
?.map((e) => Review.fromJson(e))
.toList() ??
[],
serviceRequestLists: ServiceRequestList.fromJson(
json['service_request_lists'] ?? {},
),
);
}
}
// ---------------------------------------------------------------
// SERVICE LIST MODEL
// ---------------------------------------------------------------
class ServiceList {
final int id;
final String service;
final String icon;
ServiceList({required this.id, required this.service, required this.icon});
factory ServiceList.fromJson(Map<String, dynamic> json) {
return ServiceList(
id: json['id'] ?? 0,
service: json['service'] ?? '',
icon: json['icon'] ?? '',
);
}
}
class ImageSlider {
final String image;
ImageSlider({required this.image});
factory ImageSlider.fromJson(Map<String, dynamic> json) {
return ImageSlider(image: json['image'] ?? '');
}
}
// ---------------------------------------------------------------
// REVIEW MODEL
// ---------------------------------------------------------------
class Review {
final int id;
final String name;
final String about;
final String content;
final int rating;
Review({
required this.id,
required this.name,
required this.about,
required this.content,
required this.rating,
});
factory Review.fromJson(Map<String, dynamic> json) {
return Review(
id: json['id'] ?? 0,
name: json['name'] ?? '',
about: json['about'] ?? '',
content: json['content'] ?? '',
rating: json['rating'] ?? 0,
);
}
}
// ---------------------------------------------------------------
// SERVICE REQUEST LIST WRAPPER
// ---------------------------------------------------------------
class ServiceRequestList {
final String status;
final int recordsTotal;
final int recordsFiltered;
final List<ServiceRequestItem> data;
ServiceRequestList({
required this.status,
required this.recordsTotal,
required this.recordsFiltered,
required this.data,
});
factory ServiceRequestList.fromJson(Map<String, dynamic> json) {
return ServiceRequestList(
status: json['status'] ?? '',
recordsTotal: json['recordsTotal'] ?? 0,
recordsFiltered: json['recordsFiltered'] ?? 0,
data:
(json['data'] as List<dynamic>?)
?.map((e) => ServiceRequestItem.fromJson(e))
.toList() ??
[],
);
}
}
// ---------------------------------------------------------------
// SERVICE REQUEST ITEM
// ---------------------------------------------------------------
class ServiceRequestItem {
final int id;
final String name;
final String logo;
final String email;
final String mobile;
final String paymentStatus;
final num paymentAmount;
final String status;
final String service;
final String message;
final String createdDate;
final String createdTime;
final int actions;
final int? chatId;
ServiceRequestItem({
required this.id,
required this.name,
required this.logo,
required this.email,
required this.mobile,
required this.paymentStatus,
required this.paymentAmount,
required this.status,
required this.service,
required this.message,
required this.createdDate,
required this.createdTime,
required this.actions,
required this.chatId,
});
factory ServiceRequestItem.fromJson(Map<String, dynamic> json) {
return ServiceRequestItem(
id: json['id'] ?? 0,
name: json['name'] ?? '',
logo: json['logo'] ?? '',
email: json['email'] ?? '',
mobile: json['mobile'] ?? '',
paymentStatus: json['payment_status'] ?? '',
paymentAmount: json['payment_amount'] ?? 0,
status: json['status'] ?? '',
service: json['service'] ?? '',
message: json['message'] ?? '',
createdDate: json['created_date'] ?? '',
createdTime: json['created_time'] ?? '',
actions: json['actions'] ?? 0,
chatId: json['chat_id'],
);
}
}
+235
View File
@@ -0,0 +1,235 @@
import 'dart:convert';
/// ------------------------------
/// JSON Helpers
/// ------------------------------
DetailModel detailModelFromJson(String str) =>
DetailModel.fromJson(json.decode(str));
String detailModelToJson(DetailModel data) => json.encode(data.toJson());
/// ------------------------------
/// Root Model
/// ------------------------------
class DetailModel {
final String? status;
final DetailData? data;
DetailModel({this.status, this.data});
factory DetailModel.fromJson(Map<String, dynamic> json) => DetailModel(
status: json["status"],
data: json["data"] == null ? null : DetailData.fromJson(json["data"]),
);
Map<String, dynamic> toJson() => {"status": status, "data": data?.toJson()};
}
/// ------------------------------
/// Detail Data Model
/// ------------------------------
class DetailData {
final int? id;
final int? firmId;
final String? dueDate;
final String? service;
final String? serviceStatus;
final String? paymentStatus;
final String? paidStatus;
final String? paymentDate;
final String? paymentAmount;
final String? invoice;
final String? invoiceNumber;
final String? proforma;
final int? proformaId;
final String? proformaNumber;
final String? proformastatus;
final String? createdDate;
final String? createdTime;
final String? createdBy;
final String? message;
final String? completedDate;
final String? paidBy;
final List<UserUploadedDocument> userUploadedDocuments;
final List<UserHandoverDocument> userHandoverDocuments;
final String? chatId;
final String? completedChatId;
DetailData({
this.id,
this.firmId,
this.dueDate,
this.service,
this.proformastatus,
this.serviceStatus,
this.paymentStatus,
this.paidStatus,
this.paymentDate,
this.paymentAmount,
this.invoice,
this.invoiceNumber,
this.proforma,
this.proformaId,
this.proformaNumber,
this.createdDate,
this.createdTime,
this.createdBy,
this.message,
this.completedDate,
this.paidBy,
required this.userUploadedDocuments,
required this.userHandoverDocuments,
this.chatId,
this.completedChatId,
});
factory DetailData.fromJson(Map<String, dynamic> json) => DetailData(
id: json["id"],
firmId: json["firm_id"],
dueDate: json["due_date"],
service: json["service"],
serviceStatus: json["service_status"],
paymentStatus: json["payment_status"],
proformastatus: json["proforma_status"],
paidStatus: json["paid_status"],
paymentDate: json["payment_date"],
paymentAmount: json["payment_amount"]?.toString(),
invoice: json["invoice"],
invoiceNumber: json["invoice_number"],
proforma: json["proforma"],
proformaId: json["proforma_id"],
proformaNumber: json["proforma_number"],
createdDate: json["created_date"],
createdTime: json["created_time"],
createdBy: json["created_by"],
message: json["message"],
completedDate: json["completed_date"],
paidBy: json["paid_by"],
userUploadedDocuments: json["user_uploaded_documents"] == null
? []
: List<UserUploadedDocument>.from(
json["user_uploaded_documents"].map(
(x) => UserUploadedDocument.fromJson(x),
),
),
userHandoverDocuments: json["user_handover_documents"] == null
? []
: List<UserHandoverDocument>.from(
json["user_handover_documents"].map(
(x) => UserHandoverDocument.fromJson(x),
),
),
chatId: json["chat_id"]?.toString(),
completedChatId: json["completed_chat_id"]?.toString(),
);
Map<String, dynamic> toJson() => {
"id": id,
"firm_id": firmId,
"due_date": dueDate,
"service": service,
"service_status": serviceStatus,
"payment_status": paymentStatus,
"paid_status": paidStatus,
"payment_date": paymentDate,
"payment_amount": paymentAmount,
"invoice": invoice,
"proforma_status": proformastatus,
"invoice_number": invoiceNumber,
"proforma": proforma,
"proforma_id": proformaId,
"proforma_number": proformaNumber,
"created_date": createdDate,
"created_time": createdTime,
"created_by": createdBy,
"message": message,
"completed_date": completedDate,
"paid_by": paidBy,
"user_uploaded_documents": userUploadedDocuments
.map((x) => x.toJson())
.toList(),
"user_handover_documents": userHandoverDocuments
.map((x) => x.toJson())
.toList(),
"chat_id": chatId,
"completed_chat_id": completedChatId,
};
}
/// ------------------------------
/// User Uploaded Document
/// ------------------------------
class UserUploadedDocument {
final int? id;
final String? filePath;
final String? fileName;
final String? uploadedBy;
final bool? viewable;
UserUploadedDocument({
this.id,
this.filePath,
this.fileName,
this.uploadedBy,
this.viewable,
});
factory UserUploadedDocument.fromJson(Map<String, dynamic> json) =>
UserUploadedDocument(
id: json["id"],
filePath: json["file_path"],
fileName: json["file_name"],
uploadedBy: json["uploaded_by"],
viewable: json["viewable"],
);
Map<String, dynamic> toJson() => {
"id": id,
"file_path": filePath,
"file_name": fileName,
"uploaded_by": uploadedBy,
"viewable": viewable,
};
}
/// ------------------------------
/// User Handover Document
/// ------------------------------
class UserHandoverDocument {
final String? filePath;
final String? fileName;
UserHandoverDocument({this.filePath, this.fileName});
factory UserHandoverDocument.fromJson(Map<String, dynamic> json) =>
UserHandoverDocument(
filePath: json["file_path"],
fileName: json["file_name"],
);
Map<String, dynamic> toJson() => {
"file_path": filePath,
"file_name": fileName,
};
}
+85
View File
@@ -0,0 +1,85 @@
class EmployeeProfileModel {
final bool status;
final EmployeeData? data;
EmployeeProfileModel({required this.status, this.data});
factory EmployeeProfileModel.fromJson(Map<String, dynamic> json) {
return EmployeeProfileModel(
status: json['status'] ?? false,
data: json['data'] != null ? EmployeeData.fromJson(json['data']) : null,
);
}
Map<String, dynamic> toJson() {
return {'status': status, 'data': data?.toJson()};
}
}
class EmployeeData {
final int id;
final String name;
final String mobile;
final String email;
final int status;
final int generalChat;
final int createService;
final Company? company;
EmployeeData({
required this.id,
required this.name,
required this.mobile,
required this.email,
required this.status,
required this.generalChat,
required this.createService,
this.company,
});
factory EmployeeData.fromJson(Map<String, dynamic> json) {
return EmployeeData(
id: json['id'] ?? 0,
name: json['name'] ?? '',
mobile: json['mobile'] ?? '',
email: json['email'] ?? '',
status: json['status'] ?? 0,
generalChat: json['general_chat'] ?? 0,
createService: json['create_service'] ?? 0,
company: json['company'] != null
? Company.fromJson(json['company'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'mobile': mobile,
'email': email,
'status': status,
'general_chat': generalChat,
'create_service': createService,
'company': company?.toJson(),
};
}
}
class Company {
final String? companyName;
final String? companyLogo;
Company({this.companyName, this.companyLogo});
factory Company.fromJson(Map<String, dynamic> json) {
return Company(
companyName: json['company_name'],
companyLogo: json['company_logo'],
);
}
Map<String, dynamic> toJson() {
return {'company_name': companyName, 'company_logo': companyLogo};
}
}
+23
View File
@@ -0,0 +1,23 @@
// login_model.dart
class LoginModel {
String? mobile;
String? otp;
LoginModel({this.mobile, this.otp});
// For sending mobile number to request OTP
Map<String, dynamic> toJsonMobile() {
return {'mobile': mobile};
}
// For sending mobile + OTP for verification
Map<String, dynamic> toJsonOtp() {
return {'mobile': mobile, 'otp': otp};
}
// Response from API
factory LoginModel.fromJson(Map<String, dynamic> json) {
return LoginModel(mobile: json['mobile'], otp: json['otp']);
}
}
+79
View File
@@ -0,0 +1,79 @@
class NotificationResponse {
final String status;
final int? draw;
final int recordsTotal;
final int recordsFiltered;
final List<NotificationModel> data;
NotificationResponse({
required this.status,
required this.draw,
required this.recordsTotal,
required this.recordsFiltered,
required this.data,
});
factory NotificationResponse.fromJson(Map<String, dynamic> json) {
return NotificationResponse(
status: json['status'] ?? '',
draw: json['draw'],
recordsTotal: json['recordsTotal'] ?? 0,
recordsFiltered: json['recordsFiltered'] ?? 0,
data: (json['data'] as List<dynamic>? ?? [])
.map((e) => NotificationModel.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
"status": status,
"draw": draw,
"recordsTotal": recordsTotal,
"recordsFiltered": recordsFiltered,
"data": data.map((e) => e.toJson()).toList(),
};
}
}
// -------------------------------------------------------------
class NotificationModel {
final int id;
final String title;
final String description;
final int? pageId;
final String page;
final String createdAt; // Keeping as string because format is custom
NotificationModel({
required this.id,
required this.title,
required this.description,
required this.pageId,
required this.page,
required this.createdAt,
});
factory NotificationModel.fromJson(Map<String, dynamic> json) {
return NotificationModel(
id: json['id'] ?? 0,
title: json['tittle'] ?? '', // API spelled as "tittle"
description: json['description'] ?? '',
pageId: json['page_id'],
page: json['page'] ?? '',
createdAt: json['created_at'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
"id": id,
"tittle": title,
"description": description,
"page_id": pageId,
"page": page,
"created_at": createdAt,
};
}
}
+17
View File
@@ -0,0 +1,17 @@
class NotificationCountModel {
final bool status;
final int count;
NotificationCountModel({required this.status, required this.count});
factory NotificationCountModel.fromJson(Map<String, dynamic> json) {
return NotificationCountModel(
status: json['status'] ?? false,
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {'status': status, 'count': count};
}
}
+313
View File
@@ -0,0 +1,313 @@
class ProfileGetModel {
final bool status;
final ProfileData? data;
ProfileGetModel({required this.status, this.data});
factory ProfileGetModel.fromJson(Map<String, dynamic> json) {
return ProfileGetModel(
status: json['status'] ?? false,
data: json['data'] != null ? ProfileData.fromJson(json['data']) : null,
);
}
Map<String, dynamic> toJson() {
return {'status': status, 'data': data?.toJson()};
}
}
class ProfileData {
final int? id;
final String? companyName;
final String? companyEmail;
final String? companyMobile;
final String? companyAddress;
final String? companyPincode;
final String? panNumber;
final String? gstNumber;
final String? tanNumber;
final String? cin;
final int? yearOfIncorporation;
final String? logo;
final String? panFile;
final String? gstFile;
final String? incorporationFile;
final int? active;
final int? createdBy;
final int? updatedBy;
final String? createdAt;
final String? updatedAt;
final int? countryId;
final int? stateId;
final int? districtId;
final String? countryName;
final String? stateName;
final String? districtName;
final String? name;
final String? email;
final String? mobile;
final int? userId;
final Country? country;
final StateData? state;
final District? district;
ProfileData({
this.id,
this.companyName,
this.companyEmail,
this.companyMobile,
this.companyAddress,
this.companyPincode,
this.panNumber,
this.gstNumber,
this.tanNumber,
this.cin,
this.yearOfIncorporation,
this.logo,
this.panFile,
this.gstFile,
this.incorporationFile,
this.active,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
this.countryId,
this.stateId,
this.districtId,
this.countryName,
this.stateName,
this.districtName,
this.name,
this.email,
this.mobile,
this.userId,
this.country,
this.state,
this.district,
});
factory ProfileData.fromJson(Map<String, dynamic> json) {
return ProfileData(
id: json['id'],
companyName: json['company_name'],
companyEmail: json['company_email'],
companyMobile: json['company_mobile'],
companyAddress: json['company_address'],
companyPincode: json['company_pincode'],
panNumber: json['pan_number'],
gstNumber: json['gst_number'],
tanNumber: json['tan_number'],
cin: json['cin'],
yearOfIncorporation: json['year_of_incorporation'],
logo: json['logo'],
panFile: json['pan_file'],
gstFile: json['gst_file'],
incorporationFile: json['incorporation_file'],
active: json['active'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
countryId: json['country_id'],
stateId: json['state_id'],
districtId: json['district_id'],
countryName: json['country_name'],
stateName: json['state_name'],
districtName: json['district_name'],
name: json['name'],
email: json['email'],
mobile: json['mobile'],
userId: json['user_id'],
country: json['country'] != null
? Country.fromJson(json['country'])
: null,
state: json['state'] != null ? StateData.fromJson(json['state']) : null,
district: json['district'] != null
? District.fromJson(json['district'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'company_name': companyName,
'company_email': companyEmail,
'company_mobile': companyMobile,
'company_address': companyAddress,
'company_pincode': companyPincode,
'pan_number': panNumber,
'gst_number': gstNumber,
'tan_number': tanNumber,
'cin': cin,
'year_of_incorporation': yearOfIncorporation,
'logo': logo,
'pan_file': panFile,
'gst_file': gstFile,
'incorporation_file': incorporationFile,
'active': active,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
'country_id': countryId,
'state_id': stateId,
'district_id': districtId,
'country_name': countryName,
'state_name': stateName,
'district_name': districtName,
'name': name,
'email': email,
'mobile': mobile,
'user_id': userId,
'country': country?.toJson(),
'state': state?.toJson(),
'district': district?.toJson(),
};
}
}
class Country {
final int? id;
final String? countryCode;
final String? countryName;
final int? active;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
Country({
this.id,
this.countryCode,
this.countryName,
this.active,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory Country.fromJson(Map<String, dynamic> json) {
return Country(
id: json['id'],
countryCode: json['country_code'],
countryName: json['country_name'],
active: json['active'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'country_code': countryCode,
'country_name': countryName,
'active': active,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
class StateData {
final int? id;
final String? stateName;
final int? active;
final int? countryId;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
StateData({
this.id,
this.stateName,
this.active,
this.countryId,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory StateData.fromJson(Map<String, dynamic> json) {
return StateData(
id: json['id'],
stateName: json['state_name'],
active: json['active'],
countryId: json['country_id'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'state_name': stateName,
'active': active,
'country_id': countryId,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
class District {
final int? id;
final String? districtName;
final int? active;
final int? stateId;
final dynamic createdBy;
final dynamic updatedBy;
final String? createdAt;
final String? updatedAt;
District({
this.id,
this.districtName,
this.active,
this.stateId,
this.createdBy,
this.updatedBy,
this.createdAt,
this.updatedAt,
});
factory District.fromJson(Map<String, dynamic> json) {
return District(
id: json['id'],
districtName: json['district_name'],
active: json['active'],
stateId: json['state_id'],
createdBy: json['created_by'],
updatedBy: json['updated_by'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'district_name': districtName,
'active': active,
'state_id': stateId,
'created_by': createdBy,
'updated_by': updatedBy,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
+43
View File
@@ -0,0 +1,43 @@
class ServiceListModel {
final int id;
final String service;
final String icon;
ServiceListModel({
required this.id,
required this.service,
required this.icon,
});
factory ServiceListModel.fromJson(Map<String, dynamic> json) {
return ServiceListModel(
id: json['id'] ?? 0,
service: json['service'] ?? '',
icon: json['icon'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {'id': id, 'service': service, 'icon': icon};
}
}
class ServiceListResponse {
final String status;
final List<ServiceListModel> data;
ServiceListResponse({required this.status, required this.data});
factory ServiceListResponse.fromJson(Map<String, dynamic> json) {
return ServiceListResponse(
status: json['status'] ?? '',
data: (json['data'] as List<dynamic>)
.map((e) => ServiceListModel.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {'status': status, 'data': data.map((e) => e.toJson()).toList()};
}
}
+91
View File
@@ -0,0 +1,91 @@
class ServiceListHistoryModel {
final String? status;
final dynamic draw;
final int? recordsTotal;
final int? recordsFiltered;
final List<ServiceHistoryData>? data;
ServiceListHistoryModel({
this.status,
this.draw,
this.recordsTotal,
this.recordsFiltered,
this.data,
});
factory ServiceListHistoryModel.fromJson(Map<String, dynamic> json) {
return ServiceListHistoryModel(
status: json['status'] as String?,
draw: json['draw'],
recordsTotal: json['recordsTotal'] is int
? json['recordsTotal']
: int.tryParse(json['recordsTotal']?.toString() ?? ''),
recordsFiltered: json['recordsFiltered'] is int
? json['recordsFiltered']
: int.tryParse(json['recordsFiltered']?.toString() ?? ''),
data: (json['data'] as List?)
?.map((e) => ServiceHistoryData.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'draw': draw,
'recordsTotal': recordsTotal,
'recordsFiltered': recordsFiltered,
'data': data?.map((e) => e.toJson()).toList(),
};
}
}
class ServiceHistoryData {
final int? id;
final String? paymentStatus;
final String? paymentAmount;
final String? status;
final String? service;
final String? message;
final String? createdDate;
final String? createdTime;
ServiceHistoryData({
this.id,
this.paymentStatus,
this.paymentAmount,
this.status,
this.service,
this.message,
this.createdDate,
this.createdTime,
});
factory ServiceHistoryData.fromJson(Map<String, dynamic> json) {
return ServiceHistoryData(
id: json['id'] is int
? json['id']
: int.tryParse(json['id']?.toString() ?? ''),
paymentStatus: json['payment_status']?.toString(),
paymentAmount: json['payment_amount']?.toString(),
status: json['status']?.toString(),
service: json['service']?.toString(),
message: json['message']?.toString(),
createdDate: json['created_date']?.toString(),
createdTime: json['created_time']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'payment_status': paymentStatus,
'payment_amount': paymentAmount,
'status': status,
'service': service,
'message': message,
'created_date': createdDate,
'created_time': createdTime,
};
}
}
+25
View File
@@ -0,0 +1,25 @@
class SignupModel {
final String email;
final String name;
final String contactNumber;
SignupModel({
required this.email,
required this.name,
required this.contactNumber,
});
// Convert JSON to SignupModel
factory SignupModel.fromJson(Map<String, dynamic> json) {
return SignupModel(
email: json['email'] ?? '',
name: json['name'] ?? '',
contactNumber: json['mobile'] ?? '',
);
}
// Convert SignupModel to JSON
Map<String, dynamic> toJson() {
return {'email': email, 'name': name, 'mobile': contactNumber};
}
}
+47
View File
@@ -0,0 +1,47 @@
class StaffResponse {
final String status;
final List<StaffModel> data;
StaffResponse({required this.status, required this.data});
factory StaffResponse.fromJson(Map<String, dynamic> json) {
return StaffResponse(
status: json['status'] ?? '',
data: (json['data'] as List<dynamic>)
.map((item) => StaffModel.fromJson(item))
.toList(),
);
}
}
class StaffModel {
final int id;
final String name;
final String mobile;
final String email;
final int status;
final int generalChat;
final int createService;
StaffModel({
required this.id,
required this.name,
required this.mobile,
required this.email,
required this.status,
required this.generalChat,
required this.createService,
});
factory StaffModel.fromJson(Map<String, dynamic> json) {
return StaffModel(
id: json['id'] ?? 0,
name: json['name'] ?? '',
mobile: json['mobile'] ?? '',
email: json['email'] ?? '',
status: json['status'] ?? 0,
generalChat: json['general_chat'] ?? 0,
createService: json['create_service'] ?? 0,
);
}
}
+35
View File
@@ -0,0 +1,35 @@
class TermsModel {
final bool status;
final TermsData? data;
TermsModel({required this.status, this.data});
factory TermsModel.fromJson(Map<String, dynamic> json) {
return TermsModel(
status: json['status'] ?? false,
data: json['data'] != null ? TermsData.fromJson(json['data']) : null,
);
}
Map<String, dynamic> toJson() {
return {'status': status, 'data': data?.toJson()};
}
}
class TermsData {
final String? title;
final String? content;
TermsData({this.title, this.content});
factory TermsData.fromJson(Map<String, dynamic> json) {
return TermsData(
title: json['title'] ?? '',
content: json['content'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {'title': title, 'content': content};
}
}
+16
View File
@@ -0,0 +1,16 @@
class ConstRouters {
static const String splash = '/splash';
static const String login = '/login';
static const String signup = '/signup';
static const String otp = '/otp';
static const String registerOtp = '/registerOtp';
static const String terms = '/terms';
static const String policy = '/policy';
static const String employeelogin = '/employeelogin';
static const String employeeotp = '/employeeotp';
static const String kycdetails = '/kycdetails';
static const String kycdetailslist = '/kycdetailslist';
static const String servicerequest = '/servicerequest';
static const String staff = '/staff';
static const String employeekycdetailslist = '/employeekycdetailslist';
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:get/get.dart';
import 'package:taxglide/auth/employee_login_screen.dart';
import 'package:taxglide/auth/employee_otp_screen.dart';
import 'package:taxglide/auth/login_screen.dart';
import 'package:taxglide/auth/otp_screen.dart';
import 'package:taxglide/auth/register_otp_screen.dart';
import 'package:taxglide/auth/signup_screen.dart';
import 'package:taxglide/router/consts_routers.dart';
import 'package:taxglide/view/flash_screen.dart';
import 'package:taxglide/view/screens/profile/employee_profile/employee_profile_list.dart';
import 'package:taxglide/view/screens/profile/kyc_detail_screen.dart';
import 'package:taxglide/view/screens/profile/kyc_details_list.dart';
import 'package:taxglide/view/screens/profile/policy_screen.dart';
import 'package:taxglide/view/screens/profile/staff_list_screen.dart';
import 'package:taxglide/view/screens/profile/terms_and_condition_screen.dart';
class AppRoutes {
static final routes = [
// Flash / Splash Screen
GetPage(name: ConstRouters.splash, page: () => const FlashScreen()),
GetPage(name: ConstRouters.login, page: () => const LoginScreen()),
GetPage(
name: ConstRouters.employeelogin,
page: () => const EmployeeLoginScreen(),
),
GetPage(name: ConstRouters.signup, page: () => const SignupScreen()),
GetPage(
name: ConstRouters.registerOtp,
page: () => const RegisterOtpScreen(),
),
GetPage(name: ConstRouters.otp, page: () => const OtpScreen()),
GetPage(
name: ConstRouters.employeeotp,
page: () => const EmployeeOtpScreen(),
),
GetPage(
name: ConstRouters.terms,
page: () => const TermsAndConditionScreen(),
),
GetPage(name: ConstRouters.kycdetails, page: () => const KycDetailScreen()),
GetPage(
name: ConstRouters.kycdetailslist,
page: () => const KycDetailsList(),
),
GetPage(
name: ConstRouters.employeekycdetailslist,
page: () => const EmployeeProfileList(),
),
GetPage(name: ConstRouters.policy, page: () => const PolicyScreen()),
GetPage(name: ConstRouters.staff, page: () => const StaffListScreen()),
];
}
+254
View File
@@ -0,0 +1,254 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/local_store.dart';
import 'package:taxglide/view/Mahi_chat/live_chat_screen.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
import 'package:taxglide/view/screens/history/detail_screen.dart';
import 'package:taxglide/view/screens/notification_screen.dart';
import 'package:taxglide/router/consts_routers.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Local Notifications setup (singleton plugin instance)
// ─────────────────────────────────────────────────────────────────────────────
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
/// Android notification channel used for all foreground chat/app notifications.
const AndroidNotificationChannel _channel = AndroidNotificationChannel(
'taxglide_foreground', // id
'TaxGlide Notifications', // name shown in system settings
description: 'Foreground notifications for TaxGlide',
importance: Importance.high,
playSound: true,
enableVibration: true,
);
/// Call once in main() after Firebase.initializeApp().
Future<void> initLocalNotifications() async {
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/launcher_icon'),
iOS: DarwinInitializationSettings(),
);
await flutterLocalNotificationsPlugin.initialize(
initSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) {
// Notification tapped while app is open – handled inside NotificationService.
debugPrint('🔔 Local notification tapped: payload=${response.payload}');
Get.find<NotificationService>().handleNavigationFromPayload(
response.payload,
);
},
);
// Create the Android channel (no-op on iOS).
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_channel);
debugPrint('✅ LocalNotifications initialized');
}
// ─────────────────────────────────────────────────────────────────────────────
// NotificationService
// ─────────────────────────────────────────────────────────────────────────────
class NotificationService extends GetxController {
final RemoteMessage? initialMessage;
NotificationService(this.initialMessage);
/// Running notification ID counter so multiple notifications can stack.
int _notifId = 0;
/// Shows a native Android system notification for a foreground message.
/// Works from any context – WebSocket callbacks, FCM listeners, etc.
///
/// [tag] is used as a deduplication key: if two sources (FCM + WebSocket)
/// fire for the same chat/event, passing the same tag means Android will
/// *replace* the first notification instead of showing a second one.
void showForegroundNotification(RemoteMessage message, {String? tag}) {
final String title =
message.notification?.title ??
message.data['title']?.toString() ??
'New Notification';
final String body =
message.notification?.body ??
message.data['body']?.toString() ??
'You have a new update';
// Build a simple payload string so tapping routes correctly.
final String? type = message.data['type']?.toString();
final String? id =
message.data['page_id']?.toString() ?? message.data['id']?.toString();
final String payload = (type != null && id != null) ? '$type:$id' : '';
// Use the tag as dedup key; fall back to an incrementing ID so unrelated
// notifications still stack correctly.
final int notifId = tag != null ? tag.hashCode.abs() % 100000 : _notifId++;
debugPrint(
'🔔 NotificationService: showing local notification '
'[id=$notifId tag=$tag title=$title]',
);
flutterLocalNotificationsPlugin.show(
notifId,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/launcher_icon',
playSound: true,
enableVibration: true,
styleInformation: BigTextStyleInformation(body),
tag: tag, // same tag → replaces instead of duplicating
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: payload,
);
}
// ── Navigation helpers ──────────────────────────────────────────────────────
/// Called when a local notification is tapped (payload = "type:id").
void handleNavigationFromPayload(String? payload) {
if (payload == null || payload.isEmpty) {
_safeNavigate(() => Get.offAll(() => MainController()));
return;
}
final parts = payload.split(':');
final type = parts.isNotEmpty ? parts[0] : null;
final idStr = parts.length > 1 ? parts[1] : null;
handleNavigation({'type': type, 'id': idStr});
}
Future<void> handleNavigation(Map<String, dynamic> data) async {
final LocalStore localStore = LocalStore();
try {
// ── Auth check ──────────────────────────────────────────────────────────
final String? token = await localStore.getToken();
if (token == null || token.isEmpty || token == 'null') {
debugPrint('🔒 No valid token → Login');
_safeNavigate(() => Get.offAllNamed(ConstRouters.login));
return;
}
// Support both 'page'+'page_id' and 'type'+'id' payload formats.
final String? type = data['page']?.toString() ?? data['type']?.toString();
final String? idStr =
data['page_id']?.toString() ??
data['pageId']?.toString() ??
data['id']?.toString();
if (type == null || type.isEmpty) {
debugPrint('⚠️ Invalid notification data: type is empty');
_safeNavigate(() => Get.offAll(() => MainController()));
return;
}
debugPrint('📍 Notification nav → type=$type id=$idStr');
if (type == 'chat') {
final int chatId = int.tryParse(idStr ?? '') ?? 0;
if (chatId == 0) {
_safeNavigate(() => Get.offAll(() => MainController()));
return;
}
debugPrint('💬 Navigating to Chat $chatId');
_safeNavigate(() {
try {
if (Get.isDialogOpen ?? false) Get.back();
if (Get.isBottomSheetOpen ?? false) Get.back();
Navigator.of(Get.context!).popUntil((route) {
return route.isFirst ||
route.settings.name == '/splash' ||
route.settings.name == '/MainController';
});
} catch (_) {}
Future.delayed(const Duration(milliseconds: 200), () {
if (Get.currentRoute == '/LiveChatScreen') {
Get.off(
() => LiveChatScreen(chatid: chatId, fileid: '0'),
preventDuplicates: false,
);
} else {
Get.to(
() => LiveChatScreen(chatid: chatId, fileid: '0'),
preventDuplicates: false,
);
}
});
});
} else if (type == 'service') {
final int serviceId = int.tryParse(idStr ?? '') ?? 0;
if (serviceId == 0) {
_safeNavigate(() => Get.offAll(() => MainController()));
return;
}
debugPrint('📋 Navigating to Service $serviceId');
_safeNavigate(
() => Get.offAll(
() => MainController(
initialIndex: 2,
child: DetailScreen(id: serviceId, sourceTabIndex: 2),
),
),
);
} else {
debugPrint("❓ Unknown type '$type' → NotificationScreen");
_safeNavigate(() {
Get.offAll(() => const MainController());
Future.delayed(
const Duration(milliseconds: 300),
() => Get.to(() => const NotificationScreen()),
);
});
}
} catch (e) {
debugPrint('❌ Error handling notification nav: $e');
try {
final String? token = await localStore.getToken();
if (token != null && token.isNotEmpty && token != 'null') {
_safeNavigate(() => Get.offAll(() => MainController()));
} else {
_safeNavigate(() => Get.offAllNamed(ConstRouters.login));
}
} catch (_) {
_safeNavigate(() => Get.offAllNamed(ConstRouters.login));
}
}
}
void _safeNavigate(Function action) {
if (Get.key.currentState != null) {
action();
return;
}
int attempts = 0;
Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
attempts++;
if (Get.key.currentState != null) {
action();
return false;
}
return attempts < 20;
});
}
}
+373
View File
@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/consts/download_helper.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:photo_view/photo_view.dart';
class ChatProfileScreen extends ConsumerWidget {
final int chatid;
const ChatProfileScreen({super.key, required this.chatid});
// Helper function to check if document is valid
bool _isValidDocument(dynamic doc) {
// Check if fileName exists and is not empty
final hasFileName =
doc.fileName != null && doc.fileName.toString().trim().isNotEmpty;
// Check if filePath exists and is not empty
final hasFilePath =
doc.filePath != null && doc.filePath.toString().trim().isNotEmpty;
// Return true only if both fileName and filePath exist
return hasFileName && hasFilePath;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final result = ref.watch(chatDocumentProvider(chatid));
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(240),
child: AppBar(
automaticallyImplyLeading: true,
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: const Text(
"Profile",
style: TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w400,
fontSize: 20,
height: 1.4,
letterSpacing: 0.6,
color: Colors.black,
),
),
flexibleSpace: result.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text(e.toString())),
data: (data) {
return Container(
width: double.infinity,
height: 282,
padding: const EdgeInsets.only(top: 100, bottom: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: const Color(0xFFDEDEDE),
width: 1,
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 45,
backgroundImage: NetworkImage(data.profile),
),
const SizedBox(height: 12),
Text(
data.name,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
data.role,
style: TextStyle(fontSize: 16, color: Colors.grey[700]),
),
],
),
);
},
),
),
),
body: result.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text(e.toString())),
data: (data) {
// Filter out invalid documents
final senderList = data.userUploadedDocuments
.where((doc) => _isValidDocument(doc))
.toList();
final receiverList = data.adminUploadedDocuments
.where((doc) => _isValidDocument(doc))
.toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------------- Shared by Taxglide ----------------
const Text(
"Shared By Taxglide",
style: TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
fontSize: 20,
height: 1.4,
letterSpacing: 0.6,
color: Colors.black,
),
),
const SizedBox(height: 10),
receiverList.isEmpty
? const Text("No documents from Taxglide")
: SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: receiverList.length,
separatorBuilder: (_, __) =>
const SizedBox(width: 12),
itemBuilder: (_, index) {
final doc = receiverList[index];
final isImage = DownloadHelper.isImageFile(
doc.filePath,
);
final isPdf =
DownloadHelper.getFileExtension(doc.filePath) ==
'pdf';
return GestureDetector(
onTap: () {
if (isImage) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ImagePreviewScreen(
imageUrl: doc.filePath,
),
),
);
} else if (isPdf) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PdfPreviewScreen(
pdfUrl: doc.filePath,
fileName: doc.fileName,
),
),
);
}
},
child: Container(
width: 100,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: Column(
children: [
Expanded(
child: isImage
? ClipRRect(
borderRadius:
BorderRadius.circular(8),
child: Image.network(
doc.filePath,
width: 100,
fit: BoxFit.cover,
),
)
: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 40,
color: Colors.red,
)
: const Icon(
Icons.insert_drive_file,
size: 40,
color: Colors.blue,
),
),
],
),
),
);
},
),
),
const SizedBox(height: 30),
// ---------------- Shared by Client ----------------
const Text(
"Shared By Client",
style: TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
fontSize: 20,
height: 1.4,
letterSpacing: 0.6,
color: Colors.black,
),
),
const SizedBox(height: 10),
senderList.isEmpty
? const Text("No documents from Client")
: SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: senderList.length,
separatorBuilder: (_, __) =>
const SizedBox(width: 12),
itemBuilder: (_, index) {
final doc = senderList[index];
final isImage = DownloadHelper.isImageFile(
doc.filePath,
);
final isPdf =
DownloadHelper.getFileExtension(doc.filePath) ==
'pdf';
return GestureDetector(
onTap: () async {
if (isImage) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ImagePreviewScreen(
imageUrl: doc.filePath,
),
),
);
} else {
await DownloadHelper.downloadFile(
doc.filePath,
);
}
},
child: Container(
width: 100,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: Column(
children: [
Expanded(
child: isImage
? ClipRRect(
borderRadius:
BorderRadius.circular(8),
child: Image.network(
doc.filePath,
width: 100,
fit: BoxFit.cover,
),
)
: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 40,
color: Colors.red,
)
: const Icon(
Icons.insert_drive_file,
size: 40,
color: Colors.blue,
),
),
],
),
),
);
},
),
),
],
),
);
},
),
);
}
}
class ImagePreviewScreen extends StatelessWidget {
final String imageUrl;
const ImagePreviewScreen({super.key, required this.imageUrl});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(
child: PhotoView(
imageProvider: NetworkImage(imageUrl),
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
),
);
}
}
class PdfPreviewScreen extends StatelessWidget {
final String pdfUrl;
final String fileName;
const PdfPreviewScreen({
super.key,
required this.pdfUrl,
required this.fileName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(fileName),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.picture_as_pdf, size: 80, color: Colors.red),
const SizedBox(height: 20),
Text(
fileName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
const Text(
"PDF Preview not available",
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}
+569
View File
@@ -0,0 +1,569 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:taxglide/consts/download_helper.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/controller/api_repository.dart';
import 'package:taxglide/view/Mahi_chat/live_chat_screen.dart';
// ⭐⭐⭐ CHAT INPUT BOX WITH FILE TAG SUPPORT ⭐⭐⭐
class ChatInputBox extends ConsumerStatefulWidget {
final int chatId;
final VoidCallback? onMessageSent;
const ChatInputBox(this.chatId, {Key? key, this.onMessageSent})
: super(key: key);
@override
ConsumerState<ChatInputBox> createState() => _ChatInputBoxState();
}
class _ChatInputBoxState extends ConsumerState<ChatInputBox> {
final TextEditingController messageCtrl = TextEditingController();
final ImagePicker picker = ImagePicker();
List<File> selectedFiles = [];
bool _isSending = false;
@override
void initState() {
super.initState();
_requestPermissions();
}
// ⭐ Request storage permissions
Future<void> _requestPermissions() async {
await DownloadHelper.requestStoragePermission();
}
void showPickerMenu() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
),
builder: (context) {
return SafeArea(
bottom: true,
child: SizedBox(
height: 220,
child: Column(
children: [
const SizedBox(height: 10),
ListTile(
leading: const Icon(Icons.photo_camera, size: 28),
title: const Text("Camera", style: TextStyle(fontSize: 17)),
onTap: pickFromCamera,
),
ListTile(
leading: const Icon(Icons.photo_library, size: 28),
title: const Text("Gallery", style: TextStyle(fontSize: 17)),
onTap: pickFromGallery,
),
ListTile(
leading: const Icon(Icons.attach_file, size: 28),
title: const Text("Files", style: TextStyle(fontSize: 17)),
onTap: pickDocuments,
),
const SizedBox(height: 10),
],
),
),
);
},
);
}
Future pickFromCamera() async {
Navigator.pop(context);
try {
var status = await Permission.camera.status;
// Handle cases where we cannot show the system dialog again
// Skip strict blocks on iOS Simulator by just trying to launch picker
if (!Platform.isIOS || status.isGranted || status.isLimited) {
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
status = await Permission.camera.request();
if (!status.isGranted && !status.isLimited) {
_showPermissionDialog("Camera");
return;
}
}
}
final XFile? image = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1920,
maxHeight: 1080,
imageQuality: 85,
);
if (image != null) {
setState(() => selectedFiles.add(File(image.path)));
}
} catch (e) {
debugPrint("❌ Camera error: $e");
// If it's a simulator, it will throw an error since there is no camera
if (Platform.isIOS && e.toString().contains("camera not available")) {
_showErrorSnackbar("Camera not available on iOS Simulator");
} else {
_showErrorSnackbar("Failed to capture image");
}
}
}
Future pickFromGallery() async {
Navigator.pop(context);
try {
var status = await Permission.photos.status;
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
status = await Permission.photos.request();
if (!status.isGranted && !status.isLimited && !Platform.isIOS) {
_showPermissionDialog("Photos");
return;
}
}
final List<XFile>? images = await picker.pickMultiImage(
maxWidth: 1920,
maxHeight: 1080,
imageQuality: 85,
);
if (images != null && images.isNotEmpty) {
setState(() {
selectedFiles.addAll(images.map((e) => File(e.path)));
});
}
} catch (e) {
print("❌ Gallery error: $e");
_showErrorSnackbar("Failed to pick images");
}
}
Future pickDocuments() async {
Navigator.pop(context);
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.any,
);
if (result != null && result.files.isNotEmpty) {
setState(() {
selectedFiles.addAll(
result.paths.where((e) => e != null).map((e) => File(e!)),
);
});
}
} catch (e) {
print("❌ File picker error: $e");
_showErrorSnackbar("Failed to pick files");
}
}
void _showPermissionDialog(String permissionName) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text("$permissionName Permission Required"),
content: Text(
"Allow Taxglide to access your $permissionName to capture and upload files easily. You can enable this in the app settings.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: const Text("Go to Settings"),
),
],
),
);
}
void _showErrorSnackbar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Colors.red,
content: Text(message),
duration: const Duration(seconds: 2),
),
);
}
void removeFile(int index) {
setState(() {
selectedFiles.removeAt(index);
});
}
String getFileName(String path) {
return path.split('/').last;
}
IconData getFileIcon(String path) {
final ext = path.split('.').last.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
return Icons.image;
} else if (ext == 'pdf') {
return Icons.picture_as_pdf;
} else if (['doc', 'docx'].contains(ext)) {
return Icons.description;
} else if (['xls', 'xlsx'].contains(ext)) {
return Icons.table_chart;
} else {
return Icons.insert_drive_file;
}
}
Future<void> sendMessage() async {
if (_isSending) return; // Prevent double-tap
String text = messageCtrl.text.trim();
if (text.isEmpty && selectedFiles.isEmpty) return;
// ⭐ Get the tagged message
final taggedMessage = ref.read(taggedMessageProvider);
final tagId = taggedMessage?.id ?? 0;
setState(() => _isSending = true);
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => WillPopScope(
onWillPop: () async => false,
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Sending message..."),
],
),
),
),
),
),
);
try {
List<File> filesToUpload = [];
// ⭐⭐⭐ Save files to Send folder
if (selectedFiles.isNotEmpty) {
print("📤 Saving ${selectedFiles.length} files to Send folder...");
for (File file in selectedFiles) {
try {
// Try to save to Send folder, fallback to original if fails
final savedPath = await DownloadHelper.saveToSendFolder(file);
filesToUpload.add(File(savedPath));
} catch (e) {
print("⚠️ Could not save file, using original: $e");
filesToUpload.add(file);
}
}
print("✅ Files prepared for upload: ${filesToUpload.length}");
}
// Send message with files
await ApiRepository().sendChatMessage(
chatId: widget.chatId,
messages: text,
tagId: tagId,
files: filesToUpload,
);
// Refresh chat messages
await ref.read(chatMessagesProvider(widget.chatId).notifier).refresh();
ref.invalidate(chatDocumentProvider);
print("✅ Message sent successfully with tagId: $tagId");
// Close loading dialog
if (mounted) Navigator.pop(context);
// Clear input
if (mounted) {
setState(() {
messageCtrl.clear();
selectedFiles.clear();
_isSending = false;
});
}
// Clear the tagged message
ref.read(taggedMessageProvider.notifier).state = null;
// Notify parent to scroll to bottom
if (widget.onMessageSent != null) {
widget.onMessageSent!();
}
} catch (e) {
print("❌ Error sending message: $e");
// Close loading dialog
if (mounted) Navigator.pop(context);
setState(() => _isSending = false);
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Colors.red,
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: Text(
"Failed to send: ${e.toString()}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: "Retry",
textColor: Colors.white,
onPressed: sendMessage,
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final taggedMessage = ref.watch(taggedMessageProvider);
return SafeArea(
bottom: true,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ⭐⭐⭐ SELECTED FILES PREVIEW (like WhatsApp)
if (selectedFiles.isNotEmpty)
Container(
height: 100,
margin: const EdgeInsets.only(bottom: 10),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: selectedFiles.length,
itemBuilder: (context, index) {
final file = selectedFiles[index];
final isImage = DownloadHelper.isImageFile(file.path);
return Container(
width: 80,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// File preview
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: isImage
? Image.file(
file,
width: 80,
height: 100,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.broken_image),
);
},
)
: Center(
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
getFileIcon(file.path),
size: 30,
color: Colors.grey[600],
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Text(
getFileName(file.path),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 9),
),
),
],
),
),
),
// Remove button
Positioned(
top: 2,
right: 2,
child: GestureDetector(
onTap: () => removeFile(index),
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
size: 16,
color: Colors.white,
),
),
),
),
],
),
);
},
),
),
// Input Row
Row(
children: [
Expanded(
child: Container(
constraints: const BoxConstraints(
minHeight: 62,
maxHeight: 150,
),
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7),
border: Border.all(color: const Color(0xFFCACACA)),
boxShadow: const [
BoxShadow(
color: Colors.black26,
offset: Offset(0, 4),
blurRadius: 7.8,
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: messageCtrl,
maxLines: null,
minLines: 1,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: taggedMessage != null
? "Reply to ${taggedMessage.user?.name ?? 'message'}"
: "Send your message",
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
vertical: 12,
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _isSending ? null : showPickerMenu,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Icon(
Icons.attach_file,
size: 26,
color: _isSending ? Colors.grey : Colors.black,
),
),
),
],
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: _isSending ? null : sendMessage,
child: Container(
height: 46,
width: 46,
decoration: BoxDecoration(
color: _isSending ? Colors.grey : const Color(0xFF08710C),
shape: BoxShape.circle,
),
child: Center(
child: _isSending
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Icon(
Icons.send,
color: Colors.white,
size: 22,
),
),
),
),
],
),
],
),
),
);
}
@override
void dispose() {
messageCtrl.dispose();
super.dispose();
}
}
+323
View File
@@ -0,0 +1,323 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:taxglide/consts/download_helper.dart';
class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key});
@override
State<DownloadsScreen> createState() => _DownloadsScreenState();
}
class _DownloadsScreenState extends State<DownloadsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List<File> sentFiles = [];
List<File> receivedFiles = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadFiles();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadFiles() async {
setState(() {
isLoading = true;
});
try {
final sent = await DownloadHelper.getSentFiles();
final received = await DownloadHelper.getReceivedFiles();
setState(() {
sentFiles = sent;
receivedFiles = received;
isLoading = false;
});
} catch (e) {
print("Error loading files: $e");
setState(() {
isLoading = false;
});
}
}
Future<void> _deleteFile(String filePath, bool isSent) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Delete File"),
content: const Text("Are you sure you want to delete this file?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text("Delete"),
),
],
),
);
if (confirmed == true) {
final success = await DownloadHelper.deleteFile(filePath);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("File deleted successfully"),
backgroundColor: Colors.green,
),
);
_loadFiles();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Failed to delete file"),
backgroundColor: Colors.red,
),
);
}
}
}
String _getFileName(String path) {
return path.split('/').last;
}
String _getFileSize(File file) {
final bytes = file.lengthSync();
if (bytes < 1024) return "$bytes B";
if (bytes < 1024 * 1024) return "${(bytes / 1024).toStringAsFixed(1)} KB";
return "${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB";
}
IconData _getFileIcon(String path) {
final ext = path.split('.').last.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
return Icons.image;
} else if (ext == 'pdf') {
return Icons.picture_as_pdf;
} else if (['doc', 'docx'].contains(ext)) {
return Icons.description;
} else if (['xls', 'xlsx'].contains(ext)) {
return Icons.table_chart;
} else {
return Icons.insert_drive_file;
}
}
Color _getFileIconColor(String path) {
final ext = path.split('.').last.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
return Colors.blue;
} else if (ext == 'pdf') {
return Colors.red;
} else if (['doc', 'docx'].contains(ext)) {
return Colors.indigo;
} else if (['xls', 'xlsx'].contains(ext)) {
return Colors.green;
} else {
return Colors.grey;
}
}
Widget _buildFileList(List<File> files, bool isSent) {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (files.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSent ? Icons.upload_file : Icons.download,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
isSent ? "No sent files" : "No downloaded files",
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
isSent
? "Files you upload will appear here"
: "Downloaded files will appear here",
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadFiles,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
final fileName = _getFileName(file.path);
final fileSize = _getFileSize(file);
final isImage = DownloadHelper.isImageFile(file.path);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: () => DownloadHelper.openDownloadedFile(file.path),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// File icon or image preview
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: _getFileIconColor(file.path).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Icon(
_getFileIcon(file.path),
size: 30,
color: _getFileIconColor(file.path),
),
),
const SizedBox(width: 12),
// File info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
fileSize,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(width: 8),
// Delete button
IconButton(
icon: const Icon(Icons.delete_outline),
color: Colors.red,
onPressed: () => _deleteFile(file.path, isSent),
),
],
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
"Downloads",
style: TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
),
),
backgroundColor: Colors.white,
elevation: 1,
foregroundColor: Colors.black,
bottom: TabBar(
controller: _tabController,
labelColor: const Color(0xFF630A73),
unselectedLabelColor: Colors.grey,
indicatorColor: const Color(0xFF630A73),
tabs: [
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload, size: 18),
const SizedBox(width: 6),
Text("Sent (${sentFiles.length})"),
],
),
),
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.download, size: 18),
const SizedBox(width: 6),
Text("Received (${receivedFiles.length})"),
],
),
),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildFileList(sentFiles, true),
_buildFileList(receivedFiles, false),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _loadFiles,
backgroundColor: const Color(0xFF630A73),
child: const Icon(Icons.refresh, color: Colors.white),
),
);
}
}
+690
View File
@@ -0,0 +1,690 @@
// ⭐⭐⭐ UPDATED SWIPEABLE MESSAGE BUBBLE WITH IMAGE GRID VIEWER ⭐⭐⭐
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:taxglide/consts/download_helper.dart';
import 'package:taxglide/model/chat_model.dart';
class SwipeableMessageBubble extends StatefulWidget {
final MessageModel message;
final DateTime msgDate;
final String Function(DateTime) formatTime;
final Widget Function(int, int) buildTick;
final VoidCallback onSwipe;
final Function(int?)? onParentTagTap;
final Function(List<String> imageUrls, int initialIndex)?
onImageTap; // 🖼️ NEW
const SwipeableMessageBubble({
super.key,
required this.message,
required this.msgDate,
required this.formatTime,
required this.buildTick,
required this.onSwipe,
this.onParentTagTap,
this.onImageTap, // 🖼️ NEW
});
@override
State<SwipeableMessageBubble> createState() => SwipeableMessageBubbleState();
}
class SwipeableMessageBubbleState extends State<SwipeableMessageBubble>
with SingleTickerProviderStateMixin {
double _dragExtent = 0;
late AnimationController _controller;
late Animation<double> _animation;
final Map<String, bool> _downloadingFiles = {};
final Map<String, bool> _downloadedFiles = {};
bool _isHighlighted = false;
void highlight() {
if (!mounted) return;
// Reset first to ensure it re-triggers if already highlighted
setState(() => _isHighlighted = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _isHighlighted = true);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _isHighlighted = false);
});
});
}
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_animation = Tween<double>(begin: 0, end: 0).animate(_controller)
..addListener(() {
setState(() {
_dragExtent = _animation.value;
});
});
_checkDownloadedFiles();
}
Future<void> _checkDownloadedFiles() async {
for (var doc in widget.message.uploadedDocuments) {
final existingPath = await DownloadHelper.checkFileExistsInReceived(
doc.filePath,
);
if (existingPath != null && mounted) {
setState(() {
_downloadedFiles[doc.fileName] = true;
});
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!mounted) return;
final isUserMessage = widget.message.chatBy == "user";
final delta = details.primaryDelta ?? 0;
if ((isUserMessage && delta < 0) || (!isUserMessage && delta > 0)) {
setState(() {
_dragExtent += delta;
_dragExtent = _dragExtent.clamp(
isUserMessage ? -80.0 : 0.0,
isUserMessage ? 0.0 : 80.0,
);
});
}
}
void _handleDragEnd(DragEndDetails details) {
if (!mounted) return;
final threshold = 60.0;
if (_dragExtent.abs() > threshold) {
widget.onSwipe();
}
_animation = Tween<double>(
begin: _dragExtent,
end: 0,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.reset();
_controller.forward();
}
// 🖼️ NEW: Handle image tap - open grid viewer
void _handleImageTap(int tappedIndex) {
if (widget.onImageTap == null) return;
// Get all image URLs from uploaded documents
final imageUrls = widget.message.uploadedDocuments
.where((doc) => DownloadHelper.isImageFile(doc.fileName))
.map((doc) => doc.filePath)
.toList();
if (imageUrls.isNotEmpty) {
widget.onImageTap!(imageUrls, tappedIndex);
}
}
Future<void> _handleFileView(String url, String fileName) async {
if (!mounted) return;
try {
print("👁️ Opening file: $fileName");
final existingPath = await DownloadHelper.checkFileExistsInReceived(url);
if (existingPath != null) {
await DownloadHelper.openDownloadedFile(existingPath);
} else {
final sendFolder = await DownloadHelper.getSendFolder();
final localPath = '${sendFolder.path}/$fileName';
final file = File(localPath);
if (await file.exists()) {
await DownloadHelper.openDownloadedFile(localPath);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("File not found locally"),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
);
}
}
}
} catch (e) {
print("❌ View error: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Could not open file: $e"),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _handleFileDownload(String url, String fileName) async {
if (!mounted) return;
setState(() {
_downloadingFiles[fileName] = true;
});
try {
print("🔗 Downloading from: $url");
final result = await DownloadHelper.downloadFileToReceived(url);
if (!mounted) return;
if (result['success'] == true) {
setState(() {
_downloadedFiles[fileName] = true;
});
if (mounted) {
if (result['isNewDownload'] == false) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Opening $fileName"),
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Downloaded $fileName"),
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
),
);
}
}
}
} catch (e) {
print("❌ Download error: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Download failed: $e"),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_downloadingFiles[fileName] = false;
});
}
}
}
Future<void> _handleDownloadedFileView(String url, String fileName) async {
if (!mounted) return;
try {
final existingPath = await DownloadHelper.checkFileExistsInReceived(url);
if (existingPath != null) {
await DownloadHelper.openDownloadedFile(existingPath);
}
} catch (e) {
print("❌ View error: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Could not open file: $e"),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final isUserMessage = widget.message.chatBy == "user";
final hasDocuments = widget.message.uploadedDocuments.isNotEmpty;
print("💬 Building message bubble:");
print(" - ID: ${widget.message.id}");
print(" - chatBy: ${widget.message.chatBy}");
print(
" - uploadedDocuments count: ${widget.message.uploadedDocuments.length}",
);
return Stack(
children: [
if (_dragExtent.abs() > 10)
Positioned(
right: isUserMessage ? 20 : null,
left: isUserMessage ? null : 20,
top: 0,
bottom: 0,
child: Center(
child: Icon(
Icons.reply_rounded,
color: Colors.grey[600],
size: 24,
),
),
),
GestureDetector(
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: Transform.translate(
offset: Offset(_dragExtent, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.message.user != null)
Padding(
padding: const EdgeInsets.only(left: 5, right: 5),
child: Align(
alignment: isUserMessage
? Alignment.centerRight
: Alignment.centerLeft,
child: Text(
widget.message.user!.name,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
),
if (widget.message.parentTag != null)
GestureDetector(
onTap: () {
if (widget.onParentTagTap != null) {
widget.onParentTagTap!(
widget.message.parentTag?.id ?? widget.message.tagId,
);
}
},
child: Align(
alignment: isUserMessage
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(
bottom: 4,
left: 5,
right: 5,
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.60,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF630A73).withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 3,
height: 30,
decoration: BoxDecoration(
color: const Color(0xFF630A73),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.message.parentTag!.type == "file")
Row(
children: [
Icon(
Icons.insert_drive_file,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
widget
.message
.parentTag!
.fileName ??
"File",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
),
],
),
Text(
widget.message.parentTag!.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
],
),
),
],
),
),
),
),
Align(
alignment: isUserMessage
? Alignment.centerRight
: Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.70,
),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
decoration: BoxDecoration(
color: _isHighlighted
? Colors.green.withOpacity(0.2)
: (isUserMessage
? const Color(0xFFFFF8E2)
: Colors.white),
border: Border.all(
color: _isHighlighted
? Colors.green.withOpacity(0.5)
: (isUserMessage
? const Color(0xFFF9D9C8)
: const Color(0xFFE2E2E2)),
),
borderRadius: BorderRadius.circular(10),
boxShadow: isUserMessage
? []
: const [
BoxShadow(
color: Color(0x40979797),
blurRadius: 3.6,
offset: Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ⭐⭐⭐ DISPLAY uploadedDocuments WITH IMAGE GRID SUPPORT
if (hasDocuments)
...widget.message.uploadedDocuments.asMap().entries.map((
entry,
) {
final index = entry.key;
final doc = entry.value;
final isImage = DownloadHelper.isImageFile(
doc.fileName,
);
final isDownloading =
_downloadingFiles[doc.fileName] ?? false;
final isDownloaded =
_downloadedFiles[doc.fileName] ?? false;
// 🖼️ Calculate image index (only count images before this one)
final imageIndex = widget
.message
.uploadedDocuments
.sublist(0, index)
.where(
(d) =>
DownloadHelper.isImageFile(d.fileName),
)
.length;
return GestureDetector(
onTap: isImage
? () =>
_handleImageTap(
imageIndex,
) // 🖼️ Open grid viewer
: (isUserMessage
? () => _handleFileView(
doc.filePath,
doc.fileName,
)
: null),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
child: isImage
? Stack(
children: [
ClipRRect(
borderRadius:
BorderRadius.circular(8),
child: Image.network(
doc.filePath,
width: 150,
height: 100,
fit: BoxFit.cover,
errorBuilder:
(context, error, stack) {
return Container(
width: 200,
height: 150,
color: Colors.grey[300],
child: const Center(
child: Icon(
Icons.broken_image,
),
),
);
},
),
),
// ⭐ Download/View icon for receiver messages
if (!isUserMessage)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: isDownloading
? null
: isDownloaded
? () =>
_handleDownloadedFileView(
doc.filePath,
doc.fileName,
)
: () =>
_handleFileDownload(
doc.filePath,
doc.fileName,
),
child: Container(
padding:
const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black
.withOpacity(0.6),
shape: BoxShape.circle,
),
child: isDownloading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<
Color
>(
Colors
.white,
),
),
)
: Icon(
isDownloaded
? Icons
.visibility
: Icons
.download,
color: Colors.white,
size: 20,
),
),
),
),
],
)
: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(
8,
),
border: Border.all(
color: Colors.grey[300]!,
),
),
child: Row(
children: [
Icon(
Icons.insert_drive_file,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
doc.fileName,
style: const TextStyle(
fontSize: 13,
fontWeight:
FontWeight.w600,
),
),
Text(
doc.fileType
.toUpperCase(),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
),
),
GestureDetector(
onTap: isDownloading
? null
: () => isUserMessage
? _handleFileView(
doc.filePath,
doc.fileName,
)
: isDownloaded
? _handleDownloadedFileView(
doc.filePath,
doc.fileName,
)
: _handleFileDownload(
doc.filePath,
doc.fileName,
),
child: isDownloading
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
isUserMessage
? Icons.open_in_new
: isDownloaded
? Icons.visibility
: Icons.download,
color: Colors.grey[600],
size: 20,
),
),
],
),
),
),
);
}).toList(),
if (widget.message.message.isNotEmpty)
Text(
widget.message.message,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 3),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
widget.formatTime(widget.msgDate),
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
),
),
const SizedBox(width: 4),
widget.buildTick(
widget.message.isDelivered,
widget.message.isRead,
),
],
),
],
),
),
),
),
],
),
),
),
],
);
}
}
File diff suppressed because it is too large. Load diff
+296
View File
@@ -0,0 +1,296 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// WebSocket service for real-time chat communication
class ChatWebSocketService {
static final ChatWebSocketService _instance =
ChatWebSocketService._internal();
factory ChatWebSocketService() => _instance;
ChatWebSocketService._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? _currentChatId;
String? _wsUrl;
/// Check if WebSocket is currently connected
bool get isConnected => _isConnected;
/// Get current chat ID
String? get currentChatId => _currentChatId;
/// Connect to WebSocket for a specific chat
Future<void> connect({
required String chatId,
String baseUrl = "wss://taxglide.amrithaa.net:443/reverb",
String appKey = "2yj0lyzc9ylw2h03ts6i",
}) async {
try {
// Close existing connection if any
await disconnect();
_currentChatId = chatId;
_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();
}
}
/// Establish WebSocket connection
Future<void> _establishConnection() async {
try {
debugPrint("🔌 Connecting to: $_wsUrl");
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
// Listen to messages
_subscription = _channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
cancelOnError: false,
);
// Wait a bit for connection to establish
await Future.delayed(const Duration(milliseconds: 500));
_isConnected = true;
_reconnectAttempts = 0;
debugPrint("✅ WebSocket connected");
// Subscribe to chat channel
_subscribeToChannel();
// Start ping timer
_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);
// Handle Pusher ping
if (message['event'] == 'pusher:ping') {
_sendPong();
return;
}
// Handle connection established
if (message['event'] == 'pusher:connection_established') {
debugPrint("🔗 Connection established");
return;
}
// Handle subscription succeeded
if (message['event'] == 'pusher_internal:subscription_succeeded') {
debugPrint("✅ Subscribed to chat.${_currentChatId}");
return;
}
// Handle custom events (your chat messages)
if (message['event'] != null &&
!message['event'].toString().startsWith('pusher')) {
onMessageReceived?.call(message);
}
} catch (e) {
debugPrint("❌ Error parsing message: $e");
onError?.call(e);
}
}
/// Handle WebSocket errors
void _handleError(dynamic error) {
debugPrint("❌ WebSocket error: $error");
_isConnected = false;
onError?.call(error);
if (!_isIntentionalClose) {
_scheduleReconnect();
}
}
/// Handle disconnection
void _handleDisconnection() {
debugPrint("🔌 WebSocket disconnected");
_isConnected = false;
_stopPingTimer();
onDisconnected?.call();
if (!_isIntentionalClose) {
_scheduleReconnect();
}
}
/// Subscribe to chat channel
void _subscribeToChannel() {
if (_currentChatId == null) return;
final subscribeMessage = {
'event': 'pusher:subscribe',
'data': {'channel': 'chat.$_currentChatId'},
};
_sendMessage(subscribeMessage);
debugPrint("📡 Subscribing to chat.$_currentChatId");
}
/// Send pong response to ping
void _sendPong() {
final pongMessage = {'event': 'pusher:pong', 'data': {}};
_sendMessage(pongMessage);
debugPrint("🏓 Pong sent");
}
/// Start periodic ping timer
void _startPingTimer() {
_pingTimer?.cancel();
_pingTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isConnected) {
// Server will send ping, we just respond with pong
debugPrint("💓 Heartbeat check");
}
});
}
/// Stop ping timer
void _stopPingTimer() {
_pingTimer?.cancel();
_pingTimer = null;
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_isIntentionalClose) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint("❌ Max reconnection attempts reached");
onError?.call(
"Failed to reconnect after $_maxReconnectAttempts attempts",
);
return;
}
_reconnectTimer?.cancel();
_reconnectAttempts++;
debugPrint(
"🔄 Scheduling reconnect attempt $_reconnectAttempts in ${_reconnectDelay.inSeconds}s",
);
_reconnectTimer = Timer(_reconnectDelay, () async {
if (!_isIntentionalClose && _wsUrl != null && _currentChatId != null) {
debugPrint("🔄 Attempting to reconnect...");
try {
await _establishConnection();
} catch (e) {
debugPrint("❌ Reconnect failed: $e");
_scheduleReconnect();
}
}
});
}
/// Send a message through WebSocket
void _sendMessage(Map<String, dynamic> message) {
if (_channel == null || !_isConnected) {
debugPrint("⚠️ Cannot send message: Not connected");
return;
}
try {
final jsonMessage = json.encode(message);
_channel!.sink.add(jsonMessage);
debugPrint("📤 Sent: $jsonMessage");
} catch (e) {
debugPrint("❌ Error sending message: $e");
onError?.call(e);
}
}
/// Send a custom event to the channel
void sendEvent({
required String eventName,
required Map<String, dynamic> data,
}) {
final message = {
'event': eventName,
'data': data,
'channel': 'chat.$_currentChatId',
};
_sendMessage(message);
}
/// Disconnect from WebSocket
Future<void> disconnect() async {
_isIntentionalClose = true;
_reconnectTimer?.cancel();
_stopPingTimer();
await _subscription?.cancel();
await _channel?.sink.close();
_channel = null;
_subscription = null;
_isConnected = false;
_currentChatId = null;
debugPrint("🔌 WebSocket disconnected intentionally");
}
/// Switch to a different chat channel
Future<void> switchChannel(String newChatId) async {
if (newChatId == _currentChatId) return;
debugPrint("🔄 Switching from chat.$_currentChatId to chat.$newChatId");
// Unsubscribe from current channel
if (_currentChatId != null) {
final unsubscribeMessage = {
'event': 'pusher:unsubscribe',
'data': {'channel': 'chat.$_currentChatId'},
};
_sendMessage(unsubscribeMessage);
}
// Update chat ID and subscribe to new channel
_currentChatId = newChatId;
_subscribeToChannel();
}
/// Dispose and cleanup
void dispose() {
disconnect();
}
}
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/comman_webscoket.dart';
import 'package:taxglide/consts/local_store.dart';
import 'package:taxglide/consts/notification_webscoket.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/view/Mahi_chat/live_chat_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CommanChatBox extends ConsumerStatefulWidget {
final String? chatId;
const CommanChatBox({super.key, this.chatId});
@override
ConsumerState<CommanChatBox> createState() => _CommanChatBoxState();
}
class _CommanChatBoxState extends ConsumerState<CommanChatBox> {
bool _isWebSocketInitialized = false;
@override
void initState() {
super.initState();
_initializeWebSocket();
}
/// ✅ Initialize WebSocket on widget load
Future<void> _initializeWebSocket() async {
if (_isWebSocketInitialized) return;
try {
final String? userId = await LocalStore().getUserId();
if (userId != null && userId.isNotEmpty) {
final wsService = CommonWebSocketService();
// Setup message handler to refresh count
wsService.onMessageReceived = (message) {
debugPrint("📨 WebSocket message received: $message");
_refreshCount();
};
// Connect WebSocket
await wsService.connect(userId: userId);
_isWebSocketInitialized = true;
debugPrint("✅ WebSocket initialized for chat box");
// ✅ Subscribe NotificationWebSocket to the chat channel early
// so notifications arrive from ANY screen, not just the chat screen.
final String chatId =
widget.chatId ?? await LocalStore().getGeneralChatId() ?? '0';
if (chatId != '0' && chatId.isNotEmpty) {
NotificationWebSocket().watchChatChannel(chatId);
debugPrint(
"👁 CommanChatBox: watching chat channel $chatId for notifications",
);
}
}
} catch (e) {
debugPrint("❌ Failed to initialize WebSocket: $e");
}
}
/// ✅ Refresh count provider
void _refreshCount() {
try {
final chatId = widget.chatId ?? "0";
// Invalidate the count provider to trigger API call
ref.invalidate(countProvider(chatId));
debugPrint("🔄 Count refreshed for chatId: $chatId");
} catch (e) {
debugPrint("❌ Error refreshing count: $e");
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String?>(
future: widget.chatId != null
? Future.value(widget.chatId)
: LocalStore().getGeneralChatId(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final id = snapshot.data ?? "0";
final countAsync = ref.watch(countProvider(id));
return Positioned(
right: 20,
bottom: 120,
child: Stack(
clipBehavior: Clip.none,
children: [
// Main Chat Button
GestureDetector(
onTap: () async {
int cid = int.tryParse(id) ?? 0;
ref.invalidate(chatMessagesProvider);
// Navigate to chat screen
Get.to(() => LiveChatScreen(chatid: cid, fileid: '0'))?.then((
_,
) {
ref.read(notificationTriggerProvider.notifier).state++;
});
},
child: Container(
width: 53,
height: 53,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFBD49F7), Color(0xFF630A73)],
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.2,
offset: const Offset(0, 1),
),
],
),
child: Container(
margin: const EdgeInsets.all(3),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(AppAssets.generalchatimage),
),
),
),
// 🔴 Count Badge
Positioned(
right: -2,
top: -2,
child: countAsync.when(
loading: () => const SizedBox(),
error: (_, __) => const SizedBox(),
data: (data) {
if (data.count == 0) return const SizedBox();
return Container(
padding: const EdgeInsets.all(5),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
data.count.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
],
),
);
},
);
}
@override
void dispose() {
// Don't disconnect WebSocket here as it's shared across app
super.dispose();
}
}
@@ -0,0 +1,451 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/local_store.dart';
import 'package:taxglide/controller/api_contoller.dart'; // ✅ Needed for providers
import 'package:taxglide/view/Main_controller/comman_chat_box.dart';
import 'package:taxglide/view/screens/history/serivces_status_screen.dart'
show ServicesStatusScreen;
import 'package:taxglide/view/screens/home_screen.dart';
import 'package:taxglide/view/screens/list_service_screen.dart';
import 'package:taxglide/view/screens/profile/employee_profile/employee_profile_screen.dart';
import 'package:taxglide/view/screens/profile/profile_screen.dart';
class MainController extends ConsumerStatefulWidget {
final Widget? child; // Optional child (like ServiceRequestScreen)
final int? initialIndex; // Which tab to show initially
final int? sourceTabIndex; // Source tab (Home=0, Services=1)
const MainController({
Key? key,
this.child,
this.initialIndex,
this.sourceTabIndex,
}) : super(key: key);
@override
ConsumerState<MainController> createState() => _MainControllerState();
}
class _MainControllerState extends ConsumerState<MainController> {
int currentIndex = 0;
DateTime? lastBackPressed;
String? userRole;
/// 📌 Bottom bar labels
final List<String> _labels = [
'Home',
'Services',
'Service Details',
'Profile',
];
/// 📌 Screens list
List<Widget> _screens = [];
@override
void initState() {
super.initState();
currentIndex = widget.initialIndex ?? 0;
_loadRole();
}
/// 🔹 Load user role from local storage
Future<void> _loadRole() async {
userRole = await LocalStore().getRole(); // "employee" or "user"
_initScreens();
setState(() {});
}
/// 🔹 Initialize screens based on role
void _initScreens() {
_screens = [
const HomeScreen(),
ListServiceScreen(key: UniqueKey()),
ServicesStatusScreen(),
_getProfileScreen(), // ✅ role based
];
}
/// 🔹 Role based profile screen
Widget _getProfileScreen() {
if (userRole == "employee") {
return const EmployeeProfileScreen(); // 👈 employee profile
}
return const ProfileScreen(); // 👈 normal user profile
}
/// 🔄 Change tab index
void setBottomBarIndex(int index) {
setState(() {
currentIndex = index;
});
_refreshTab(index);
}
/// ✅ Refresh API data based on the current tab
void _refreshTab(int index) {
final container = ProviderScope.containerOf(context, listen: false);
switch (index) {
case 0:
// 🔁 Refresh Home screen APIs
container.refresh(profileProvider);
container.refresh(serviceListProvider);
ref.invalidate(chatMessagesProvider);
ref.invalidate(notificationCountProvider);
ref.invalidate(dashboardProvider);
break;
case 1:
// 🔁 Refresh ListServiceScreen APIs
container.refresh(serviceListProvider);
ref.invalidate(chatMessagesProvider);
ref.invalidate(dashboardProvider);
break;
case 2:
// 🔁 Refresh Service Status APIs
try {
ref.invalidate(serviceHistoryNotifierProvider);
ref.invalidate(serviceDetailProvider);
ref.invalidate(chatMessagesProvider);
ref.invalidate(dashboardProvider);
} catch (_) {}
break;
case 3:
// 🔁 Refresh Profile screen APIs
ref.invalidate(chatMessagesProvider);
container.refresh(profileProvider);
ref.invalidate(staffListProvider);
ref.invalidate(dashboardProvider);
break;
}
}
/// ✅ Handle Back button presses
Future<bool> _onWillPop() async {
// If inside ServiceRequestScreen → go back to originating tab
if (widget.child != null) {
Get.offAll(
() => MainController(initialIndex: widget.sourceTabIndex ?? 0),
);
return false;
}
// If not in Home tab, go back to Home
if (currentIndex != 0) {
setState(() => currentIndex = 0);
return false;
}
// Double back press to exit
DateTime now = DateTime.now();
if (lastBackPressed == null ||
now.difference(lastBackPressed!) > const Duration(seconds: 2)) {
lastBackPressed = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Press back again to exit"),
duration: Duration(seconds: 2),
),
);
return false;
}
await SystemNavigator.pop();
return true;
}
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.of(context).size;
// ✅ Show loading while screens are being initialized
if (_screens.isEmpty) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final Widget mainContent = widget.child ?? _screens[currentIndex];
final dashboardAsync = ref.watch(dashboardProvider);
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
backgroundColor: Colors.white.withAlpha(55),
body: Stack(
children: [
mainContent,
/// ✅ Bottom Navigation Bar
Positioned(
bottom: 0,
left: 0,
child: SafeArea(
top: false,
child: Container(
width: size.width,
height: 80,
child: Stack(
clipBehavior: Clip.none,
children: [
CustomPaint(
size: Size(size.width, 80),
painter: BNBCustomPainter(),
),
Center(
heightFactor: 0.6,
child: GestureDetector(
onTap: () {},
child: CustomPaint(
painter: PentagonPainter(
color: const Color(0xFF61277A),
),
child: Container(
width: 70,
height: 70,
alignment: Alignment.center,
child: Image.asset(
AppAssets.maincontroller,
color: Colors.white,
fit: BoxFit.contain,
height: 40,
width: 40,
),
),
),
),
),
SizedBox(
width: size.width,
height: 80,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(Icons.home, 0, _labels[0]),
_buildNavItem(Icons.list_alt_sharp, 1, _labels[1]),
SizedBox(width: size.width * 0.20),
_buildNavItem(Icons.history_edu, 2, _labels[2]),
_buildNavItem(Icons.person_2, 3, _labels[3]),
],
),
),
],
),
),
),
),
dashboardAsync.when(
data: (dashboard) {
final int? chatIdInt = dashboard.generalChatId;
if (chatIdInt == null || chatIdInt == 0) {
return const SizedBox(); // ❌ No chat box
}
return CommanChatBox(
chatId: chatIdInt.toString(), // ✅ INT → STRING
);
},
loading: () => const SizedBox(),
error: (e, _) => const SizedBox(),
),
],
),
),
);
}
/// ✅ Navigation Logic with Refresh
Widget _buildNavItem(IconData icon, int index, String label) {
bool isSelected = currentIndex == index;
return GestureDetector(
onTap: () {
// 🔹 If inside ServiceRequestScreen
if (widget.child != null) {
if (widget.initialIndex == index) return;
if (widget.sourceTabIndex == 0 && index == 1) {
Get.offAll(() => const MainController(initialIndex: 1));
return;
}
if (widget.sourceTabIndex == 1 && index == 1) return;
Get.offAll(() => MainController(initialIndex: index));
return;
}
// 🔹 If same tab tapped again → refresh
if (currentIndex == index) {
_refreshTab(index);
return;
}
// 🔹 Switch to a different tab
setBottomBarIndex(index);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: const Color(0xFF61277A), width: 2)
: null,
),
child: Icon(
icon,
color: isSelected
? const Color(0xFF61277A)
: const Color(0xFF6C7278),
size: isSelected ? 19.5 : 20.8,
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
label,
style: const TextStyle(
color: Color(0xFF61277A),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// ✅ Custom Curved Bottom Navigation Painter
class BNBCustomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
Path path = Path();
path.moveTo(0, 20);
path.quadraticBezierTo(size.width * 0.20, 0, size.width * 0.35, 0);
path.quadraticBezierTo(size.width * 0.40, 0, size.width * 0.40, 20);
path.arcToPoint(
Offset(size.width * 0.60, 20),
radius: const Radius.circular(20.0),
clockwise: false,
);
path.quadraticBezierTo(size.width * 0.60, 0, size.width * 0.65, 0);
path.quadraticBezierTo(size.width * 0.80, 0, size.width, 20);
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
path.close();
canvas.drawShadow(path, Colors.black.withOpacity(0.2), 5, true);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
/// ✅ Pentagon Button Painter
/// ✅ Pentagon Button Painter
class PentagonPainter extends CustomPainter {
final Color color;
final double cornerRadius;
PentagonPainter({
required this.color,
this.cornerRadius = 8.0, // Adjust this value for more/less rounding
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final double w = size.width;
final double cx = w / 2;
final double cy = size.height / 2;
final double r = w / 2;
// Calculate pentagon vertices
List<Offset> vertices = [];
for (int i = 0; i < 5; i++) {
double angle = (72 * i - 90) * math.pi / 180;
double x = cx + r * 0.95 * math.cos(angle);
double y = cy + r * 0.95 * math.sin(angle);
vertices.add(Offset(x, y));
}
// Create path with rounded corners
final path = Path();
for (int i = 0; i < vertices.length; i++) {
final current = vertices[i];
final next = vertices[(i + 1) % vertices.length];
final prev = vertices[(i - 1 + vertices.length) % vertices.length];
// Calculate direction vectors
final toCurrent = Offset(current.dx - prev.dx, current.dy - prev.dy);
final toNext = Offset(next.dx - current.dx, next.dy - current.dy);
// Normalize and scale by corner radius
final lengthToCurrent = math.sqrt(
toCurrent.dx * toCurrent.dx + toCurrent.dy * toCurrent.dy,
);
final lengthToNext = math.sqrt(
toNext.dx * toNext.dx + toNext.dy * toNext.dy,
);
final normalizedToCurrent = Offset(
toCurrent.dx / lengthToCurrent,
toCurrent.dy / lengthToCurrent,
);
final normalizedToNext = Offset(
toNext.dx / lengthToNext,
toNext.dy / lengthToNext,
);
// Points before and after the corner
final beforeCorner = Offset(
current.dx - normalizedToCurrent.dx * cornerRadius,
current.dy - normalizedToCurrent.dy * cornerRadius,
);
final afterCorner = Offset(
current.dx + normalizedToNext.dx * cornerRadius,
current.dy + normalizedToNext.dy * cornerRadius,
);
if (i == 0) {
path.moveTo(beforeCorner.dx, beforeCorner.dy);
} else {
path.lineTo(beforeCorner.dx, beforeCorner.dy);
}
// Draw rounded corner using quadratic bezier
path.quadraticBezierTo(
current.dx,
current.dy,
afterCorner.dx,
afterCorner.dy,
);
}
path.close();
canvas.drawShadow(path, Colors.black.withOpacity(0.3), 4, true);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
+204
View File
@@ -0,0 +1,204 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
import 'package:taxglide/consts/local_store.dart';
import 'package:taxglide/services/notification_service.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
import 'package:taxglide/view/screens/history/detail_screen.dart'
show DetailScreen;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'Mahi_chat/live_chat_screen.dart';
class FlashScreen extends ConsumerStatefulWidget {
const FlashScreen({super.key});
@override
ConsumerState<FlashScreen> createState() => _FlashScreenState();
}
class _FlashScreenState extends ConsumerState<FlashScreen> {
final LocalStore _localStore = LocalStore();
@override
void initState() {
super.initState();
_navigateNext();
}
Future<void> _navigateNext() async {
try {
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
String? token;
String? role;
try {
token = await _localStore.getToken();
role = await _localStore.getRole();
} catch (e) {
debugPrint("⚠️ Error reading stored data: $e");
// Clear corrupted data
await _localStore.clearAll();
}
if (!mounted) return;
// Check if token and role are valid
if (token != null &&
token.isNotEmpty &&
token != 'null' &&
role != null &&
role.isNotEmpty &&
role != 'null') {
debugPrint("✅ User logged in → Checking for initial notification");
try {
// Trigger notification refresh
ref.read(notificationTriggerProvider.notifier).state++;
ref.invalidate(chatMessagesProvider);
// ✅ Check if app was opened from notification (terminated state)
final notificationService = Get.find<NotificationService>();
RemoteMessage? initialMessage = notificationService.initialMessage;
if (initialMessage != null) {
debugPrint("📥 Processing initial notification");
await _handleInitialNotification(initialMessage.data);
} else {
// Navigate to main controller normally
Get.offAll(() => MainController());
}
} catch (e) {
debugPrint("⚠️ Error checking initial notification: $e");
// If no initial notification or error, go to MainController
Get.offAll(() => MainController());
}
} else {
debugPrint("🔒 No valid login data → Navigating to Login");
// Clear any invalid stored data
await _localStore.clearAll();
Get.offNamed(ConstRouters.login);
}
} catch (e) {
debugPrint("❌ Critical error in splash screen: $e");
// Fallback: always go to login if there's any error
if (mounted) {
await _localStore.clearAll();
Get.offNamed(ConstRouters.login);
}
}
}
/// Handle notification when app was opened from terminated state
Future<void> _handleInitialNotification(Map<String, dynamic> data) async {
String? type = data['type'];
String? idStr = data['id']?.toString();
if (type == null || idStr == null) {
debugPrint("⚠️ Invalid initial notification data");
Get.offAll(() => MainController());
return;
}
debugPrint("📍 Initial notification type: $type, ID: $idStr");
if (type == 'chat') {
// Navigate to chat
Get.offAll(() => MainController());
await Future.delayed(const Duration(milliseconds: 300));
Get.to(() => LiveChatScreen(chatid: int.parse(idStr), fileid: '0'));
} else if (type == 'service') {
// Navigate to service detail
int serviceId = int.tryParse(idStr) ?? 0;
Get.offAll(
() => MainController(
initialIndex: 2,
child: DetailScreen(id: serviceId, sourceTabIndex: 2),
),
);
} else {
// Unknown type
Get.offAll(() => MainController());
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFFFF8F0),
body: Stack(
children: [
// Background gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFF8F0), Color(0xFFF5F0FF)],
),
),
),
// Top blur circle
Positioned(
top: -431,
left: -256,
child: Container(
width: 582,
height: 582,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.backgroundgrainetflashtop.withOpacity(0.9),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 368.3, sigmaY: 368.3),
child: Container(color: Colors.transparent),
),
),
),
// Bottom blur circle
Positioned(
top: 638,
left: 149,
child: Container(
width: 934,
height: 934,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.backgroundgrainetflashbottom.withOpacity(0.94),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 591.0, sigmaY: 591.0),
child: Container(color: Colors.transparent),
),
),
),
// Center logo
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Image.asset(
AppAssets.taxgildelogo,
width: 361.09,
height: 333.94,
fit: BoxFit.contain,
),
),
),
],
),
);
}
}
@@ -0,0 +1,696 @@
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:get/get_core/src/get_main.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/model/chat_model.dart';
import 'package:taxglide/view/Mahi_chat/chat_profile_screen.dart';
import 'package:taxglide/view/Mahi_chat/file_images_screen.dart';
import 'package:taxglide/view/Mahi_chat/webscoket.dart';
// ⭐ Provider to hold the tagged message
final taggedMessageProvider = StateProvider<MessageModel?>((ref) => null);
// ⭐ Global key map to track message positions (if you want from outside)
final messageKeysProvider = StateProvider<Map<int, GlobalKey>>((ref) => {});
class CompletedLiveChatScreen extends ConsumerStatefulWidget {
final int chatid;
final String? fileid;
const CompletedLiveChatScreen({
super.key,
required this.chatid,
required this.fileid,
});
@override
ConsumerState<CompletedLiveChatScreen> createState() =>
_CompletedLiveChatScreenState();
}
class _CompletedLiveChatScreenState
extends ConsumerState<CompletedLiveChatScreen>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
bool get wantKeepAlive => true;
final ScrollController scrollController = ScrollController();
final ChatWebSocketService _wsService = ChatWebSocketService();
bool showScrollButton = false;
bool userIsReading = false;
bool _isInitialLoad = true;
bool _isPaginationLoading = false;
bool _hasNewMessage = false;
// For reply scroll - FIXED: Use unique key per message
final Map<String, GlobalKey> _messageKeys = {};
// ⭐⭐⭐ Key variables for smooth pagination
final double _paginationTriggerOffset = 300; // Start loading 300px before top
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initializeWebSocket();
scrollController.addListener(_handleScroll);
}
// ⭐⭐⭐ Enhanced scroll handler with early pagination trigger
void _handleScroll() {
if (!scrollController.hasClients) return;
double position = scrollController.position.pixels;
double bottom = scrollController.position.maxScrollExtent;
double top = scrollController.position.minScrollExtent;
// ⭐⭐⭐ EARLY PAGINATION: Load before reaching exact top
if (position <= top + _paginationTriggerOffset && !_isPaginationLoading) {
_triggerPagination();
}
// Show scroll to bottom button
if (position < bottom - 50) {
if (!userIsReading) {
setState(() {
userIsReading = true;
showScrollButton = true;
});
}
} else {
if (userIsReading) {
setState(() {
userIsReading = false;
showScrollButton = false;
});
}
}
}
// ⭐⭐⭐ Smooth pagination with scroll position preservation (NO JUMP TO BOTTOM)
Future<void> _triggerPagination() async {
final notifier = ref.read(chatMessagesProvider(widget.chatid).notifier);
if (!notifier.hasNextPage) return;
if (_isPaginationLoading) return;
if (!scrollController.hasClients) return;
setState(() {
_isPaginationLoading = true;
// userIsReading remains true while scrolling old messages
});
// Save current scroll metrics BEFORE loading more messages
final double beforeMaxExtent = scrollController.position.maxScrollExtent;
final double currentOffset = scrollController.position.pixels;
try {
await notifier.loadMessages(); // This should prepend older messages
// Restore scroll position after new messages loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !scrollController.hasClients) return;
final double afterMaxExtent = scrollController.position.maxScrollExtent;
final double diff = afterMaxExtent - beforeMaxExtent;
// Keep user at same visual message position
final double newOffset = currentOffset + diff;
scrollController.jumpTo(
newOffset.clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
),
);
if (mounted) {
setState(() {
_isPaginationLoading = false;
});
}
});
} catch (e) {
debugPrint("❌ Pagination error: $e");
if (mounted) {
setState(() {
_isPaginationLoading = false;
});
}
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
debugPrint("📱 App Lifecycle State: $state");
if (state == AppLifecycleState.resumed) {
debugPrint("🔄 App resumed - checking for new messages");
if (_hasNewMessage) {
_silentRefresh();
_hasNewMessage = false;
}
}
}
Future<void> _silentRefresh() async {
try {
debugPrint("🔄 Starting silent refresh...");
await ref.read(chatMessagesProvider(widget.chatid).notifier).refresh();
debugPrint("✅ Silent refresh completed");
// Only auto-scroll to bottom on new messages if user is NOT reading old ones
if (!userIsReading && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
} catch (e) {
debugPrint("❌ Error during silent refresh: $e");
}
}
Future<void> _initializeWebSocket() async {
try {
_wsService.onMessageReceived = (Map<String, dynamic> message) async {
debugPrint("📨 WebSocket message received: $message");
try {
final event = message['event'] as String?;
if (event == 'message.sent' || event == 'message.received') {
debugPrint("🔔 New message detected!");
if (WidgetsBinding.instance.lifecycleState ==
AppLifecycleState.resumed) {
debugPrint("✅ Screen active - refreshing now");
await _silentRefresh();
} else {
debugPrint("⏳ Screen not active - will refresh on resume");
_hasNewMessage = true;
}
}
} catch (e) {
debugPrint("❌ Error handling WebSocket message: $e");
}
};
_wsService.onConnected = () {
debugPrint("✅ WebSocket Connected");
};
_wsService.onDisconnected = () {
debugPrint("🔌 WebSocket Disconnected");
};
_wsService.onError = (error) {
debugPrint("❌ WebSocket Error: $error");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Connection error: $error"),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
}
};
await _wsService.connect(chatId: widget.chatid.toString());
} catch (e) {
debugPrint("❌ Error initializing WebSocket: $e");
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_wsService.disconnect();
scrollController.dispose();
super.dispose();
}
void scrollToBottom({bool animated = false}) {
if (!scrollController.hasClients) return;
setState(() {
_isPaginationLoading = false;
userIsReading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients) return;
if (animated) {
scrollController
.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
)
.then((_) {
Future.delayed(const Duration(milliseconds: 50), () {
if (scrollController.hasClients && mounted) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
});
} else {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
Future.delayed(const Duration(milliseconds: 10), () {
if (scrollController.hasClients && mounted) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
}
});
}
// ⭐ FIXED: Use unique string key instead of int
void scrollToMessage(String messageKey) {
final key = _messageKeys[messageKey];
if (key != null && key.currentContext != null) {
Scrollable.ensureVisible(
key.currentContext!,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
alignment: 0.3,
);
}
}
// ⭐ FIXED: Generate unique string key for each message
GlobalKey _getKeyForMessage(String messageKey) {
if (!_messageKeys.containsKey(messageKey)) {
_messageKeys[messageKey] = GlobalKey();
}
return _messageKeys[messageKey]!;
}
// ⭐ FIXED: Generate unique key string from message
String _getMessageKeyString(MessageModel msg, int index) {
// Use combination of ID, index, and document count for uniqueness
final id = msg.id;
final docCount = msg.uploadedDocuments.length;
final createdAt = msg.createdAt ?? '';
return 'msg_${id}_${index}_${docCount}_$createdAt';
}
String formatMessageDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final msgDay = DateTime(date.year, date.month, date.day);
if (msgDay == today) return "Today";
if (msgDay == today.subtract(const Duration(days: 1))) return "Yesterday";
return "${date.day}-${date.month}-${date.year}";
}
String formatTime(DateTime time) {
final hour = time.hour % 12 == 0 ? 12 : time.hour % 12;
final minute = time.minute.toString().padLeft(2, '0');
final period = time.hour >= 12 ? "PM" : "AM";
return "$hour:$minute $period";
}
Widget buildTick(int isDelivered, int isRead) {
if (isRead == 1) {
return const Icon(Icons.done_all, size: 16, color: Colors.blue);
} else if (isDelivered == 1) {
return const Icon(Icons.done_all, size: 16, color: Colors.grey);
} else {
return const Icon(Icons.check, size: 16, color: Colors.grey);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final chatAsync = ref.watch(chatMessagesProvider(widget.chatid));
final taggedMessage = ref.watch(taggedMessageProvider);
return Scaffold(
appBar: AppBar(
title: Text(
(widget.fileid != null &&
widget.fileid.toString().isNotEmpty &&
widget.fileid.toString() != "0")
? 'File ID : ${widget.fileid.toString()}'
: "Live Chat",
style: const TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
),
),
backgroundColor: Colors.white,
elevation: 1,
foregroundColor: Colors.black,
centerTitle: true,
actions: [
Padding(
padding: const EdgeInsets.only(right: 12), // ✅ Right end spacing
child: GestureDetector(
onTap: () {
Get.to(ChatProfileScreen(chatid: widget.chatid));
},
child: Container(
width: 37.446807861328125,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(6.81),
border: Border.all(
color: const Color(0xFF1E780C),
width: 0.85,
),
),
child: const Icon(Icons.person, color: Color(0xFF1E780C)),
),
),
),
],
),
body: Stack(
children: [
Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(AppAssets.backgroundimages),
fit: BoxFit.cover,
),
),
),
Column(
children: [
Expanded(
child: chatAsync.when(
loading: () => const Center(child: SizedBox.shrink()),
error: (e, _) => Center(child: Text(e.toString())),
data: (messages) {
// ✅ Only first load scrolls to bottom, NOT during pagination / scroll up
if (_isInitialLoad && !_isPaginationLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && mounted) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
_isInitialLoad = false;
}
return Stack(
children: [
ListView.builder(
controller: scrollController,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(
left: 15,
right: 15,
top: 15,
bottom: 15,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
final msgDate = DateTime.parse(msg.createdAt!);
final showDateHeader =
index == 0 ||
formatMessageDate(
DateTime.parse(
messages[index - 1].createdAt!,
),
) !=
formatMessageDate(msgDate);
// ⭐ FIXED: Generate unique key string
final messageKeyString = _getMessageKeyString(
msg,
index,
);
return Column(
key: _getKeyForMessage(messageKeyString),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDateHeader)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 10,
),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.06),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
formatMessageDate(msgDate),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
),
SwipeableMessageBubble(
message: msg,
msgDate: msgDate,
formatTime: formatTime,
buildTick: buildTick,
onSwipe: () {
ref
.read(
taggedMessageProvider.notifier,
)
.state =
msg;
},
onParentTagTap: (tagId) {
if (tagId != null) {
// ⭐ FIXED: Find the message key string for the tagId
final tagIndex = messages.indexWhere(
(m) => m.id == tagId,
);
if (tagIndex != -1) {
final taggedMsg = messages[tagIndex];
final tagKeyString =
_getMessageKeyString(
taggedMsg,
tagIndex,
);
scrollToMessage(tagKeyString);
}
}
},
// ⭐ FIXED: Use messageKeyString as ValueKey
key: ValueKey(messageKeyString),
),
],
);
},
),
// ⭐⭐⭐ Subtle loading indicator at top
if (_isPaginationLoading)
Positioned(
top: 5,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
SizedBox(width: 8),
Text(
"Loading...",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
],
);
},
),
),
if (taggedMessage != null)
GestureDetector(
onTap: () {
if (taggedMessage != null) {
// ⭐ FIXED: Need to find the message in the list to get proper key
final chatAsync = ref.read(
chatMessagesProvider(widget.chatid),
);
chatAsync.whenData((messages) {
final tagIndex = messages.indexWhere(
(m) => m.id == taggedMessage.id,
);
if (tagIndex != -1) {
final tagKeyString = _getMessageKeyString(
taggedMessage,
tagIndex,
);
scrollToMessage(tagKeyString);
}
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.grey[200],
border: Border(top: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
children: [
Container(
width: 4,
height: 50,
decoration: BoxDecoration(
color: const Color(0xFF630A73),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
taggedMessage.user?.name ?? "Unknown",
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Color(0xFF630A73),
),
),
const SizedBox(height: 2),
if (taggedMessage.type == "file" &&
taggedMessage.fileName != null)
Row(
children: [
Icon(
Icons.attach_file,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
taggedMessage.fileName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey[700],
),
),
),
],
)
else
Text(
taggedMessage.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey[700],
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
padding: EdgeInsets.zero,
onPressed: () {
ref.read(taggedMessageProvider.notifier).state =
null;
},
),
],
),
),
),
],
),
if (showScrollButton)
Positioned(
bottom: 150,
right: 20,
child: FloatingActionButton(
heroTag: "scrollBtn",
backgroundColor: Colors.green,
mini: true,
onPressed: () {
scrollToBottom(animated: true);
},
child: const Icon(Icons.arrow_downward, color: Colors.white),
),
),
],
),
);
}
}
File diff suppressed because it is too large. Load diff
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/controller/api_contoller.dart';
class FlitterPopup {
static void show(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return const _FilterBottomSheet();
},
);
}
}
class _FilterBottomSheet extends StatefulWidget {
const _FilterBottomSheet({Key? key}) : super(key: key);
@override
State<_FilterBottomSheet> createState() => _FilterBottomSheetState();
}
class _FilterBottomSheetState extends State<_FilterBottomSheet>
with SingleTickerProviderStateMixin {
DateTime? fromDate;
DateTime? toDate;
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> pickFromDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => fromDate = picked);
}
}
Future<void> pickToDate() async {
final picked = await showDatePicker(
context: context,
initialDate: fromDate ?? DateTime.now(),
firstDate: fromDate ?? DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => toDate = picked);
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: SlideTransition(
position: _slideAnimation,
child: Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Filter By Date",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
/// FROM DATE
InkWell(
onTap: pickFromDate,
child: _dateBox(title: "From Date", value: fromDate),
),
const SizedBox(height: 12),
/// TO DATE
InkWell(
onTap: pickToDate,
child: _dateBox(title: "To Date", value: toDate),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: SizedBox(
width: double.infinity,
height: 57,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(
0xFFF7E9FF,
), // ✅ background
elevation: 6, // ✅ shadow depth
shadowColor: const Color(
0x40000000,
), // ✅ shadow color
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), // ✅ radius
side: const BorderSide(
color: Color(0xFFD39FEA), // ✅ border color
width: 1, // ✅ border width
),
),
),
child: const Text(
"Cancel",
style: TextStyle(
fontFamily: "Gilroy-SemiBold", // ✅ Font
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.3, // ✅ line-height 130%
letterSpacing: 0.64, // ✅ 4%
color: Color(0xFF61277A), // ✅ text color
),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Consumer(
builder: (context, ref, _) {
return CommanButton(
text: 'Submit',
onPressed: () {
final from = fromDate == null
? null
: "${fromDate!.year}-${fromDate!.month.toString().padLeft(2, '0')}-${fromDate!.day.toString().padLeft(2, '0')}";
final to = toDate == null
? null
: "${toDate!.year}-${toDate!.month.toString().padLeft(2, '0')}-${toDate!.day.toString().padLeft(2, '0')}";
// ✅ Apply filter to ALL TABS
ref
.read(
serviceHistoryNotifierProvider(
"pending",
).notifier,
)
.applyDateFilter(from: from, to: to);
ref
.read(
serviceHistoryNotifierProvider(
"inprogress",
).notifier,
)
.applyDateFilter(from: from, to: to);
ref
.read(
serviceHistoryNotifierProvider(
"completed",
).notifier,
)
.applyDateFilter(from: from, to: to);
ref
.read(
serviceHistoryNotifierProvider(
"cancelled",
).notifier,
)
.applyDateFilter(from: from, to: to);
Navigator.pop(context); // ✅ Close popup
},
);
},
),
),
],
),
const SizedBox(height: 10),
],
),
),
),
);
}
Widget _dateBox({required String title, DateTime? value}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value == null ? title : "${value.day}-${value.month}-${value.year}",
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.calendar_month, size: 20),
],
),
);
}
}
@@ -0,0 +1,551 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
import 'package:taxglide/view/screens/history/detail_screen.dart';
class PendingScreen extends ConsumerWidget {
final String status;
const PendingScreen({super.key, required this.status});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pendingAsync = ref.watch(serviceHistoryNotifierProvider(status));
final size = MediaQuery.of(context).size;
final width = size.width;
final height = size.height;
return pendingAsync.when(
data: (data) {
final list = data.data ?? [];
if (list.isEmpty) {
// Using Get's capitalize extension explicitly
return Center(child: Text("No ${status.capitalizeFirst} Requests"));
}
return ListView.separated(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(
width * 0.04,
height * 0.01,
width * 0.04,
height * 0.2,
),
itemCount: list.length,
separatorBuilder: (_, __) => SizedBox(height: height * 0.01),
itemBuilder: (context, index) {
final item = list[index];
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE1E1E1), width: 1),
boxShadow: [
BoxShadow(
color: const Color.fromARGB(117, 192, 192, 189),
blurRadius: 15,
offset: const Offset(0, 1),
),
],
),
padding: EdgeInsets.all(width * 0.03),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 File ID Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'File ID : ',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.04,
color: const Color(0xFF111827),
),
),
TextSpan(
text: '${item.id}',
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: width * 0.04,
color: const Color(0xFF111827),
),
),
],
),
),
// 🔹 Status Badges
if (item.status == 'Waiting for Admin' ||
item.status == 'Payment Pending' ||
item.status == 'Cancelled By Admin')
Container(
width: width * 0.3,
height: height * 0.04,
decoration: BoxDecoration(
color: const Color(0xFFFFE8E8),
borderRadius: BorderRadius.circular(5.52),
border: Border.all(
color: const Color(0xFFFFD7D7),
width: 1.84,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.17,
offset: const Offset(0, 3.68),
),
],
),
child: Center(
child: Text(
item.status.toString(),
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w400,
fontSize: 11.03,
color: Color(0xFFFF0F0F),
),
),
),
),
// 🔹 In Progress Badge
if (item.status == 'In Progress')
Container(
width: 98,
height: 34.9,
decoration: BoxDecoration(
color: const Color(0xFFEAFAE6),
borderRadius: BorderRadius.circular(6.21),
border: Border.all(
color: const Color(0xFFDDFFDD),
width: 2.07,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 8.08,
offset: const Offset(0, 4.14),
),
],
),
child: const Center(
child: Text(
'In Progress',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w400,
fontSize: 12.43,
letterSpacing: 0.03,
color: Color(0xFF12800C),
),
),
),
),
// 🔹 Completed Badge
if (item.status == 'Completed')
Container(
width: 87,
height: 31,
decoration: BoxDecoration(
color: const Color(0xFFFAF7E6),
borderRadius: BorderRadius.circular(5.52),
border: Border.all(
color: const Color(0xFFFFE9DD),
width: 1.84,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.17,
offset: const Offset(0, 3.68),
),
],
),
child: const Center(
child: Text(
'Completed',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w400,
fontSize: 11.03,
letterSpacing: 0.03,
color: Color(0xFFFF630F),
),
),
),
),
],
),
SizedBox(height: height * 0.015),
// 🔹 Request Type
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Request Type : ',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
TextSpan(
text: item.service,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
],
),
),
SizedBox(height: height * 0.015),
// 🔹 Date and Time Row
Row(
children: [
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Date : ',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
TextSpan(
text: item.createdDate,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
],
),
),
SizedBox(width: width * 0.05),
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Time : ',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
TextSpan(
text: item.createdTime,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
],
),
),
],
),
SizedBox(height: height * 0.015),
// 🔹 Message
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Message : ',
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
TextSpan(
text: item.message,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontWeight: FontWeight.w500,
fontSize: width * 0.035,
color: const Color(0xFF111827),
),
),
],
),
),
SizedBox(height: height * 0.015),
const Divider(),
SizedBox(height: height * 0.015),
// 🔹 Conditional Buttons & Amount
if (item.paymentStatus == "Unpaid") ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Total Amount",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: width * 0.038,
color: const Color(0xFF111827),
),
),
Text(
'₹ ${item.paymentAmount.toString()}',
style: TextStyle(
fontFamily: 'Roboto',
fontWeight: FontWeight.w600,
fontSize: width * 0.05,
color: const Color(0xFF111827),
),
),
],
),
SizedBox(height: height * 0.015),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
// Add payment functionality here
},
child: Container(
height: height * 0.055,
decoration: BoxDecoration(
color: const Color(0xFFF7E9FF),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: const Color(0xFFD39FEA),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.8,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
"Pay Now",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 16,
color: Color(0xFF61277A),
),
),
),
),
),
),
SizedBox(width: width * 0.03),
Expanded(
child: GestureDetector(
onTap: () {
ref.invalidate(serviceDetailProvider);
Get.offAll(
() => MainController(
initialIndex: 2, // ✅ History tab
child: DetailScreen(
id: int.parse(item.id.toString()),
sourceTabIndex: 2, // ✅ Remember source
),
),
);
},
child: Container(
height: height * 0.055,
decoration: BoxDecoration(
color: const Color(0xFF5F297B),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.8,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
"View Details",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
),
),
),
),
),
),
],
),
] else if (item.paymentStatus == "Paid") ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 149,
height: 33,
decoration: BoxDecoration(
color: const Color(0xFF04690B),
borderRadius: BorderRadius.circular(4.53),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 5.89,
offset: const Offset(0, 3.02),
),
],
),
alignment: Alignment.center,
child: const Text(
"Payment Status: Paid",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w400,
fontSize: 12.08,
letterSpacing: 0.04,
height: 1.3,
color: Colors.white,
),
),
),
GestureDetector(
onTap: () {
ref.invalidate(serviceDetailProvider);
Get.offAll(
() => MainController(
initialIndex: 2, // ✅ History tab
child: DetailScreen(
id: int.parse(item.id.toString()),
sourceTabIndex: 2, // ✅ Remember source
),
),
);
},
child: Container(
height: height * 0.055,
width: width * 0.35,
decoration: BoxDecoration(
color: const Color(0xFF5F297B),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.8,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
"View Details",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
),
),
),
),
),
],
),
] else if (item.paymentStatus == "Waiting") ...[
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {
ref.invalidate(serviceDetailProvider);
Get.offAll(
() => MainController(
initialIndex: 2, // ✅ History tab
child: DetailScreen(
id: int.parse(item.id.toString()),
sourceTabIndex: 2, // ✅ Remember source
),
),
);
},
child: Container(
height: height * 0.055,
width: width * 0.35,
decoration: BoxDecoration(
color: const Color(0xFF5F297B),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 7.8,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
"View Details",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
),
),
),
),
),
),
],
SizedBox(height: height * 0.02),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text("Error: $error", style: const TextStyle(color: Colors.red)),
),
);
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/view/screens/history/flitter_popup.dart';
import 'package:taxglide/view/screens/history/pending_screen.dart';
class ServicesStatusScreen extends ConsumerStatefulWidget {
const ServicesStatusScreen({super.key});
@override
ConsumerState<ServicesStatusScreen> createState() =>
_ServicesStatusScreenState();
}
class _ServicesStatusScreenState extends ConsumerState<ServicesStatusScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = [
"Pending",
"In Progress",
"Completed",
"Cancelled Request",
];
@override
void initState() {
super.initState();
_tabController = TabController(
length: _tabs.length,
vsync: this,
animationDuration: Duration.zero,
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ✅ Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 21),
child: SizedBox(
height: 50,
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(width: 40), // ✅ Left side space balance
// ✅ CENTER TITLE
const Text(
"Service Status",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 24,
color: Color(0xFF111827),
),
),
// ✅ RIGHT SIDE FILTER ICON
GestureDetector(
onTap: () {
FlitterPopup.show(context);
},
child: Container(
width: 39,
height: 39,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF055976),
width: 0.8,
),
),
child: const Center(
child: Icon(
Icons.filter_list,
color: Color(0xFF055976),
size: 22,
),
),
),
),
],
),
),
),
// ✅ Tabs
Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFDEDEDE), width: 1),
),
),
child: Align(
alignment: Alignment.centerLeft,
child: TabBar(
controller: _tabController,
isScrollable: true,
padding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
tabAlignment: TabAlignment.start,
splashFactory: NoSplash.splashFactory,
overlayColor: const MaterialStatePropertyAll(
Colors.transparent,
),
indicatorColor: const Color(0xFF5F297B),
indicator: const UnderlineTabIndicator(
borderSide: BorderSide(width: 3, color: Color(0xFF5F297B)),
insets: EdgeInsets.fromLTRB(6, 0, 4, 10),
),
labelColor: const Color(0xFF5F297B),
unselectedLabelColor: const Color(0xFF6C7278),
labelStyle: const TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
fontSize: 16,
height: 1.4,
letterSpacing: 0.03,
color: Color(0xFF5F297B),
),
unselectedLabelStyle: const TextStyle(
fontFamily: "Gilroy-Regular",
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.4,
letterSpacing: 0.03,
color: Color(0xFF6C7278),
),
tabs: _tabs.map((tab) => Tab(text: tab)).toList(),
),
),
),
// ✅ Tab Views
Expanded(
child: TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(),
children: const [
ColoredBox(
color: Color.fromARGB(255, 230, 229, 229),
child: PendingScreen(status: "pending"),
),
ColoredBox(
color: Color.fromARGB(255, 230, 229, 229),
child: PendingScreen(status: "inprogress"),
),
ColoredBox(
color: Color.fromARGB(255, 230, 229, 229),
child: PendingScreen(status: "completed"),
),
ColoredBox(
color: Color.fromARGB(255, 230, 229, 229),
child: PendingScreen(status: "cancelled"),
),
],
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/comman_serivce.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/model/serivce_list_model.dart';
class ListServiceScreen extends ConsumerStatefulWidget {
const ListServiceScreen({super.key});
@override
ConsumerState<ListServiceScreen> createState() => _ListServiceScreenState();
}
class _ListServiceScreenState extends ConsumerState<ListServiceScreen> {
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final serviceAsync = ref.watch(serviceListProvider);
final screenWidth = size.width;
return Scaffold(
body: SafeArea(
child: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(AppAssets.backgroundimages),
fit: BoxFit.cover,
),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"List of Services Offered",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontStyle: FontStyle.normal,
fontSize: 18,
height: 1.3,
letterSpacing: 0.72,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 26),
/// 🔹 Service List from API
serviceAsync.when(
data: (services) {
if (services.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 40),
child: Center(
child: Text(
"No Services Available",
style: TextStyle(
fontSize: 16,
fontFamily: 'Gilroy-SemiBold',
color: Colors.black54,
),
),
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: services.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: screenWidth < 400
? 3
: screenWidth < 700
? 3
: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemBuilder: (context, index) {
return CommonServiceItem(
service: ServiceListModel(
id: services[index].id,
service: services[index].service,
icon: services[index].icon,
),
sourceTabIndex: 1,
);
},
);
},
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(),
),
),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text('Error loading services: $err'),
),
),
),
],
),
),
),
),
),
);
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/model/notification_model.dart';
import 'package:taxglide/view/Mahi_chat/live_chat_screen.dart';
import 'package:taxglide/view/screens/history/detail_screen.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
class NotificationScreen extends ConsumerStatefulWidget {
const NotificationScreen({super.key});
@override
ConsumerState<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends ConsumerState<NotificationScreen> {
@override
Widget build(BuildContext context) {
final state = ref.watch(notificationProvider(1)); // page = 1
return Scaffold(
body: SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(
child: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text("Error: $e")),
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (context, index) {
final item = list[index];
return _buildNotificationTile(item);
},
),
),
),
],
),
),
);
}
// HEADER UI
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 21),
child: SizedBox(
height: 50,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
const Text(
"Notification",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 24,
color: Color(0xFF111827),
),
),
Positioned(
left: 0,
child: GestureDetector(
onTap: () {
// 🔥 Force refresh when user goes BACK
ref.read(notificationTriggerProvider.notifier).state++;
ref.invalidate(chatMessagesProvider);
Get.offAll(() => const MainController());
},
child: const Icon(Icons.arrow_back_ios_rounded),
),
),
],
),
),
);
}
// NOTIFICATION TILE
Widget _buildNotificationTile(NotificationModel item) {
return GestureDetector(
onTap: () {
if (item.page.isEmpty || item.pageId == null) return;
final String page = item.page.toLowerCase();
final int id = item.pageId!;
if (page == 'chat') {
// Navigate to chat
Get.to(() => LiveChatScreen(chatid: id, fileid: '0'))?.then((_) {
ref.read(notificationTriggerProvider.notifier).state++;
ref.invalidate(chatMessagesProvider);
ref.invalidate(notificationProvider);
});
} else if (page == 'service') {
// Navigate to service detail
Get.to(
() => MainController(
initialIndex: 2,
child: DetailScreen(id: id, sourceTabIndex: 2),
),
);
} else if (page.contains('profile')) {
// Navigate to profile (Assuming MainController index 3 is Profile)
Get.to(() => const MainController(initialIndex: 3));
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w700,
fontSize: 16,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
item.description,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
Text(
item.createdAt,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}
}
@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/controller/api_contoller.dart';
class EmployeeProfileList extends ConsumerStatefulWidget {
const EmployeeProfileList({super.key});
@override
ConsumerState<EmployeeProfileList> createState() =>
_EmployeeProfileListState();
}
class _EmployeeProfileListState extends ConsumerState<EmployeeProfileList> {
String yesNo(dynamic value) {
return value == 1 ? "Yes" : "No";
}
String statusText(dynamic value) {
return value == 1 ? "Active" : "Inactive";
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final profileAsync = ref.watch(employeeProfileProvider);
return Scaffold(
body: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(AppAssets.backgroundimages),
fit: BoxFit.cover,
),
),
child: SafeArea(
child: profileAsync.when(
data: (profile) {
final data = profile.data;
if (data == null) {
return const Center(
child: Text(
"No profile data found",
style: TextStyle(color: Colors.white),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// ---------------- HEADER ----------------
Padding(
padding: const EdgeInsets.symmetric(vertical: 21),
child: SizedBox(
height: 50,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
const Text(
"KYC Details",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight
.w600, // equivalent to 400 (Normal)
fontStyle: FontStyle.normal,
fontSize: 24,
height: 1.3, // line-height: 130%
letterSpacing:
0.04 * 24, // 4% of font size = 0.96px
color: Color(0xFF111827), // background: #111827
),
),
Positioned(
left: 0,
child: GestureDetector(
onTap: () => Get.back(),
child: const Icon(Icons.arrow_back_ios_rounded),
),
),
],
),
),
),
const SizedBox(height: 20),
/// ---------------- CARD ----------------
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Personal Details",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontSize: 20,
color: Color(0xFF111827),
),
),
const SizedBox(height: 25),
_buildDetailRow("Name : ", data.name),
const SizedBox(height: 24),
_buildDetailRow("Mobile : ", data.mobile),
const SizedBox(height: 24),
_buildDetailRow("Email : ", data.email),
const SizedBox(height: 24),
_buildDetailRow(
"Company Name : ",
data.company?.companyName ?? "N/A",
),
const SizedBox(height: 24),
_buildDetailRow(
"Service : ",
yesNo(data.createService),
),
const SizedBox(height: 24),
_buildDetailRow("Status : ", statusText(data.status)),
const SizedBox(height: 24),
_buildDetailRow(
"General Chat : ",
yesNo(data.generalChat),
),
],
),
),
const SizedBox(height: 30),
/// ---------------- COMPANY LOGO ----------------
if (data.company?.companyLogo != null)
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 15,
mainAxisSpacing: 15,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildImageBox(
"Company Logo",
data.company!.companyLogo!,
),
],
),
const SizedBox(height: 40),
],
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (e, _) => Center(
child: Text(
"Error loading profile: $e",
style: const TextStyle(color: Colors.redAccent),
),
),
),
),
),
);
}
/// ---------------- TEXT ROW ----------------
Widget _buildDetailRow(String title, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 14,
height: 1.3,
letterSpacing: 0.64,
color: Colors.black87,
),
),
Flexible(
child: Text(
value,
softWrap: true,
overflow: TextOverflow.visible,
style: const TextStyle(
fontFamily: 'Gilroy-Regular',
fontWeight: FontWeight.w400,
fontSize: 14,
height: 1.3,
letterSpacing: 0.64,
color: Color(0xFF111827),
),
),
),
],
);
}
/// ---------------- IMAGE BOX ----------------
Widget _buildImageBox(String label, String imageUrl) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
children: [
const SizedBox(height: 10),
Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(height: 10),
Expanded(
child: Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
),
),
const SizedBox(height: 10),
],
),
);
}
}
@@ -0,0 +1,450 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/controller/api_repository.dart';
import 'package:taxglide/main.dart';
import 'package:taxglide/router/consts_routers.dart';
class EmployeeProfileScreen extends ConsumerStatefulWidget {
const EmployeeProfileScreen({super.key});
@override
ConsumerState<EmployeeProfileScreen> createState() =>
_EmployeeProfileScreenState();
}
class _EmployeeProfileScreenState extends ConsumerState<EmployeeProfileScreen> {
String? selectedItem;
bool _isLoggingOut = false;
@override
Widget build(BuildContext context) {
final profileAsync = ref.watch(employeeProfileProvider);
return Scaffold(
backgroundColor: Colors.white,
body: profileAsync.when(
data: (profile) {
final data = profile.data;
return Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: double.infinity,
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(50),
bottomRight: Radius.circular(50),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(140),
bottomRight: Radius.circular(0),
),
child: Align(
alignment: Alignment.centerRight,
child: Image.asset(
AppAssets.profilebanner,
fit: BoxFit.cover,
height: 200,
width: MediaQuery.of(context).size.width * 1.3,
),
),
),
),
Positioned(
bottom: -50,
left: 0,
right: 0,
child: Center(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child:
(data?.company?.companyLogo != null &&
data!.company!.companyLogo!.isNotEmpty)
? CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(
data.company!.companyLogo!,
),
)
: Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black87,
),
child: Center(
child: Text(
(data?.name != null &&
data!.name.isNotEmpty)
? data.name[0].toUpperCase()
: "M",
style: const TextStyle(
color: Colors.white,
fontSize: 40,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
],
),
const SizedBox(height: 60),
Text(
data?.name ?? 'No Company Name',
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 20,
color: Colors.black,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
_buildMenuItem(
id: 'profile',
icon: Icons.person_outline,
title: 'Profile',
onTap: () {
setState(() {
selectedItem = 'profile';
});
ref.invalidate(employeeProfileProvider);
Get.toNamed(ConstRouters.employeekycdetailslist);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'terms',
icon: Icons.description_outlined,
title: 'Terms and Conditions',
onTap: () {
setState(() {
selectedItem = 'terms';
});
ref.invalidate(termsProvider);
Get.toNamed(ConstRouters.terms);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'policy',
icon: Icons.policy_outlined,
title: 'Policy',
onTap: () {
setState(() {
selectedItem = 'policy';
});
ref.invalidate(termsProvider);
Get.toNamed(ConstRouters.policy);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'logout',
icon: Icons.logout,
title: 'Log out',
onTap: () {
setState(() {
selectedItem = 'logout';
});
_showLogoutPopup(context);
},
),
],
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
),
);
}
/// ----------------------- LOGOUT POPUP ---------------------
void _showLogoutPopup(BuildContext context) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Logout',
barrierColor: Colors.black.withOpacity(0.2),
transitionDuration: const Duration(milliseconds: 300),
pageBuilder: (context, animation, secondaryAnimation) {
return const SizedBox.shrink();
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
final offsetAnimation =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutBack),
);
return SlideTransition(
position: offsetAnimation,
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Material(
color: Colors.transparent,
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: 160,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7.75),
border: Border.all(color: Colors.white, width: 3.87),
boxShadow: const [
BoxShadow(
color: Color(0x7A5E5E5E),
offset: Offset(0, 2.32),
blurRadius: 9.14,
spreadRadius: 3.1,
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Note",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 20,
color: Colors.black,
),
),
const Text(
"Are you sure you want to log out?",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w500,
fontSize: 15,
color: Colors.black87,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
"Cancel",
style: TextStyle(
color: Color(0xFF535A5B),
fontSize: 16,
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: _isLoggingOut
? null
: () async {
Navigator.pop(context);
setState(() {
_isLoggingOut = true;
});
try {
await ApiRepository().logout();
_showSnackBar("Logout successful");
await Future.delayed(
const Duration(milliseconds: 800),
);
Get.offAllNamed(
ConstRouters.employeelogin,
);
} catch (e) {
_showSnackBar(
e.toString(),
isError: true,
);
} finally {
setState(() {
_isLoggingOut = false;
});
}
},
child: _isLoggingOut
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF5F297B),
),
)
: const Text(
"Log Out",
style: TextStyle(
color: Color(0xFF5F297B),
fontSize: 16,
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
),
),
),
);
},
);
}
/// ----------------------- MENU ITEM ---------------------
Widget _buildMenuItem({
required String id,
required IconData icon,
required String title,
required VoidCallback onTap,
}) {
final isSelected = selectedItem == id;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: Row(
children: [
SizedBox(
width: 24,
height: 48,
child: isSelected
? CustomPaint(
size: const Size(24, 48),
painter: BeveledTrianglePainter(),
)
: const SizedBox.shrink(),
),
Expanded(
child: Container(
height: 58,
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFEEFAFF)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(icon, size: 24, color: Colors.black87),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: const TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
),
],
),
),
),
],
),
);
}
/// ----------------------- SNACK BAR ---------------------
void _showSnackBar(String msg, {bool isError = false}) {
rootScaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
backgroundColor: isError ? Colors.red : AppColors.commanbutton,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 70, vertical: 25),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
content: Row(
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),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class BeveledTrianglePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF5E2976)
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, 4)
..lineTo(size.width - 6, size.height / 2)
..lineTo(0, size.height - 4)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,449 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/download_helper.dart';
import 'package:taxglide/controller/api_consts.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/router/consts_routers.dart';
class KycDetailsList extends ConsumerStatefulWidget {
const KycDetailsList({super.key});
@override
ConsumerState<KycDetailsList> createState() => _KycDetailsListState();
}
class _KycDetailsListState extends ConsumerState<KycDetailsList> {
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final profileAsync = ref.watch(profileProvider);
return Scaffold(
body: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(AppAssets.backgroundimages),
fit: BoxFit.cover,
),
),
child: SafeArea(
child: profileAsync.when(
data: (profile) {
final data = profile.data;
if (data == null) {
return const Center(
child: Text(
"No KYC data found",
style: TextStyle(color: Colors.white),
),
);
}
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 21),
child: SizedBox(
height: 50,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
const Text(
"KYC Details",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight
.w600, // equivalent to 400 (Normal)
fontStyle: FontStyle.normal,
fontSize: 24,
height: 1.3, // line-height: 130%
letterSpacing:
0.04 * 24, // 4% of font size = 0.96px
color: Color(0xFF111827), // background: #111827
),
),
Positioned(
left: 0,
child: GestureDetector(
onTap: () => Get.back(),
child: const Icon(Icons.arrow_back_ios_rounded),
),
),
],
),
),
),
const SizedBox(height: 20),
// White Card
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Personal Details",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight
.w400, // equivalent to 400 (Normal)
fontStyle: FontStyle.normal,
fontSize: 20,
height: 1.3, // line-height: 130%
letterSpacing:
0.04 * 20, // 4% of font size = 0.8px
color: Color(
0xFF111827,
), // background: #111827
),
),
GestureDetector(
onTap: () {
Get.toNamed(ConstRouters.kycdetails);
},
child: Container(
width: 109,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
color: const Color(
0xFFFFFFFF,
), // background: #FFFFFF
borderRadius: BorderRadius.circular(
8,
), // border-radius: 8px
border: Border.all(
color: const Color(
0xFFE3E3E3,
), // border: 1px solid #E3E3E3
width: 1,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF767373)
.withOpacity(
0.25,
), // #76737340 ≈ 25% opacity
blurRadius: 10,
offset: const Offset(0, 0),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment
.center, // center horizontally
crossAxisAlignment: CrossAxisAlignment
.center, // center vertically
children: const [
Icon(
Icons.edit_outlined,
size: 18,
color: Color(
0xFF111827,
), // matches your text color
),
SizedBox(width: 6),
Text(
'Edit',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontStyle: FontStyle.normal,
fontSize: 16,
height: 1.3, // 130%
letterSpacing: 0.04 * 16, // 4%
color: Color(0xFF111827),
),
),
],
),
),
),
],
),
SizedBox(height: 25),
_buildDetailRow('Company Name : ', data.companyName),
const SizedBox(height: 24),
_buildDetailRow('PAN Number : ', data.panNumber),
const SizedBox(height: 24),
_buildDetailRow('GST Number : ', data.gstNumber),
const SizedBox(height: 24),
_buildDetailRow(
'Year of Incorporation : ',
data.yearOfIncorporation?.toString(),
),
const SizedBox(height: 24),
_buildDetailRow('Email : ', data.companyEmail),
const SizedBox(height: 24),
_buildDetailRow(
'Phone Number : ',
data.companyMobile,
),
const SizedBox(height: 24),
_buildDetailRow('Address : ', data.companyAddress),
const SizedBox(height: 24),
_buildDetailRow('Country : ', data.countryName),
const SizedBox(height: 24),
_buildDetailRow('State : ', data.stateName),
const SizedBox(height: 24),
_buildDetailRow('City : ', data.districtName),
const SizedBox(height: 24),
_buildDetailRow('PinCode : ', data.companyPincode),
],
),
),
const SizedBox(height: 30),
// 4 Image Containers
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 15,
mainAxisSpacing: 15,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildImageBox("GST Certificate ", data.gstFile),
_buildImageBox("PAN File", data.panFile),
_buildImageBox(
"Incorporation File",
data.incorporationFile,
),
_buildImageBox("Profile Image", data.logo),
],
),
const SizedBox(height: 40),
],
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (e, st) => Center(
child: Text(
"Error loading KYC details: $e",
style: const TextStyle(color: Colors.redAccent),
),
),
),
),
),
);
}
Widget _buildDetailRow(String title, String? value) {
if (value == null || value.isEmpty) return const SizedBox.shrink();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 14,
height: 1.3,
letterSpacing: 0.64,
color: Colors.black87,
),
),
Flexible(
child: Text(
value,
softWrap: true,
overflow: TextOverflow.visible,
style: const TextStyle(
fontFamily: 'Gilroy-Regular',
fontWeight: FontWeight.w400,
fontSize: 14,
height: 1.3,
letterSpacing: 0.64,
color: Color(0xFF111827),
),
),
),
],
);
}
Widget _buildImageBox(String label, String? imageUrl) {
final fullUrl = imageUrl != null && imageUrl.isNotEmpty
? imageUrl.startsWith('http')
? imageUrl
: "${ConstsApi.baseUrl}/api/$imageUrl"
: null;
final isDocument =
label.toLowerCase().contains('gst') ||
label.toLowerCase().contains('pan') ||
label.toLowerCase().contains('incorporation');
return Container(
width: 175,
height: 155,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
children: [
/// ✅ LABEL
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Roboto',
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 8),
/// ✅ IMAGE / DOCUMENT PREVIEW
Expanded(
child: InkWell(
onTap: fullUrl == null
? null
: () async {
await DownloadHelper.downloadFileToReceived(fullUrl);
setState(() {});
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: fullUrl != null
? isDocument
? const Center(
child: Icon(
Icons.insert_drive_file_rounded,
size: 50,
color: Colors.blueGrey,
),
)
: Image.network(
fullUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image),
),
)
: const Center(
child: Icon(Icons.image_not_supported, size: 40),
),
),
),
),
),
const SizedBox(height: 8),
/// ✅ DOWNLOAD / VIEW BUTTON
if (fullUrl != null)
FutureBuilder<String?>(
future: DownloadHelper.checkFileExistsInReceived(fullUrl),
builder: (context, snapshot) {
final existsPath = snapshot.data;
return SizedBox(
height: 36,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: existsPath == null
? Colors.blue
: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
onPressed: () async {
if (existsPath == null) {
await DownloadHelper.downloadFileToReceived(fullUrl);
setState(() {});
} else {
await DownloadHelper.openDownloadedFile(existsPath);
}
},
icon: Icon(
existsPath == null
? Icons.download_rounded
: Icons.remove_red_eye_rounded,
size: 18,
color: Colors.white,
),
label: Text(
existsPath == null ? "Download" : "View",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
},
),
],
),
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/controller/api_contoller.dart';
class PolicyScreen extends ConsumerStatefulWidget {
const PolicyScreen({super.key});
@override
ConsumerState<PolicyScreen> createState() => _PolicyScreenState();
}
class _PolicyScreenState extends ConsumerState<PolicyScreen> {
@override
Widget build(BuildContext context) {
final policyAsync = ref.watch(policyProvider);
return Scaffold(
backgroundColor: const Color.fromARGB(255, 230, 229, 229),
body: SafeArea(
child: policyAsync.when(
data: (policy) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 Header Row with Back Arrow + Centered Title
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 22,
),
),
Expanded(
child: Center(
child: Text(
policy.data?.title ?? 'Privacy Policy',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
),
),
// Invisible icon to balance the layout
const Opacity(
opacity: 0,
child: Icon(Icons.arrow_back_ios_new, size: 22),
),
],
),
const SizedBox(height: 24),
// 🔹 Policy Content
Text(
policy.data?.content ?? '',
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.black87,
),
),
],
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text(
'Error: $err',
style: const TextStyle(color: Colors.red),
),
),
),
),
);
}
}
@@ -0,0 +1,539 @@
import 'dart:math' as math show sqrt;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get/get.dart';
import 'package:taxglide/consts/app_asstes.dart';
import 'package:taxglide/consts/app_colors.dart';
import 'package:taxglide/consts/local_store.dart';
import 'package:taxglide/controller/api_repository.dart';
import 'package:taxglide/router/consts_routers.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/main.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
String? selectedItem;
bool _isLoggingOut = false;
String? userRole; // <-- role variable
@override
void initState() {
super.initState();
_loadRole(); // <-- load role on start
}
Future<void> _loadRole() async {
userRole = await LocalStore().getRole();
setState(() {});
}
@override
Widget build(BuildContext context) {
final profileAsync = ref.watch(profileProvider);
return Scaffold(
backgroundColor: Colors.white,
body: profileAsync.when(
data: (profile) {
final data = profile.data;
return SingleChildScrollView(
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: double.infinity,
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(50),
bottomRight: Radius.circular(50),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(140),
bottomRight: Radius.circular(0),
),
child: Align(
alignment: Alignment.centerRight,
child: Image.asset(
AppAssets.profilebanner,
fit: BoxFit.cover,
height: 200,
width: MediaQuery.of(context).size.width * 1.3,
),
),
),
),
Positioned(
bottom: -50,
left: 0,
right: 0,
child: Center(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: data?.logo != null && data!.logo!.isNotEmpty
? CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(data.logo!),
)
: Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black87,
),
child: Center(
child: Text(
(data?.companyName != null &&
data!.companyName!.isNotEmpty)
? data.companyName![0].toUpperCase()
: "M",
style: const TextStyle(
color: Colors.white,
fontSize: 40,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
],
),
const SizedBox(height: 60),
Text(
data?.companyName ?? 'No Company Name',
style: const TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 20,
color: Colors.black,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
_buildMenuItem(
id: 'profile',
icon: Icons.person_outline,
title: 'Profile',
onTap: () {
setState(() {
selectedItem = 'profile';
});
final pan = profile.data?.panNumber ?? '';
ref.invalidate(profileProvider);
if (pan.isNotEmpty) {
Get.toNamed(ConstRouters.kycdetailslist);
} else {
Get.toNamed(ConstRouters.kycdetails);
}
},
),
const SizedBox(height: 16),
if (userRole != "employee")
_buildMenuItem(
id: 'Staff',
icon: Icons.list_alt_outlined,
title: 'Sub - Staff List',
onTap: () {
setState(() {
selectedItem = 'staff';
});
ref.invalidate(staffListProvider);
Get.toNamed(ConstRouters.staff);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'terms',
icon: Icons.description_outlined,
title: 'Terms and Conditions',
onTap: () {
setState(() {
selectedItem = 'terms';
});
ref.invalidate(termsProvider);
Get.toNamed(ConstRouters.terms);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'policy',
icon: Icons.policy_outlined,
title: 'Policy',
onTap: () {
setState(() {
selectedItem = 'policy';
});
ref.invalidate(termsProvider);
Get.toNamed(ConstRouters.policy);
},
),
const SizedBox(height: 16),
_buildMenuItem(
id: 'logout',
icon: Icons.logout,
title: 'Log out',
onTap: () {
setState(() {
selectedItem = 'logout';
});
_showLogoutPopup(context);
},
),
],
),
const SizedBox(height: 50),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
),
);
}
/// ----------------------- LOGOUT POPUP ---------------------
void _showLogoutPopup(BuildContext context) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Logout',
barrierColor: Colors.black.withOpacity(0.2),
transitionDuration: const Duration(milliseconds: 300),
pageBuilder: (context, animation, secondaryAnimation) {
return const SizedBox.shrink();
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
final offsetAnimation =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutBack),
);
return SlideTransition(
position: offsetAnimation,
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Material(
color: Colors.transparent,
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: 160,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7.75),
border: Border.all(color: Colors.white, width: 3.87),
boxShadow: const [
BoxShadow(
color: Color(0x7A5E5E5E),
offset: Offset(0, 2.32),
blurRadius: 9.14,
spreadRadius: 3.1,
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Note",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: 20,
color: Colors.black,
),
),
const Text(
"Are you sure you want to log out?",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w500,
fontSize: 15,
color: Colors.black87,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
"Cancel",
style: TextStyle(
color: Color(0xFF535A5B),
fontSize: 16,
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: _isLoggingOut
? null
: () async {
Navigator.pop(context);
setState(() {
_isLoggingOut = true;
});
try {
await ApiRepository().logout();
_showSnackBar("Logout successful");
await Future.delayed(
const Duration(milliseconds: 800),
);
Get.offAllNamed(ConstRouters.login);
} catch (e) {
_showSnackBar(
e.toString(),
isError: true,
);
} finally {
setState(() {
_isLoggingOut = false;
});
}
},
child: _isLoggingOut
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF5F297B),
),
)
: const Text(
"Log Out",
style: TextStyle(
color: Color(0xFF5F297B),
fontSize: 16,
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
),
),
),
);
},
);
}
/// ----------------------- MENU ITEM ---------------------
Widget _buildMenuItem({
required String id,
required IconData icon,
required String title,
required VoidCallback onTap,
}) {
final isSelected = selectedItem == id;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: Row(
children: [
SizedBox(
width: 24,
height: 48,
child: isSelected
? CustomPaint(
size: const Size(24, 48),
painter: BeveledTrianglePainter(),
)
: const SizedBox.shrink(),
),
Expanded(
child: Container(
height: 58,
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFEEFAFF)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(icon, size: 24, color: Colors.black87),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: const TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
),
],
),
),
),
],
),
);
}
/// ----------------------- SNACK BAR ---------------------
void _showSnackBar(String msg, {bool isError = false}) {
rootScaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
backgroundColor: isError ? Colors.red : AppColors.commanbutton,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 70, vertical: 25),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
content: Row(
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),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class BeveledTrianglePainter extends CustomPainter {
final double cornerRadius;
BeveledTrianglePainter({
this.cornerRadius = 4.0, // Adjust this value for more/less rounding
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF5E2976)
..style = PaintingStyle.fill;
// Triangle vertices
final vertices = [
Offset(0, cornerRadius), // Top left (adjusted for bevel)
Offset(size.width - 6, size.height / 2), // Right point
Offset(0, size.height - cornerRadius), // Bottom left (adjusted for bevel)
];
final path = Path();
for (int i = 0; i < vertices.length; i++) {
final current = vertices[i];
final next = vertices[(i + 1) % vertices.length];
final prev = vertices[(i - 1 + vertices.length) % vertices.length];
// Calculate direction vectors
final toCurrent = Offset(current.dx - prev.dx, current.dy - prev.dy);
final toNext = Offset(next.dx - current.dx, next.dy - current.dy);
// Normalize
final lengthToCurrent = math.sqrt(
toCurrent.dx * toCurrent.dx + toCurrent.dy * toCurrent.dy,
);
final lengthToNext = math.sqrt(
toNext.dx * toNext.dx + toNext.dy * toNext.dy,
);
final normalizedToCurrent = Offset(
toCurrent.dx / lengthToCurrent,
toCurrent.dy / lengthToCurrent,
);
final normalizedToNext = Offset(
toNext.dx / lengthToNext,
toNext.dy / lengthToNext,
);
// Points before and after the corner
final beforeCorner = Offset(
current.dx - normalizedToCurrent.dx * cornerRadius,
current.dy - normalizedToCurrent.dy * cornerRadius,
);
final afterCorner = Offset(
current.dx + normalizedToNext.dx * cornerRadius,
current.dy + normalizedToNext.dy * cornerRadius,
);
if (i == 0) {
path.moveTo(beforeCorner.dx, beforeCorner.dy);
} else {
path.lineTo(beforeCorner.dx, beforeCorner.dy);
}
// Draw rounded corner
path.quadraticBezierTo(
current.dx,
current.dy,
afterCorner.dx,
afterCorner.dy,
);
}
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/controller/api_contoller.dart';
class StaffListScreen extends ConsumerStatefulWidget {
const StaffListScreen({super.key});
@override
ConsumerState<StaffListScreen> createState() => _StaffListScreenState();
}
class _StaffListScreenState extends ConsumerState<StaffListScreen> {
@override
Widget build(BuildContext context) {
final staffAsync = ref.watch(staffListProvider);
const commonTextStyle = TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w600,
fontSize: 14.7,
height: 1.30, // 130%
letterSpacing: 0.04,
color: Color(0xFF111827),
);
const commonTextStylevalue = TextStyle(
fontFamily: "Gilroy-SemiBold",
fontWeight: FontWeight.w400,
fontSize: 14.7,
height: 1.30, // 130%
letterSpacing: 0.04,
color: Color(0xFF111827),
);
return Scaffold(
backgroundColor: const Color.fromARGB(255, 230, 229, 229),
body: SafeArea(
child: staffAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text(
'Error: $err',
style: const TextStyle(color: Colors.red),
),
),
data: (staffList) {
if (staffList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
"No Staff Found",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: CommanButton(
text: 'Go Back',
onPressed: () {
Navigator.pop(context);
},
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 22,
),
),
const Expanded(
child: Center(
child: Text(
'Sub - Staff List',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
),
),
const Opacity(
opacity: 0,
child: Icon(Icons.arrow_back_ios_new, size: 22),
),
],
),
const SizedBox(height: 50),
...staffList.map((staff) {
String statusText = staff.status == 1
? "Active"
: "Inactive";
String generalChatText = staff.generalChat == 1
? "Yes"
: "No";
String createServiceText = staff.createService == 1
? "Yes"
: "No";
Color statusColor = staff.status == 1
? Colors.green
: Colors.red;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// ✅ Name
Row(
children: [
Text("Name : ", style: commonTextStyle),
Expanded(
child: Text(
staff.name,
style: commonTextStylevalue,
),
),
],
),
const SizedBox(height: 6),
/// ✅ Mobile
Row(
children: [
Text("Mobile : ", style: commonTextStyle),
Expanded(
child: Text(
staff.mobile,
style: commonTextStylevalue,
),
),
],
),
const SizedBox(height: 6),
/// ✅ Email
Row(
children: [
Text("Email : ", style: commonTextStyle),
Expanded(
child: Text(
staff.email,
style: commonTextStylevalue,
),
),
],
),
const SizedBox(height: 10),
/// ✅ Status
Row(
children: [
Text("Status : ", style: commonTextStyle),
Text(
statusText,
style: commonTextStylevalue.copyWith(
color: statusColor,
),
),
],
),
const SizedBox(height: 6),
/// ✅ General Chat
Row(
children: [
Text("General Chat : ", style: commonTextStyle),
Text(
generalChatText,
style: commonTextStylevalue,
),
],
),
const SizedBox(height: 6),
/// ✅ Create Service
Row(
children: [
Text(
"Create Service : ",
style: commonTextStyle,
),
Text(
createServiceText,
style: commonTextStylevalue,
),
],
),
],
),
);
}).toList(),
],
),
);
},
),
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:taxglide/controller/api_contoller.dart';
class TermsAndConditionScreen extends ConsumerStatefulWidget {
const TermsAndConditionScreen({super.key});
@override
ConsumerState<TermsAndConditionScreen> createState() =>
_TermsAndConditionScreenState();
}
class _TermsAndConditionScreenState
extends ConsumerState<TermsAndConditionScreen> {
@override
Widget build(BuildContext context) {
final termsAsync = ref.watch(termsProvider);
return Scaffold(
backgroundColor: const Color.fromARGB(255, 230, 229, 229),
body: SafeArea(
child: termsAsync.when(
data: (terms) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 Header Row with Back Arrow + Centered Title
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 22,
),
),
Expanded(
child: Center(
child: Text(
terms.data?.title ?? 'Terms & Conditions',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
),
),
// To balance layout so title stays centered
const Opacity(
opacity: 0,
child: Icon(Icons.arrow_back_ios_new, size: 22),
),
],
),
const SizedBox(height: 24),
// 🔹 Terms Content
Text(
terms.data?.content ?? '',
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.black87,
),
),
],
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text(
'Error: $err',
style: const TextStyle(color: Colors.red),
),
),
),
),
);
}
}
@@ -0,0 +1,910 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:path/path.dart' as path;
import 'package:taxglide/consts/comman_button.dart';
import 'package:taxglide/consts/responsive_helper.dart';
import 'package:taxglide/controller/api_contoller.dart';
import 'package:taxglide/controller/api_repository.dart';
import 'package:taxglide/view/Main_controller/main_controller.dart';
class ServiceRequestScreen extends ConsumerStatefulWidget {
final int id;
final String service;
final String icon;
final int? returnTabIndex;
const ServiceRequestScreen({
super.key,
required this.id,
required this.service,
required this.icon,
this.returnTabIndex,
});
@override
ConsumerState<ServiceRequestScreen> createState() =>
_ServiceRequestScreenState();
}
class _ServiceRequestScreenState extends ConsumerState<ServiceRequestScreen> {
final List<File> _selectedFiles = [];
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isFileError = false;
bool _isTermsAccepted = false;
bool _isCheckboxError = false;
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
void _showSnackBar(String msg, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
backgroundColor: isError ? Colors.red : Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 6,
margin: const EdgeInsets.symmetric(horizontal: 50, vertical: 25),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isError ? Icons.error_outline : Icons.check_circle_outline,
color: Colors.white,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
msg,
style: const TextStyle(color: Colors.white, fontSize: 12),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
],
),
),
);
}
bool _validateForm() {
setState(() {
_isFileError = false;
_isCheckboxError = false;
});
bool isValid = true;
if (_selectedFiles.isEmpty) {
setState(() => _isFileError = true);
_showSnackBar("Please upload at least one file", isError: true);
isValid = false;
}
if (!_isTermsAccepted) {
setState(() => _isCheckboxError = true);
_showSnackBar("Please accept terms and conditions", isError: true);
isValid = false;
}
return isValid;
}
Future<void> _submitRequest() async {
if (_validateForm()) {
try {
_showSnackBar("Submitting request...");
await ApiRepository().updateServiceRequest(
serviceId: widget.id,
message: _messageController.text.trim(),
documents: _selectedFiles,
);
_showSnackBar("Request submitted successfully!");
ref.invalidate(serviceHistoryNotifierProvider);
Get.offAll(() => const MainController());
} catch (e) {
debugPrint("❌ Submit error: $e");
_showSnackBar("Something went wrong. Please try again.", isError: true);
}
}
}
Future<void> _pickFile() async {
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Select File Type'),
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'),
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'),
),
ListTile(
leading: const Icon(Icons.folder_zip, color: Colors.orange),
title: const Text('Select ZIP'),
onTap: () => Navigator.pop(ctx, 'zip'),
),
],
),
),
);
if (choice == null) return;
try {
if (choice == 'camera') {
final hasPermission = await _requestPermission(ImageSource.camera);
if (!hasPermission) return;
final picked = await ImagePicker().pickImage(
source: ImageSource.camera,
);
if (picked != null) {
setState(() {
_selectedFiles.add(File(picked.path));
_isFileError = false;
});
}
} else if (choice == 'gallery') {
final hasPermission = await _requestPermission(ImageSource.gallery);
if (!hasPermission) return;
final picked = await ImagePicker().pickMultiImage();
if (picked.isNotEmpty) {
setState(() {
for (var image in picked) {
_selectedFiles.add(File(image.path));
}
_isFileError = false;
});
}
} else if (choice == 'pdf' || choice == 'zip') {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: choice == 'pdf' ? ['pdf'] : ['zip', 'rar', '7z'],
);
if (result != null && result.files.isNotEmpty) {
for (var file in result.files) {
if (file.path != null) {
setState(() {
_selectedFiles.add(File(file.path!));
_isFileError = false;
});
}
}
}
}
} catch (e) {
debugPrint("❌ File picking error: $e");
_showSnackBar("Failed to pick file: $e", isError: true);
}
}
Future<void> _showSettingsDialog(String permission) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('$permission Permission Required'),
content: Text(
'Allow $permission access to capture and upload your documents easily. You can enable it in the app settings.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
openAppSettings();
},
child: const Text('Go to Settings'),
),
],
),
);
}
Future<bool> _requestPermission(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;
name = 'Photos';
}
}
var status = await permission.status;
print("status $status");
status = await permission.request();
if (status.isGranted || status.isLimited) return true;
if (status.isPermanentlyDenied || (Platform.isIOS && status.isDenied)) {
await _showSettingsDialog(name);
return false;
}
await _showSettingsDialog(name);
return false;
} catch (e) {
debugPrint("Permission error: $e");
return Platform.isIOS;
}
}
bool _isImage(String pathStr) {
final ext = path.extension(pathStr).toLowerCase();
return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
}
bool _isPdf(String pathStr) =>
path.extension(pathStr).toLowerCase() == '.pdf';
bool _isZip(String pathStr) =>
['.zip', '.rar', '.7z'].contains(path.extension(pathStr).toLowerCase());
@override
Widget build(BuildContext context) {
final r = ResponsiveUtils(context);
// Responsive values
final hPadding = r.spacing(mobile: 10, tablet: 16, desktop: 24);
final hPaddingInner = r.spacing(mobile: 20, tablet: 24, desktop: 32);
final headerFontSize = r.fontSize(mobile: 20, tablet: 24, desktop: 28);
final labelFontSize = r.fontSize(mobile: 14, tablet: 16, desktop: 18);
final readOnlyFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final uploadFontSize = r.fontSize(mobile: 11.5, tablet: 12.78, desktop: 14);
final uploadSubFontSize = r.fontSize(mobile: 9, tablet: 10, desktop: 11);
final checkboxFontSize = r.fontSize(
mobile: 11.5,
tablet: 12.78,
desktop: 14,
);
final messageFontSize = r.fontSize(
mobile: 10.5,
tablet: 11.31,
desktop: 12.5,
);
final serviceNameFontSize = r.fontSize(mobile: 13, tablet: 14, desktop: 15);
final uploadedCountFontSize = r.fontSize(
mobile: 13,
tablet: 14,
desktop: 15,
);
final serviceIconContainerW = r.getValue<double>(
mobile: 90,
tablet: 110,
desktop: 130,
);
final serviceIconContainerH = r.getValue<double>(
mobile: 70,
tablet: 84,
desktop: 100,
);
final serviceIconSize = r.getValue<double>(
mobile: 36,
tablet: 42,
desktop: 50,
);
final messageBoxHeight = r.getValue<double>(
mobile: 100,
tablet: 117,
desktop: 140,
);
final fileCardWidth = r.getValue<double>(
mobile: 90,
tablet: 110,
desktop: 130,
);
final fileCardHeight = r.getValue<double>(
mobile: 100,
tablet: 120,
desktop: 140,
);
final fileIconSize = r.getValue<double>(
mobile: 34,
tablet: 40,
desktop: 46,
);
final spacingSM = r.spacing(mobile: 10, tablet: 10, desktop: 12);
final spacingMD = r.spacing(mobile: 16, tablet: 20, desktop: 24);
final spacingLG = r.spacing(mobile: 20, tablet: 20, desktop: 24);
return Scaffold(
backgroundColor: const Color.fromARGB(255, 230, 229, 229),
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: EdgeInsets.symmetric(
horizontal: hPadding,
vertical: 21,
),
child: SizedBox(
height: 50,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Text(
"New Service Request",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: headerFontSize,
color: const Color(0xFF111827),
),
),
Positioned(
left: 0,
child: GestureDetector(
onTap: () => Get.offAll(() => const MainController()),
child: const Icon(Icons.arrow_back_ios_rounded),
),
),
],
),
),
),
SizedBox(height: spacingLG),
// Service icon and name
Center(
child: Column(
children: [
Container(
width: serviceIconContainerW,
height: serviceIconContainerH,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE1E1E1)),
boxShadow: const [
BoxShadow(
color: Color(0x66757575),
offset: Offset(0, 1),
blurRadius: 7.3,
),
],
),
padding: const EdgeInsets.all(8),
child: Image.network(
widget.icon,
width: serviceIconSize,
height: serviceIconSize,
),
),
SizedBox(height: spacingMD),
Text(
widget.service,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: serviceNameFontSize,
color: const Color(0xFF3F3F3F),
),
),
],
),
),
SizedBox(height: spacingLG),
// Request Type
_buildLabel("Request Type*", hPaddingInner, labelFontSize),
_buildReadOnlyBox(
widget.service,
hPaddingInner,
readOnlyFontSize,
),
SizedBox(height: spacingSM),
// File Upload Header
Padding(
padding: EdgeInsets.symmetric(horizontal: hPaddingInner),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"File Upload *",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: labelFontSize,
color: const Color(0xFF111827),
),
),
GestureDetector(
onTap: _pickFile,
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: const Icon(
Icons.add,
size: 18,
color: Colors.white,
),
),
),
],
),
),
SizedBox(height: spacingLG),
// Upload Box
_buildUploadBox(hPaddingInner, uploadFontSize, uploadSubFontSize),
// Selected Files
if (_selectedFiles.isNotEmpty)
_buildEnhancedSelectedFiles(
hPaddingInner,
fileCardWidth,
fileCardHeight,
fileIconSize,
uploadedCountFontSize,
),
SizedBox(height: spacingLG),
// Message
_buildLabel("Message", hPaddingInner, labelFontSize),
_buildMessageBox(
hPaddingInner,
messageBoxHeight,
messageFontSize,
),
// Terms Checkbox
Padding(
padding: EdgeInsets.symmetric(horizontal: hPadding),
child: Row(
children: [
Checkbox(
value: _isTermsAccepted,
onChanged: (v) {
setState(() {
_isTermsAccepted = v ?? false;
if (v == true) _isCheckboxError = false;
});
},
activeColor: Colors.green,
side: BorderSide(
color: _isCheckboxError ? Colors.red : Colors.grey,
width: _isCheckboxError ? 2 : 1,
),
),
Expanded(
child: Text(
"I accept the terms and conditions *",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontStyle: FontStyle.normal,
fontSize: checkboxFontSize,
height: 25.56 / 12.78,
letterSpacing: 0.8,
color: _isCheckboxError
? Colors.red
: const Color(0xFF4F4C4C),
),
),
),
],
),
),
// Submit Button
Padding(
padding: EdgeInsets.symmetric(
horizontal: r.spacing(mobile: 30, tablet: 41, desktop: 60),
vertical: spacingLG,
),
child: CommanButton(
text: 'Submit Request',
onPressed: _submitRequest,
),
),
const SizedBox(height: 250),
],
),
),
),
);
}
Widget _buildLabel(String title, double hPadding, double fontSize) => Padding(
padding: EdgeInsets.symmetric(horizontal: hPadding),
child: Text(
title,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: fontSize,
color: const Color(0xFF111827),
),
),
);
Widget _buildReadOnlyBox(String text, double hPadding, double fontSize) =>
Padding(
padding: EdgeInsets.symmetric(horizontal: hPadding, vertical: 15),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFDFDFDF)),
boxShadow: const [
BoxShadow(
color: Color(0x66BDBDBD),
offset: Offset(0, 1),
blurRadius: 7,
),
],
),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 21),
child: Text(
text,
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: fontSize,
color: const Color(0xFF3F3F3F),
),
),
),
);
Widget _buildUploadBox(
double hPadding,
double fontSize,
double subFontSize,
) => Padding(
padding: EdgeInsets.symmetric(horizontal: hPadding),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: _isFileError ? Colors.red : const Color(0xFFDFDFDF),
width: _isFileError ? 2 : 1,
),
),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 21),
child: Column(
children: [
Text(
"Upload PDF, IMG, JPG, ZIP",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: fontSize,
height: 25.56 / 12.78,
letterSpacing: 0.8,
color: _isFileError ? Colors.red : const Color(0xFF4F4C4C),
),
),
Text(
"(40 MB only allowed File)",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: subFontSize,
height: 25.56 / 12.78,
letterSpacing: 0.8,
color: _isFileError ? Colors.red : const Color(0xFF4F4C4C),
),
),
if (_isFileError)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
"Please upload at least one file",
style: TextStyle(color: Colors.red, fontSize: 11),
),
),
],
),
),
);
Widget _buildEnhancedSelectedFiles(
double hPadding,
double cardWidth,
double cardHeight,
double iconSize,
double countFontSize,
) => Container(
margin: EdgeInsets.symmetric(horizontal: hPadding, vertical: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE1E1E1)),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
offset: Offset(0, 2),
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.check_circle,
color: Colors.green.shade600,
size: 18,
),
),
const SizedBox(width: 8),
Text(
"Uploaded Files (${_selectedFiles.length})",
style: TextStyle(
fontFamily: 'Gilroy-SemiBold',
fontWeight: FontWeight.w600,
fontSize: countFontSize,
color: const Color(0xFF111827),
),
),
],
),
if (_selectedFiles.length > 2)
Row(
children: [
GestureDetector(
onTap: () {
_scrollController.animateTo(
_scrollController.offset - 120,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new,
size: 14,
color: Colors.black87,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_scrollController.animateTo(
_scrollController.offset + 120,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_forward_ios,
size: 14,
color: Colors.black87,
),
),
),
],
),
],
),
const SizedBox(height: 12),
SizedBox(
height: cardHeight,
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: _selectedFiles.length,
itemBuilder: (context, index) {
final file = _selectedFiles[index];
final pathStr = file.path;
Widget preview;
Color bgColor;
if (_isImage(pathStr)) {
preview = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
file,
fit: BoxFit.cover,
width: cardWidth,
height: cardHeight,
),
);
bgColor = Colors.transparent;
} else if (_isPdf(pathStr)) {
bgColor = Colors.red.shade50;
preview = Icon(
Icons.picture_as_pdf,
size: iconSize,
color: Colors.red.shade600,
);
} else if (_isZip(pathStr)) {
bgColor = Colors.orange.shade50;
preview = Icon(
Icons.folder_zip,
size: iconSize,
color: Colors.orange.shade600,
);
} else {
bgColor = Colors.blue.shade50;
preview = Icon(
Icons.insert_drive_file,
size: iconSize,
color: Colors.blue.shade600,
);
}
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Stack(
children: [
Container(
width: cardWidth,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.grey.shade300,
width: 1.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
offset: const Offset(0, 2),
blurRadius: 4,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Expanded(child: Center(child: preview))],
),
),
Positioned(
right: -2,
top: -2,
child: GestureDetector(
onTap: () {
setState(() {
_selectedFiles.removeAt(index);
if (_selectedFiles.isEmpty) _isFileError = true;
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.red.shade600,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
offset: const Offset(0, 2),
blurRadius: 4,
),
],
),
padding: const EdgeInsets.all(4),
child: const Icon(
Icons.close,
size: 14,
color: Colors.white,
),
),
),
),
],
),
);
},
),
),
],
),
);
Widget _buildMessageBox(double hPadding, double boxHeight, double fontSize) =>
Padding(
padding: EdgeInsets.symmetric(horizontal: hPadding, vertical: 15),
child: Container(
width: double.infinity,
height: boxHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFCFCFCF)),
),
child: TextFormField(
controller: _messageController,
maxLines: null,
expands: true,
style: TextStyle(
fontFamily: 'Gilroy-Medium',
fontSize: fontSize,
color: const Color(0xFF6C7278),
),
decoration: const InputDecoration(
hintText: "Enter your message",
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
);
}