Enhance Flutter app with animations, contacts access, image picker, and permissions handler

This commit is contained in:
Sridhar R 2026-06-10 13:48:06 +05:30
parent f5a2e02839
commit 6f11b367b6
9 changed files with 822 additions and 273 deletions

View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class FadeSlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget child;
FadeSlidePageRoute({required this.child})
: super(
pageBuilder: (context, animation, secondaryAnimation) => child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final fadeAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
);
final slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.05),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
),
);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: child,
),
);
},
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 200),
);
}

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:ui';
import '../theme.dart';
import '../route_transitions.dart';
import 'customize_setup_screen.dart';
class BusinessSetupScreen extends StatefulWidget {
@ -44,6 +48,105 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
{'id': 'others', 'name': 'Others', 'icon': Icons.grid_view},
];
Future<void> _handleImagePick(String docName, ImageSource source) async {
final permission = source == ImageSource.camera ? Permission.camera : Permission.photos;
var status = await permission.status;
if (!mounted) return;
if (status.isDenied && !kIsWeb) {
final String sourceName = source == ImageSource.camera ? 'Camera' : 'Photos/Gallery';
final bool? proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('$sourceName Permission Required'),
content: Text(
'PozoApp requires $sourceName permission to upload document verification files and store facade images.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Continue'),
),
],
),
);
if (!mounted) return;
if (proceed != true) return;
status = await permission.request();
if (!mounted) return;
}
if (status.isGranted || kIsWeb) {
try {
final picker = ImagePicker();
final XFile? file = await picker.pickImage(source: source);
if (!mounted) return;
if (file != null) {
setState(() {
_uploadedDocs[docName] = file.name;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('✅ Successfully loaded $docName!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to pick image: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Permission denied. Cannot load document image.'),
behavior: SnackBarBehavior.floating,
),
);
}
}
void _showFacadePhotoOptions() {
showModalBottomSheet(
context: context,
builder: (context) {
return SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.camera_alt, color: Color(0xFFAF101A)),
title: const Text('Take Photo'),
onTap: () {
Navigator.pop(context);
_handleImagePick('facade_photo', ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library, color: Color(0xFFAF101A)),
title: const Text('Choose from Gallery'),
onTap: () {
Navigator.pop(context);
_handleImagePick('facade_photo', ImageSource.gallery);
},
),
],
),
);
},
);
}
Widget _buildDocChip(String label, IconData icon) {
final theme = Theme.of(context);
final isSelected = _selectedDocs.contains(label);
@ -95,7 +198,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
Widget _buildUploadSection(String docName, IconData icon) {
final theme = Theme.of(context);
final isUploaded = _uploadedDocs.containsKey(docName);
return Container(
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
@ -180,7 +283,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 2),
const Text(
'Size: 1.2 MB • Ready',
'Attached • Ready',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
@ -240,18 +343,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () {
setState(() {
_uploadedDocs[docName] = '${docName.toLowerCase().replaceAll(' ', '_')}_scan.jpg';
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('📸 Captured photo for $docName!'),
backgroundColor: const Color(0xFFAF101A),
behavior: SnackBarBehavior.floating,
),
);
},
onPressed: () => _handleImagePick(docName, ImageSource.camera),
icon: const Icon(Icons.camera_alt, size: 14),
label: const Text('Take Photo'),
style: ElevatedButton.styleFrom(
@ -261,24 +353,12 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
elevation: 1,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
setState(() {
_uploadedDocs[docName] = 'gallery_${docName.toLowerCase().replaceAll(' ', '_')}.png';
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('📂 Uploaded $docName from gallery!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
},
onPressed: () => _handleImagePick(docName, ImageSource.gallery),
icon: const Icon(Icons.photo_library, size: 14),
label: const Text('From Gallery'),
style: OutlinedButton.styleFrom(
@ -286,8 +366,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
side: const BorderSide(color: Color(0xFFAF101A)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
borderRadius: BorderRadius.circular(8)),
),
),
],
@ -339,7 +418,8 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
child: Row(
children: List.generate(5, (index) {
return Expanded(
child: Container(
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2.0),
decoration: BoxDecoration(
@ -379,7 +459,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 16),
// Name Field Input
Text(
'Business/Shop Name *',
style: theme.textTheme.labelSmall?.copyWith(
@ -397,7 +476,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 16),
// Category selection Block
Text(
'Business Type *',
style: theme.textTheme.labelSmall?.copyWith(
@ -407,7 +485,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 10),
// Category custom grid (2x3)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -431,8 +508,8 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFAF101A).withOpacity(0.08)
color: isSelected
? const Color(0xFFAF101A).withOpacity(0.08)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
@ -478,7 +555,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 16),
// GSTIN optional Input group with Verify button on right-side
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -542,7 +618,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 16),
// Shop Location Detect
Text(
'Shop Location *',
style: theme.textTheme.labelSmall?.copyWith(
@ -619,7 +694,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 16),
// Identity/License Documents
Text(
'Identity / License Documents (Select all that apply)',
style: theme.textTheme.labelSmall?.copyWith(
@ -640,7 +714,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
],
),
const SizedBox(height: 8),
// Expanded Upload Sections
..._selectedDocs.map((doc) {
IconData docIcon = Icons.description;
if (doc == 'GSTIN') docIcon = Icons.description;
@ -651,7 +724,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
return _buildUploadSection(doc, docIcon);
}),
const SizedBox(height: 16),
// Shop Facade Photo upload area
Text(
'Shop Facade Photo *',
style: theme.textTheme.labelSmall?.copyWith(
@ -662,12 +735,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
const SizedBox(height: 8),
InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('📸 Camera / Photo Upload Clicked!'),
behavior: SnackBarBehavior.floating,
),
);
_showFacadePhotoOptions();
},
child: Container(
width: double.infinity,
@ -676,7 +744,8 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.outlineVariant,
color: _uploadedDocs.containsKey('facade_photo') ? Colors.green : theme.colorScheme.outlineVariant,
width: _uploadedDocs.containsKey('facade_photo') ? 2.0 : 1.0,
),
),
child: Column(
@ -685,18 +754,18 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
color: _uploadedDocs.containsKey('facade_photo') ? Colors.green.withOpacity(0.1) : theme.colorScheme.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.add_a_photo_outlined,
color: theme.colorScheme.primary,
_uploadedDocs.containsKey('facade_photo') ? Icons.check_circle : Icons.add_a_photo_outlined,
color: _uploadedDocs.containsKey('facade_photo') ? Colors.green : theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(height: 12),
Text(
'Upload front view',
_uploadedDocs.containsKey('facade_photo') ? _uploadedDocs['facade_photo']! : 'Upload front view',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
@ -704,7 +773,7 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
const SizedBox(height: 4),
Text(
'Clear photo showing shop board',
_uploadedDocs.containsKey('facade_photo') ? 'Facade Photo Attached • Tap to change' : 'Clear photo showing shop board',
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
@ -721,7 +790,6 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
),
),
// Button docked
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: ElevatedButton(
@ -729,8 +797,8 @@ class _BusinessSetupScreenState extends State<BusinessSetupScreen> {
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CustomizeSetupScreen(
FadeSlidePageRoute(
child: CustomizeSetupScreen(
businessName: _nameController.text.trim(),
businessType: _selectedCategory,
gstin: _gstinController.text.trim(),
@ -805,7 +873,6 @@ class DashedBorderPainter extends CustomPainter {
canvas.drawPath(dashPath, paint);
} catch (e) {
// Fallback for platform/renderers (such as Flutter Web HTML renderer) that do not support computeMetrics()
canvas.drawRRect(rrect, paint);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../route_transitions.dart';
import 'setup_complete_screen.dart';
class CustomizeSetupScreen extends StatefulWidget {
@ -53,8 +54,8 @@ class _CustomizeSetupScreenState extends State<CustomizeSetupScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SetupCompleteScreen(
FadeSlidePageRoute(
child: SetupCompleteScreen(
businessName: widget.businessName,
businessType: widget.businessType,
gstin: widget.gstin,
@ -175,8 +176,8 @@ class _CustomizeSetupScreenState extends State<CustomizeSetupScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SetupCompleteScreen(
FadeSlidePageRoute(
child: SetupCompleteScreen(
businessName: widget.businessName,
businessType: widget.businessType,
gstin: widget.gstin,

View File

@ -1,8 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart';
import 'package:contacts_service/contacts_service.dart';
import '../theme.dart';
import '../route_transitions.dart';
import 'otp_verification_screen.dart';
class MockContact {
final String? displayName;
final List<MockPhone> phones;
MockContact({this.displayName, required String phoneNumber})
: phones = [MockPhone(phoneNumber)];
}
class MockPhone {
final String? value;
MockPhone(this.value);
}
class MobileLoginScreen extends StatefulWidget {
const MobileLoginScreen({super.key});
@ -32,6 +49,176 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
super.dispose();
}
Future<void> _pickContact() async {
const permission = Permission.contacts;
var status = await permission.status;
if (!mounted) return;
if (status.isDenied) {
final bool? proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Contacts Permission Required'),
content: const Text(
'PozoApp requires access to your contacts to quickly auto-fill mobile numbers during registration/billing.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Continue'),
),
],
),
);
if (!mounted) return;
if (proceed != true) return;
status = await permission.request();
if (!mounted) return;
}
if (status.isGranted || kIsWeb) {
_showContactPicker();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Contacts permission was denied. Please enter the number manually or enable it in Settings.'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<List<dynamic>> _loadContacts() async {
if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 600));
return [
MockContact(displayName: 'Amit Sharma', phoneNumber: '9876543210'),
MockContact(displayName: 'Rajesh Patel', phoneNumber: '9823456789'),
MockContact(displayName: 'Priya Sundaram', phoneNumber: '8765432109'),
MockContact(displayName: 'Karan Malhotra', phoneNumber: '7654321098'),
];
} else {
try {
final List<Contact> list = await ContactsService.getContacts(withThumbnails: false);
return list;
} catch (e) {
return [];
}
}
}
void _selectContactNumber(String? rawNumber) {
if (rawNumber == null) return;
var digits = rawNumber.replaceAll(RegExp(r'\D'), '');
if (digits.length == 12 && digits.startsWith('91')) {
digits = digits.substring(2);
}
if (digits.length == 11 && digits.startsWith('0')) {
digits = digits.substring(1);
}
if (digits.length > 10) {
digits = digits.substring(digits.length - 10);
}
setState(() {
_controller.text = digits;
_isValid = digits.length == 10;
});
}
Future<void> _showContactPicker() async {
List<dynamic> allContacts = [];
List<dynamic> filteredContacts = [];
bool isLoading = true;
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
if (isLoading) {
_loadContacts().then((loaded) {
setDialogState(() {
allContacts = loaded;
filteredContacts = loaded;
isLoading = false;
});
});
}
return AlertDialog(
title: const Text('Select Contact'),
content: SizedBox(
width: double.maxFinite,
height: 400,
child: Column(
children: [
TextField(
decoration: const InputDecoration(
hintText: 'Search contacts...',
prefixIcon: Icon(Icons.search),
),
onChanged: (query) {
setDialogState(() {
filteredContacts = allContacts.where((contact) {
final name = (contact.displayName ?? '').toLowerCase();
final phone = (contact.phones.isNotEmpty ? contact.phones.first.value ?? '' : '').toLowerCase();
return name.contains(query.toLowerCase()) || phone.contains(query.toLowerCase());
}).toList();
});
},
),
const SizedBox(height: 12),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: filteredContacts.isEmpty
? const Center(child: Text('No contacts found'))
: ListView.builder(
itemCount: filteredContacts.length,
itemBuilder: (context, index) {
final contact = filteredContacts[index];
final name = contact.displayName ?? 'Unknown';
final phone = contact.phones.isNotEmpty ? contact.phones.first.value : 'No number';
return ListTile(
title: Text(name),
subtitle: Text(phone),
leading: CircleAvatar(
backgroundColor: const Color(0xFFAF101A).withOpacity(0.1),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: const TextStyle(color: Color(0xFFAF101A), fontWeight: FontWeight.bold),
),
),
onTap: () {
_selectContactNumber(phone);
Navigator.pop(context);
},
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
)
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -63,23 +250,22 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Progress Bar: Step 2 of 5 with "Mobile Login" label on right
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Step 2 of 5',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurfaceVariant,
),
'Step 2 of 5',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Text(
'Mobile Login',
@ -112,7 +298,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
const SizedBox(height: 16),
// Title Header & Secure Badge Group
Text(
'Welcome Back',
style: theme.textTheme.headlineLarge?.copyWith(
@ -125,8 +310,7 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
// 6. Top Badge: Green "Secure OTP Verification" chip with lock icon
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@ -152,11 +336,10 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
const SizedBox(height: 16),
// 2. Truecaller Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF3F3F3), // light gray background (surface low)
color: const Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE4BEBA).withOpacity(0.5)),
),
@ -172,7 +355,7 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
shape: BoxShape.circle,
),
child: const Icon(
Icons.verified_user, // blue shield icon
Icons.verified_user,
color: Colors.white,
size: 24,
),
@ -228,8 +411,8 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const OtpVerificationScreen(
FadeSlidePageRoute(
child: const OtpVerificationScreen(
mobileNumber: '9876543210',
),
),
@ -249,7 +432,7 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 8px for buttons
borderRadius: BorderRadius.circular(8),
),
elevation: 1,
),
@ -269,7 +452,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
SizedBox(height: gap),
// 3. Divider: "Or enter mobile number manually"
Row(
children: [
const Expanded(child: Divider(color: Color(0xFFE4BEBA))),
@ -299,7 +481,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
const SizedBox(height: 8),
// 4. Mobile Number field: Country code group on left, number field on right
Container(
height: 56,
decoration: BoxDecoration(
@ -316,7 +497,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
child: Row(
children: [
// Flag and code prefix box
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
@ -326,7 +506,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
child: Row(
children: [
// Indian flag representation
Container(
width: 22,
height: 14,
@ -337,7 +516,7 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
children: [
Container(height: 4, color: const Color(0xFFFF9933)),
Container(
height: 4,
height: 4,
color: Colors.white,
child: Center(
child: Container(
@ -366,7 +545,6 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
),
// Input field
Expanded(
child: TextField(
controller: _controller,
@ -388,7 +566,11 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
color: theme.colorScheme.onSurface,
),
),
)
),
IconButton(
icon: const Icon(Icons.contact_phone, color: Color(0xFFAF101A)),
onPressed: _pickContact,
),
],
),
),
@ -398,18 +580,16 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
),
),
// 5. Bottom buttons
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Full width red "Send OTP" button
ElevatedButton(
onPressed: _isValid
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OtpVerificationScreen(
FadeSlidePageRoute(
child: OtpVerificationScreen(
mobileNumber: _controller.text,
),
),
@ -422,13 +602,12 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
foregroundColor: _isValid ? Colors.white : Colors.grey[400],
elevation: _isValid ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 8px default button radius
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Send OTP'),
),
SizedBox(height: gap / 2),
// Outline style red "Help / Talk to Support" button with chat icon
OutlinedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
@ -442,7 +621,7 @@ class _MobileLoginScreenState extends State<MobileLoginScreen> {
minimumSize: const Size.fromHeight(56),
side: const BorderSide(color: Color(0xFFAF101A), width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // 8px default button radius
borderRadius: BorderRadius.circular(8),
),
),
child: Row(

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../theme.dart';
import '../route_transitions.dart';
import 'business_setup_screen.dart';
class OtpVerificationScreen extends StatefulWidget {
@ -331,8 +332,8 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BusinessSetupScreen(),
FadeSlidePageRoute(
child: const BusinessSetupScreen(),
),
);
},

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../route_transitions.dart';
import 'billing_dashboard_screen.dart';
class SetupCompleteScreen extends StatelessWidget {
@ -302,8 +303,8 @@ class SetupCompleteScreen extends StatelessWidget {
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BillingDashboardScreen(
FadeSlidePageRoute(
child: BillingDashboardScreen(
businessName: businessName,
businessType: businessType,
gstin: gstin,

View File

@ -1,198 +1,256 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../route_transitions.dart';
import 'mobile_login_screen.dart';
class WelcomeScreen extends StatelessWidget {
class WelcomeScreen extends StatefulWidget {
const WelcomeScreen({super.key});
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<double> _logoScaleAnimation;
late Animation<double> _logoRotateAnimation;
double _btnScale = 1.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
);
_logoScaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.2, 0.8, curve: Curves.elasticOut),
),
);
_logoRotateAnimation = Tween<double>(begin: -0.1, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.2, 0.8, curve: Curves.easeOutBack),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isMobile = context.isMobile;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Top Progress bar indicator - 1st segment active
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
children: List.generate(5, (index) {
return Expanded(
child: Container(
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 2.0),
decoration: BoxDecoration(
color: index == 0 ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(2),
body: FadeTransition(
opacity: _fadeAnimation,
child: SafeArea(
child: Column(
children: [
// Top Progress bar indicator - 1st segment active
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
children: List.generate(5, (index) {
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 2.0),
decoration: BoxDecoration(
color: index == 0 ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(2),
),
),
),
);
}),
);
}),
),
),
),
// Scrollable / Responsive Content
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
if (maxWidth >= 768) {
// Desktop / Tablet Double-Column Layout
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left column: content + button
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLogo(theme),
const SizedBox(height: 16),
Text(
'Manage your business billing in 3 clicks!',
style: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
// Scrollable / Responsive Content
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
if (maxWidth >= 768) {
// Desktop / Tablet Double-Column Layout
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left column: content + button
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLogo(theme),
const SizedBox(height: 16),
Text(
'Manage your business billing in 3 clicks!',
style: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
Text(
'The fastest, most reliable tool for Indian MSMEs to run their shop.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: 16),
Text(
'The fastest, most reliable tool for Indian MSMEs to run their shop.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
_buildSeal(theme),
const SizedBox(height: 16),
_buildCTAColumn(context, theme),
],
const SizedBox(height: 16),
_buildSeal(theme),
const SizedBox(height: 16),
_buildCTAColumn(context, theme),
],
),
),
),
const SizedBox(width: 32),
// Right column: mock card
Expanded(
flex: 4,
child: Center(
child: _buildMockDashboard(theme),
const SizedBox(width: 32),
// Right column: mock card
Expanded(
flex: 4,
child: Center(
child: _buildMockDashboard(theme),
),
),
),
],
],
),
),
),
),
),
);
} else {
// Mobile Single Column Layout
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLogo(theme),
const SizedBox(height: 16),
Text(
'Manage your business billing in 3 clicks!',
textAlign: TextAlign.left,
style: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
);
} else {
// Mobile Single Column Layout
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLogo(theme),
const SizedBox(height: 16),
Text(
'Manage your business billing in 3 clicks!',
textAlign: TextAlign.left,
style: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
Text(
'The fastest, most reliable tool for Indian MSMEs to run their shop.',
textAlign: TextAlign.left,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: 16),
Text(
'The fastest, most reliable tool for Indian MSMEs to run their shop.',
textAlign: TextAlign.left,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
_buildSeal(theme),
const SizedBox(height: 24),
Center(child: _buildMockDashboard(theme)),
const SizedBox(height: 24),
],
const SizedBox(height: 16),
_buildSeal(theme),
const SizedBox(height: 24),
Center(child: _buildMockDashboard(theme)),
const SizedBox(height: 24),
],
),
),
),
);
}
},
);
}
},
),
),
),
// Pinned Bottom Button on Mobile
if (isMobile)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: _buildCTAColumn(context, theme),
),
],
// Pinned Bottom Button on Mobile
if (isMobile)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: _buildCTAColumn(context, theme),
),
],
),
),
),
);
}
Widget _buildLogo(ThemeData theme) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.15),
blurRadius: 24,
offset: const Offset(0, 8),
)
],
),
alignment: Alignment.center,
child: Stack(
clipBehavior: Clip.none,
children: [
Text(
'Pozo',
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: -1.0,
),
return ScaleTransition(
scale: _logoScaleAnimation,
child: RotationTransition(
turns: _logoRotateAnimation,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.15),
blurRadius: 24,
offset: const Offset(0, 8),
)
],
),
Positioned(
top: -16,
right: -16,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.amber[700],
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Text(
'IN',
style: TextStyle(
alignment: Alignment.center,
child: Stack(
clipBehavior: Clip.none,
children: [
Text(
'Pozo',
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w800,
letterSpacing: -1.0,
),
),
),
)
],
Positioned(
top: -16,
right: -16,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.amber[700],
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Text(
'IN',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
)
],
),
),
),
);
}
@ -385,24 +443,38 @@ class WelcomeScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: context.isMobile ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MobileLoginScreen()),
);
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(260, 54),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: const [
Text('Get Started'),
SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 18),
],
MouseRegion(
onEnter: (_) => setState(() => _btnScale = 1.03),
onExit: (_) => setState(() => _btnScale = 1.0),
child: GestureDetector(
onTapDown: (_) => setState(() => _btnScale = 0.95),
onTapUp: (_) => setState(() => _btnScale = 1.03),
onTapCancel: () => setState(() => _btnScale = 1.0),
child: AnimatedScale(
scale: _btnScale,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
FadeSlidePageRoute(child: const MobileLoginScreen()),
);
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(260, 54),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: const [
Text('Get Started'),
SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 18),
],
),
),
),
),
),
const SizedBox(height: 16),

View File

@ -41,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
contacts_service:
dependency: "direct main"
description:
name: contacts_service
sha256: f6d5ea33b31dfcdcd2e65d8abdc836502e04ddb0f66a96aa726fa9891ea9671e
url: "https://pub.dev"
source: hosted
version: "0.6.3"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -73,6 +89,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
url: "https://pub.dev"
source: hosted
version: "0.9.4+4"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter:
dependency: "direct main"
description: flutter
@ -86,11 +134,24 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476
url: "https://pub.dev"
source: hosted
version: "2.0.31"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
@ -115,6 +176,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e"
url: "https://pub.dev"
source: hosted
version: "0.8.13+1"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e
url: "https://pub.dev"
source: hosted
version: "0.8.13"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
leak_tracker:
dependency: transitive
description:
@ -171,6 +296,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
@ -227,6 +360,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
url: "https://pub.dev"
source: hosted
version: "9.4.9"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
platform:
dependency: transitive
description:
@ -243,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
sky_engine:
dependency: transitive
description: flutter
@ -337,5 +526,5 @@ packages:
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.32.0"

View File

@ -10,6 +10,9 @@ dependencies:
sdk: flutter
cupertino_icons: ^1.0.5
google_fonts: ^5.1.0
image_picker: ^1.0.4
permission_handler: ^11.0.1
contacts_service: ^0.6.3
dev_dependencies:
flutter_test: