Initial commit of Doormile CRM Mobile with UI modernizations
This commit is contained in:
1483
lib/screens/clients_screen.dart
Normal file
1483
lib/screens/clients_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
709
lib/screens/dashboard_screen.dart
Normal file
709
lib/screens/dashboard_screen.dart
Normal file
@@ -0,0 +1,709 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/client.dart';
|
||||
import '../models/activity.dart';
|
||||
import '../models/app_stats.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'surveys_list_screen.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
final AppStats stats;
|
||||
final List<Activity> activities;
|
||||
final List<Client> clients;
|
||||
final List<dynamic> surveys;
|
||||
final List<dynamic> pricing;
|
||||
final void Function(int) onNavigate;
|
||||
final VoidCallback onDataChanged;
|
||||
final bool backendReady;
|
||||
final VoidCallback onLogout;
|
||||
final bool isActive;
|
||||
|
||||
const DashboardScreen({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.activities,
|
||||
required this.clients,
|
||||
this.surveys = const [],
|
||||
this.pricing = const [],
|
||||
required this.onNavigate,
|
||||
required this.onDataChanged,
|
||||
required this.backendReady,
|
||||
required this.onLogout,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DashboardScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isActive && !oldWidget.isActive) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 100),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WideTopBanner(
|
||||
backendReady: widget.backendReady,
|
||||
subtitle: 'DASHBOARD',
|
||||
onLogout: widget.onLogout,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_WelcomeHeader(),
|
||||
const SizedBox(height: 20),
|
||||
_BentoGrid(stats: widget.stats),
|
||||
const SizedBox(height: 20),
|
||||
_QuickActions(onNavigate: widget.onNavigate),
|
||||
const SizedBox(height: 20),
|
||||
_MarketInsights(surveys: widget.surveys, pricing: widget.pricing, onNavigate: widget.onNavigate, onDataChanged: widget.onDataChanged),
|
||||
const SizedBox(height: 20),
|
||||
_RecentActivity(
|
||||
activities: widget.activities,
|
||||
clients: widget.clients,
|
||||
onNavigate: widget.onNavigate,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_InsightCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeHeader extends StatelessWidget {
|
||||
static String get _greeting {
|
||||
final h = DateTime.now().hour;
|
||||
if (h < 12) return 'Good Morning';
|
||||
if (h < 17) return 'Good Afternoon';
|
||||
if (h < 21) return 'Good Evening';
|
||||
return 'Good Night';
|
||||
}
|
||||
|
||||
static String get _subtext {
|
||||
final h = DateTime.now().hour;
|
||||
if (h < 12) return 'Start your day with a fresh logistics survey.';
|
||||
if (h < 17) return 'Keep the momentum log your field visits.';
|
||||
if (h < 21) return 'Wrap up the day any pending surveys to log?';
|
||||
return 'Late session active your data is synced.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_subtext,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BentoGrid extends StatelessWidget {
|
||||
final AppStats stats;
|
||||
const _BentoGrid({required this.stats});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Large card spanning full width
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD3E4FE),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFA6C8FF).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'TOTAL CLIENTS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
stats.totalClients.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(m) => '${m[1]},',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_upward,
|
||||
size: 14, color: AppColors.greenDark),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'+12% from last month',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.greenDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: -20,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.calendar_today,
|
||||
iconColor: AppColors.primary,
|
||||
value: stats.todayEntries.toString(),
|
||||
label: "Today's Entries",
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.access_time,
|
||||
iconColor: const Color(0xFF004D47),
|
||||
value: stats.pendingFollowups.toString(),
|
||||
label: 'Pending Follow-ups',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallStatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
const _SmallStatCard({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.value,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: iconColor),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActions extends StatelessWidget {
|
||||
final void Function(int) onNavigate;
|
||||
const _QuickActions({required this.onNavigate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'QUICK ACTIONS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => onNavigate(4),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.white),
|
||||
label: const Text(
|
||||
'Quick Add Client',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => onNavigate(1),
|
||||
icon: Icon(Icons.search, size: 18, color: AppColors.darkBlue),
|
||||
label: Text(
|
||||
'Search Client Database',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkBlue,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD3E4FE),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: Color(0xFFA6C8FF)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarketInsights extends StatelessWidget {
|
||||
final List<dynamic> surveys;
|
||||
final List<dynamic> pricing;
|
||||
final void Function(int) onNavigate;
|
||||
final VoidCallback onDataChanged;
|
||||
|
||||
const _MarketInsights({required this.surveys, required this.pricing, required this.onNavigate, required this.onDataChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'MARKET DATA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (ctx) => SurveysListScreen(
|
||||
surveys: surveys,
|
||||
onDataChanged: onDataChanged,
|
||||
),
|
||||
));
|
||||
},
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.storefront_outlined,
|
||||
iconColor: const Color(0xFFE87C00),
|
||||
value: surveys.length.toString(),
|
||||
label: 'Competitor Surveys',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onNavigate(3),
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.price_change_outlined,
|
||||
iconColor: const Color(0xFF15803D),
|
||||
value: pricing.length.toString(),
|
||||
label: 'Carrier Pricing',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentActivity extends StatelessWidget {
|
||||
final List<Activity> activities;
|
||||
final List<Client> clients;
|
||||
final void Function(int) onNavigate;
|
||||
|
||||
const _RecentActivity({
|
||||
required this.activities,
|
||||
required this.clients,
|
||||
required this.onNavigate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'RECENT ACTIVITY',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => onNavigate(1),
|
||||
child: Text(
|
||||
'View all',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: activities
|
||||
.take(5)
|
||||
.map((activity) => _ActivityTile(
|
||||
activity: activity,
|
||||
onTap: () => onNavigate(1),
|
||||
isLast: activity == activities.last,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityTile extends StatelessWidget {
|
||||
final Activity activity;
|
||||
final VoidCallback onTap;
|
||||
final bool isLast;
|
||||
|
||||
const _ActivityTile({
|
||||
required this.activity,
|
||||
required this.onTap,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color iconBg;
|
||||
Color iconColor;
|
||||
IconData iconData;
|
||||
|
||||
switch (activity.icon) {
|
||||
case ActivityIcon.shipping:
|
||||
iconBg = const Color(0xFFD5E0F8);
|
||||
iconColor = const Color(0xFF1E3A8A);
|
||||
iconData = Icons.local_shipping;
|
||||
break;
|
||||
case ActivityIcon.call:
|
||||
iconBg = const Color(0xFFFFE4E6);
|
||||
iconColor = AppColors.primary;
|
||||
iconData = Icons.phone;
|
||||
break;
|
||||
case ActivityIcon.edit:
|
||||
iconBg = const Color(0xFFDCFCE7);
|
||||
iconColor = const Color(0xFF15803D);
|
||||
iconData = Icons.edit;
|
||||
break;
|
||||
}
|
||||
|
||||
Widget? badge;
|
||||
if (activity.status == ActivityStatus.newStatus) {
|
||||
badge = _Badge(label: 'NEW', bg: const Color(0xFFFFDAD7), fg: const Color(0xFF930013));
|
||||
} else if (activity.status == ActivityStatus.pending) {
|
||||
badge = _Badge(label: 'PENDING', bg: const Color(0xFFD8E3FB), fg: const Color(0xFF3C475A));
|
||||
} else if (activity.status == ActivityStatus.completed) {
|
||||
badge = _Badge(label: 'DONE', bg: const Color(0xFFD8F9EE), fg: const Color(0xFF115E59));
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: isLast
|
||||
? const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: isLast
|
||||
? null
|
||||
: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.border.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(iconData, size: 18, color: iconColor),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
activity.clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
activity.action,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?badge,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
|
||||
const _Badge({required this.label, required this.bg, required this.fg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 160,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0B1C30), Color(0xFF8D0012)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _GridPainter()),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome,
|
||||
size: 12, color: const Color(0xFFFFDAD7)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'INSIGHT OF THE WEEK',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: const Color(0xFFFFDAD7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Weekly Performance Report',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Analyze your team's survey conversion rates.",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.04)
|
||||
..strokeWidth = 1;
|
||||
const spacing = 30.0;
|
||||
for (double x = 0; x < size.width; x += spacing) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += spacing) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GridPainter oldDelegate) => false;
|
||||
}
|
||||
266
lib/screens/login_screen.dart
Normal file
266
lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
final void Function(AuthUser user) onLoggedIn;
|
||||
|
||||
const LoginScreen({super.key, required this.onLoggedIn});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _auth = AuthService();
|
||||
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
|
||||
if (email.isEmpty || password.isEmpty) {
|
||||
SnackbarUtils.showError(context, 'Enter your email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final user = await _auth.login(email, password);
|
||||
if (!mounted) return;
|
||||
if (user == null) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
});
|
||||
SnackbarUtils.showError(context, 'Invalid email or password');
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onLoggedIn(user);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
});
|
||||
SnackbarUtils.showError(context, 'Could not reach server. Check your connection.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: SafeArea(
|
||||
child: Align(
|
||||
alignment: const Alignment(0, -0.4),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_logo(),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Doormile CRM',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'FIELD SALES PORTAL',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_card(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logo() {
|
||||
return Container(
|
||||
width: 92,
|
||||
height: 92,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset('assets/doormilelogoround.png', fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(22),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _loginForm(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loginForm() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Welcome back! Please enter your details.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Email',
|
||||
prefixIcon: const Icon(Icons.email_outlined, color: AppColors.muted),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.go,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_primaryButton(
|
||||
label: 'Continue',
|
||||
onPressed: _busy ? null : _login,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Widget _primaryButton({
|
||||
required String label,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: AppColors.primary.withValues(alpha: 0.5),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
538
lib/screens/pricing_screen.dart
Normal file
538
lib/screens/pricing_screen.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/crm_api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class PricingScreen extends StatefulWidget {
|
||||
final List<dynamic> pricing;
|
||||
final bool backendReady;
|
||||
final bool isActive;
|
||||
final VoidCallback onDataChanged;
|
||||
|
||||
const PricingScreen({
|
||||
super.key,
|
||||
required this.pricing,
|
||||
required this.backendReady,
|
||||
required this.onDataChanged,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PricingScreen> createState() => _PricingScreenState();
|
||||
}
|
||||
|
||||
class _PricingScreenState extends State<PricingScreen> {
|
||||
final CrmApiService _api = CrmApiService();
|
||||
|
||||
bool _isSaving = false;
|
||||
|
||||
// Group pricing by company
|
||||
Map<String, List<dynamic>> get groupedPricing {
|
||||
final Map<String, List<dynamic>> map = {};
|
||||
for (var p in widget.pricing) {
|
||||
final company = p['company']?.toString() ?? 'Unknown';
|
||||
if (!map.containsKey(company)) {
|
||||
map[company] = [];
|
||||
}
|
||||
map[company]!.add(p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
void _showAddPricingDialog([String? defaultProvider]) {
|
||||
_showPricingFormDialog(defaultProvider: defaultProvider);
|
||||
}
|
||||
|
||||
void _showEditPricingDialog(Map<String, dynamic> rateData) {
|
||||
_showPricingFormDialog(rateData: rateData);
|
||||
}
|
||||
|
||||
void _showPricingFormDialog({Map<String, dynamic>? rateData, String? defaultProvider}) {
|
||||
final isEditing = rateData != null;
|
||||
final companyCtrl = TextEditingController(text: rateData?['company']?.toString() ?? defaultProvider ?? '');
|
||||
final slabCtrl = TextEditingController(text: rateData?['weight_slab']?.toString() ?? '');
|
||||
final zoneCtrl = TextEditingController(text: rateData?['zone']?.toString().isNotEmpty == true ? rateData!['zone'] : rateData?['service_type'] ?? '');
|
||||
final rateCtrl = TextEditingController(text: rateData?['rate']?.toString() ?? '');
|
||||
final String id = rateData?['id']?.toString() ?? '';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blueLight,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(isEditing ? Icons.edit_outlined : Icons.add_circle_outline, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEditing ? 'Edit Rate' : 'Add New Rate', style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 18)),
|
||||
const SizedBox(height: 2),
|
||||
Text(isEditing ? 'Update pricing information' : 'Add to provider pricing slab', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Form Fields
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildModernTextField(
|
||||
controller: companyCtrl,
|
||||
label: 'Provider Name',
|
||||
hint: 'e.g. DTDC, Delhivery',
|
||||
icon: Icons.storefront_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: slabCtrl,
|
||||
label: 'Weight Slab',
|
||||
hint: 'e.g. 500g, 1kg-2kg',
|
||||
icon: Icons.scale_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: zoneCtrl,
|
||||
label: 'Zone / Service',
|
||||
hint: 'e.g. LOCAL, SOUTH',
|
||||
icon: Icons.place_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: rateCtrl,
|
||||
label: 'Rate (₹)',
|
||||
hint: '0.00',
|
||||
icon: Icons.currency_rupee,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (companyCtrl.text.isEmpty || slabCtrl.text.isEmpty || rateCtrl.text.isEmpty) {
|
||||
SnackbarUtils.showError(ctx, 'Please fill in required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final payload = {
|
||||
'company': companyCtrl.text,
|
||||
'weight_slab': slabCtrl.text,
|
||||
'zone': zoneCtrl.text,
|
||||
'service_type': '',
|
||||
'rate': rateCtrl.text,
|
||||
};
|
||||
|
||||
final success = isEditing
|
||||
? await _api.updatePricing(id, payload)
|
||||
: await _api.createPricing(payload);
|
||||
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, isEditing ? 'Rate updated!' : 'Rate added!');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, isEditing ? 'Update failed.' : 'Failed to add rate.');
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(isEditing ? 'Save Changes' : 'Add Rate', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModernTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.darkBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: AppColors.textSecondary.withValues(alpha: 0.5)),
|
||||
prefixIcon: Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8F9FA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _deletePricing(String id) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Delete Pricing Rate?', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('This action cannot be undone. Are you sure you want to delete this rate?', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success = await _api.deletePricing(id);
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, 'Pricing rate deleted.');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, 'Failed to delete pricing rate.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = groupedPricing;
|
||||
final companies = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: WideTopBanner(
|
||||
backendReady: widget.backendReady,
|
||||
subtitle: 'CARRIER PRICING',
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'All Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : () => _showAddPricingDialog(),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.white),
|
||||
label: const Text('Add Provider / Rate', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSaving)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
if (companies.isEmpty && !_isSaving)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No pricing data available.\nAdd a provider to get started.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final company = companies[index];
|
||||
final rates = groups[company] ?? [];
|
||||
|
||||
final zones = rates.map((r) => r['zone']?.toString().toUpperCase() ?? 'LOCAL').toSet().toList()..sort();
|
||||
final slabs = rates.map((r) => r['weight_slab']?.toString() ?? '').toSet().toList()..sort();
|
||||
final initial = company.isNotEmpty ? company.substring(0, 1).toUpperCase() : 'P';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDECEE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
company,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 16),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 14, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text('${rates.length} Registered Rates', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(Colors.grey.shade50),
|
||||
dataRowMinHeight: 56,
|
||||
dataRowMaxHeight: 56,
|
||||
columnSpacing: 32,
|
||||
horizontalMargin: 24,
|
||||
columns: [
|
||||
const DataColumn(label: Text('WEIGHT SLAB', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary))),
|
||||
...zones.map((z) => DataColumn(label: Text(z, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary)))),
|
||||
],
|
||||
rows: slabs.map((slab) {
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(slab, style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.darkBlue, fontSize: 13))),
|
||||
...zones.map((zone) {
|
||||
final matchingRate = rates.firstWhere(
|
||||
(r) => (r['weight_slab'] == slab) && ((r['zone']?.toString().toUpperCase() ?? 'LOCAL') == zone),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (matchingRate == null) {
|
||||
return const DataCell(Center(child: Text('—', style: TextStyle(color: Colors.grey))));
|
||||
}
|
||||
return DataCell(
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Show options to edit or delete
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined, color: AppColors.textSecondary),
|
||||
title: const Text('Edit Rate'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEditPricingDialog(matchingRate as Map<String, dynamic>);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: AppColors.primary),
|
||||
title: const Text('Delete Rate', style: TextStyle(color: AppColors.primary)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_deletePricing(matchingRate['id'].toString());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'₹${matchingRate['rate']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.greenDark, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showAddPricingDialog(company),
|
||||
icon: const Icon(Icons.add, size: 16, color: AppColors.primary),
|
||||
label: const Text('Add Rate', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: companies.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2843
lib/screens/survey_screen.dart
Normal file
2843
lib/screens/survey_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
843
lib/screens/surveys_list_screen.dart
Normal file
843
lib/screens/surveys_list_screen.dart
Normal file
@@ -0,0 +1,843 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/crm_api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class SurveysListScreen extends StatefulWidget {
|
||||
final List<dynamic> surveys;
|
||||
final VoidCallback onDataChanged;
|
||||
final bool isActive;
|
||||
|
||||
const SurveysListScreen({
|
||||
super.key,
|
||||
required this.surveys,
|
||||
required this.onDataChanged,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SurveysListScreen> createState() => _SurveysListScreenState();
|
||||
}
|
||||
|
||||
class _SurveysListScreenState extends State<SurveysListScreen> {
|
||||
final CrmApiService _api = CrmApiService();
|
||||
bool _isSaving = false;
|
||||
|
||||
void _deleteSurvey(String id) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Delete Survey?', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('This action cannot be undone. Are you sure you want to delete this record?', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success = await _api.deleteSurvey(id);
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, 'Survey deleted successfully.');
|
||||
Navigator.pop(context); // Optional: close screen or refresh
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, 'Failed to delete survey.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showEditSurveyDialog(Map<String, dynamic> survey) {
|
||||
_showSurveyFormDialog(survey: survey);
|
||||
}
|
||||
|
||||
void _showAddSurveyDialog() {
|
||||
_showSurveyFormDialog();
|
||||
}
|
||||
|
||||
void _showSurveyFormDialog({Map<String, dynamic>? survey}) {
|
||||
final isEditing = survey != null;
|
||||
final companyCtrl = TextEditingController(text: survey?['company']?.toString() ?? '');
|
||||
final areaCtrl = TextEditingController(text: survey?['area']?.toString() ?? '');
|
||||
final phoneCtrl = TextEditingController(text: survey?['phone']?.toString() ?? '');
|
||||
final rateCtrl = TextEditingController(text: survey?['rate_per_kg']?.toString() ?? '');
|
||||
final addressCtrl = TextEditingController(text: survey?['address']?.toString() ?? '');
|
||||
final id = survey?['id']?.toString() ?? '';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blueLight,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(isEditing ? Icons.edit_outlined : Icons.add_circle_outline, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEditing ? 'Edit Survey' : 'Add Competitor Survey', style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 18)),
|
||||
const SizedBox(height: 2),
|
||||
Text(isEditing ? 'Update competitor details' : 'Log a new provider survey', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Form Fields
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildModernTextField(
|
||||
controller: companyCtrl,
|
||||
label: 'Company / Provider',
|
||||
hint: 'e.g. DTDC, Delhivery',
|
||||
icon: Icons.storefront_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: areaCtrl,
|
||||
label: 'Area / Zone',
|
||||
hint: 'e.g. SOUTH ZONE',
|
||||
icon: Icons.place_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildModernTextField(
|
||||
controller: phoneCtrl,
|
||||
label: 'Contact Number',
|
||||
hint: 'Optional',
|
||||
icon: Icons.phone_outlined,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildModernTextField(
|
||||
controller: rateCtrl,
|
||||
label: 'Rate / KG',
|
||||
hint: '0.00',
|
||||
icon: Icons.currency_rupee,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: addressCtrl,
|
||||
label: 'Full Address',
|
||||
hint: 'Optional physical address',
|
||||
icon: Icons.map_outlined,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (companyCtrl.text.isEmpty || areaCtrl.text.isEmpty) {
|
||||
SnackbarUtils.showError(ctx, 'Company and Area are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final payload = {
|
||||
'company': companyCtrl.text,
|
||||
'area': areaCtrl.text,
|
||||
'phone': phoneCtrl.text,
|
||||
'rate_per_kg': rateCtrl.text,
|
||||
'address': addressCtrl.text,
|
||||
'offers_pickup': survey?['offers_pickup'] ?? 'no',
|
||||
'offers_drop': survey?['offers_drop'] ?? 'no',
|
||||
'packing_charge': survey?['packing_charge'] ?? '',
|
||||
'time_in_days': survey?['time_in_days'] ?? '',
|
||||
'plus_code': survey?['plus_code'] ?? '',
|
||||
'frequency': survey?['frequency'] ?? 'Daily',
|
||||
'pincodes': survey?['pincodes'] ?? '',
|
||||
};
|
||||
|
||||
final success = isEditing
|
||||
? await _api.updateSurvey(id, payload)
|
||||
: await _api.createSurvey(payload);
|
||||
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, isEditing ? 'Survey updated!' : 'Survey added!');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, isEditing ? 'Update failed.' : 'Failed to add survey.');
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(isEditing ? 'Save Changes' : 'Add Survey', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModernTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: maxLines,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.darkBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: AppColors.textSecondary.withValues(alpha: 0.5)),
|
||||
prefixIcon: maxLines == 1 ? Icon(icon, size: 18, color: AppColors.textSecondary) : null,
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8F9FA),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: maxLines == 1 ? 16 : 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.02), blurRadius: 4, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Group surveys by company
|
||||
Map<String, List<dynamic>> get groupedSurveys {
|
||||
final Map<String, List<dynamic>> map = {};
|
||||
for (var s in widget.surveys) {
|
||||
final company = s['company']?.toString().trim();
|
||||
final key = (company == null || company.isEmpty) ? 'Unknown' : company;
|
||||
if (!map.containsKey(key)) {
|
||||
map[key] = [];
|
||||
}
|
||||
map[key]!.add(s);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = groupedSurveys;
|
||||
final companies = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
appBar: AppBar(
|
||||
title: const Text('Competitor Intelligence', style: TextStyle(color: AppColors.darkBlue, fontWeight: FontWeight.w800)),
|
||||
backgroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: AppColors.darkBlue),
|
||||
elevation: 0,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _isSaving ? null : _showAddSurveyDialog,
|
||||
backgroundColor: AppColors.primary,
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: const Text('Add Provider', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
body: _isSaving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: widget.surveys.isEmpty
|
||||
? const Center(child: Text('No surveys available.', style: TextStyle(color: AppColors.textSecondary)))
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.blueLight, Colors.white],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.primary.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.radar_outlined, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Market Overview', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 2),
|
||||
Text('Tracking ${widget.surveys.length} surveys across ${companies.length} providers', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard('Active Providers', companies.length.toString(), Icons.storefront),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard('Total Branches', widget.surveys.length.toString(), Icons.location_on_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final company = companies[index];
|
||||
final locations = groups[company] ?? [];
|
||||
final initial = company.isNotEmpty ? company.substring(0, 1).toUpperCase() : 'U';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
company,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 16),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Icon(Icons.storefront, size: 14, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text('${locations.length} Locations Surveyed', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: locations.map((survey) {
|
||||
return _SurveyLocationCard(
|
||||
survey: survey as Map<String, dynamic>,
|
||||
onEdit: () => _showEditSurveyDialog(survey),
|
||||
onDelete: () => _deleteSurvey(survey['id'].toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: companies.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 80)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SurveyLocationCard extends StatefulWidget {
|
||||
final Map<String, dynamic> survey;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _SurveyLocationCard({
|
||||
required this.survey,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SurveyLocationCard> createState() => _SurveyLocationCardState();
|
||||
}
|
||||
|
||||
class _SurveyLocationCardState extends State<_SurveyLocationCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final survey = widget.survey;
|
||||
final address = survey['address']?.toString() ?? survey['area']?.toString() ?? 'No address';
|
||||
final area = survey['area']?.toString() ?? 'Unknown';
|
||||
final phone = survey['phone']?.toString() ?? 'N/A';
|
||||
final rate = survey['rate_per_kg']?.toString() ?? '';
|
||||
final hasRate = rate.isNotEmpty;
|
||||
|
||||
final pickup = survey['offers_pickup']?.toString().toLowerCase() == 'yes' ? 'Yes' : '—';
|
||||
final drop = survey['offers_drop']?.toString().toLowerCase() == 'yes' ? 'Yes' : '—';
|
||||
final packing = survey['packing_charge']?.toString() ?? '—';
|
||||
final time = survey['time_in_days']?.toString() ?? '—';
|
||||
final freq = survey['frequency']?.toString() ?? '—';
|
||||
final plusCode = survey['plus_code']?.toString() ?? '—';
|
||||
final pincodes = survey['pincodes']?.toString() ?? '—';
|
||||
final company = survey['company']?.toString() ?? '—';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header Row
|
||||
InkWell(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, color: AppColors.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
area,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.darkBlue, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.phone, size: 12, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(phone, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('RATE PER KG', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: hasRate ? AppColors.green.withValues(alpha: 0.1) : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
hasRate ? '₹$rate' : 'Not Answered',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasRate ? AppColors.greenDark : AppColors.textSecondary
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isExpanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('LOGISTICS', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.textSecondary)),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isExpanded = true),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
minimumSize: const Size(0, 30),
|
||||
),
|
||||
child: const Text('More Info', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.darkBlue)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: widget.onEdit,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.edit_outlined, size: 18, color: AppColors.textSecondary)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.delete_outline, size: 18, color: AppColors.primaryDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_isExpanded)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _isExpanded = false),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppColors.primary.withValues(alpha: 0.05),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: const Size(0, 30),
|
||||
),
|
||||
icon: const Text('Hide Info', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.primary)),
|
||||
label: const Icon(Icons.expand_less, size: 16, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: widget.onEdit,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.edit_outlined, size: 18, color: AppColors.textSecondary)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.delete_outline, size: 18, color: AppColors.primaryDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Grid of Details
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDetailSection(
|
||||
icon: Icons.assignment_outlined,
|
||||
title: 'ENQUIRY DETAILS',
|
||||
items: [
|
||||
_buildInfoItem('RATE PER KG', hasRate ? '₹$rate' : 'Not Answered', isHighlight: hasRate),
|
||||
_buildInfoItem('PICKUP', pickup),
|
||||
_buildInfoItem('DROP', drop),
|
||||
_buildInfoItem('PACKING', packing),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildDetailSection(
|
||||
icon: Icons.local_shipping_outlined,
|
||||
title: 'LOGISTICS & OPS',
|
||||
items: [
|
||||
_buildInfoItem('TIME IN DAYS', time),
|
||||
_buildInfoItem('COMPANY', company),
|
||||
_buildInfoItem('FREQUENCY', freq),
|
||||
_buildInfoItem('CONTACT NUMBER', phone),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailSection(
|
||||
icon: Icons.place_outlined,
|
||||
title: 'LOCATION & PINCODE',
|
||||
items: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem('AREA / ZONE', area)),
|
||||
Expanded(child: _buildInfoItem('PLUS CODE', plusCode)),
|
||||
],
|
||||
),
|
||||
_buildInfoItem('SERVICEABLE PINCODES', pincodes),
|
||||
_buildInfoItem('FULL ADDRESS', address),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.map_outlined, size: 14, color: AppColors.primary),
|
||||
label: const Text('View On Map', style: TextStyle(color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: const BorderSide(color: AppColors.primary)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailSection(
|
||||
icon: Icons.list_alt,
|
||||
title: 'RECORD METADATA',
|
||||
items: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem('CREATED BY', 'System')),
|
||||
Expanded(child: _buildInfoItem('LAST EDITED BY', '—')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection({required IconData icon, required String title, required List<Widget> items}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFDECEE),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(8)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(title, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items.expand((w) => [w, const SizedBox(height: 12)]).toList()..removeLast(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String label, String value, {bool isHighlight = false}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w800, color: AppColors.textSecondary, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 4),
|
||||
if (isHighlight)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: AppColors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.greenDark)),
|
||||
)
|
||||
else
|
||||
Text(value, style: const TextStyle(fontSize: 12, color: AppColors.darkBlue, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/screens/update_screen.dart
Normal file
93
lib/screens/update_screen.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'dart:io';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class UpdateRequiredScreen extends StatelessWidget {
|
||||
const UpdateRequiredScreen({super.key});
|
||||
|
||||
Future<void> _triggerNativeUpdate() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
await InAppUpdate.performImmediateUpdate();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Illustration or Icon
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF4FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.system_update_rounded,
|
||||
size: 50,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Text Content
|
||||
const Text(
|
||||
'Update Required',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'A new version of Doormile CRM is available. You must update to the latest version to continue using the app securely.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Update Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _triggerNativeUpdate,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Update Now',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user