Files
velocity-pay-flutter/lib/screens/home/home_screen.dart
2026-05-29 23:54:17 +02:00

920 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
import 'package:qpay/widgets/transaction_item_widget.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
late HomeController homeController;
late TransactionController transactionController;
late SharedPreferences prefs;
late final router = GoRouter.of(context);
bool _isLoggedIn = false;
String initials = '';
late AnimationController _providersAnimController;
late Animation<Offset> _providersSlideAnimation;
late Animation<double> _providersFadeAnimation;
late AnimationController _transactionsAnimController;
late Animation<Offset> _transactionsSlideAnimation;
late Animation<double> _transactionsFadeAnimation;
// ── Vibrant category color map ──────────────────────────────
static const Map<String, Color> _categoryColorMap = {
'ECONET': Color(0xFF1A73E8), // Deep green
'NETONE': Color.fromARGB(255, 230, 127, 0), // Bold blue
'TELECEL': Color(0xFF7B1FA2), // Vibrant purple
'ZESA': Color.fromARGB(255, 230, 58, 0), // Fiery orange
};
@override
void initState() {
super.initState();
_providersAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_providersSlideAnimation =
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
CurvedAnimation(
parent: _providersAnimController,
curve: Curves.easeOutCubic,
),
);
_providersFadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _providersAnimController, curve: Curves.easeOut),
);
_transactionsAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
_transactionsSlideAnimation =
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
CurvedAnimation(
parent: _transactionsAnimController,
curve: Curves.easeOutCubic,
),
);
_transactionsFadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _transactionsAnimController,
curve: Curves.easeOut,
),
);
setupDataAndTransitions();
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _providersAnimController.forward();
});
Future.delayed(const Duration(milliseconds: 280), () {
if (mounted) _transactionsAnimController.forward();
});
}
Future<void> setupDataAndTransitions() async {
router.routerDelegate.addListener(_onRouteChanged);
transactionController = Provider.of<TransactionController>(
context,
listen: false,
);
transactionController.model = TransactionModel();
homeController = HomeController(context);
await homeController.getAccounts();
await homeController.getCategories();
await homeController.getProviders();
prefs = await SharedPreferences.getInstance();
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
initials = prefs.getString("initials")!;
});
}
if (prefs.getString("userId") == null) {
prefs.setString("userId", Uuid().v4());
}
await homeController.getTransactions(prefs.getString("userId")!);
}
Future<void> _onRefresh() async {
await homeController.getAccounts();
await homeController.getCategories();
await homeController.getProviders();
prefs = await SharedPreferences.getInstance();
if (prefs.getString("userId") != null) {
await homeController.getTransactions(prefs.getString("userId")!);
}
}
void _onRouteChanged() async {
final location = router.routerDelegate.currentConfiguration.uri.toString();
if (location.startsWith('/home')) {
prefs = await SharedPreferences.getInstance();
if (prefs.getString("userId") == null) {
prefs.setString("userId", Uuid().v4());
}
await homeController.getTransactions(prefs.getString("userId")!);
}
}
/// Handles the repeat action for a transaction.
Future<void> _repeatTransaction(Map<String, dynamic> item) async {
final receiptController = ReceiptController(context);
final transaction = txn.TransactionModel.fromJson(item);
await receiptController.repeatTransaction(transaction);
if (mounted) {
context.push('/make-payment');
}
}
@override
void dispose() {
_providersAnimController.dispose();
_transactionsAnimController.dispose();
homeController.dispose();
router.routerDelegate.removeListener(_onRouteChanged);
super.dispose();
}
/// Derives a vibrant accent colour from the provider's category.
Color _resolveProviderColor(BillProvider provider) {
// Try exact match on category
final exact = _categoryColorMap[provider.category.toUpperCase()];
if (exact != null) return exact;
// Try matching on label
final labelMatch = _categoryColorMap[provider.label.toUpperCase()];
if (labelMatch != null) return labelMatch;
// Try matching on provider name keywords
final name = provider.name.toUpperCase();
for (final entry in _categoryColorMap.entries) {
if (name.contains(entry.key)) return entry.value;
}
// Hash-based fallback deterministic vibrant hue
final hash = provider.clientId.hashCode;
final hue = (hash % 360).toDouble();
return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
body: ListenableBuilder(
listenable: homeController,
builder: (context, child) {
return RefreshIndicator(
onRefresh: _onRefresh,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
pinned: false,
floating: true,
stretch: true,
surfaceTintColor: Colors.transparent,
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
title: constraints.maxWidth < ResponsivePolicy.md
? Image(
image: const AssetImage('assets/velocity.png'),
width: 130,
)
: null,
actions: [
if (_isLoggedIn) ...[
Container(
width: 30,
height: 30,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black87.withAlpha(20),
),
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(
alpha: 0.15,
),
theme.colorScheme.tertiary.withValues(
alpha: 0.05,
),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
child: Center(
child: Text(
initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
),
),
IconButton(
icon: Icon(
Icons.logout_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _showLogoutDialog,
),
],
],
),
SliverToBoxAdapter(
child: Center(
child: SizedBox(
width: constraints.maxWidth > 600
? 600
: constraints.maxWidth,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
child: LayoutBuilder(
builder: (context, innerConstraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FadeTransition(
opacity: _providersFadeAnimation,
child: SlideTransition(
position: _providersSlideAnimation,
child: _buildProvidersSection(
theme,
isDark,
innerConstraints,
),
),
),
const SizedBox(height: 24),
FadeTransition(
opacity: _transactionsFadeAnimation,
child: SlideTransition(
position: _transactionsSlideAnimation,
child: _buildTransactionsSection(
theme,
isDark,
innerConstraints,
),
),
),
],
);
},
),
),
),
),
),
],
),
);
},
),
);
},
);
}
// ──────────────────────────────────────────────────────────────
// Providers Section
// ──────────────────────────────────────────────────────────────
Widget _buildProvidersSection(
ThemeData theme,
bool isDark,
BoxConstraints constraints,
) {
final containerWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: 340.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
IconButton(
icon: Icon(
Icons.refresh_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _onRefresh,
tooltip: 'Refresh',
),
],
),
const SizedBox(height: 12),
Skeletonizer(
enabled: homeController.model.isLoading,
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
...homeController.model.filterableProviders.map(
(provider) =>
_buildProviderCard(provider, theme, isDark, containerWidth),
),
],
),
),
],
);
}
// ──────────────────────────────────────────────────────────────
// Provider Card — flex-based 2-column grid
// ──────────────────────────────────────────────────────────────
Widget _buildProviderCard(
BillProvider provider,
ThemeData theme,
bool isDark,
double availableWidth,
) {
final cardWidth = (availableWidth / 2) - 6;
final accent = _resolveProviderColor(provider);
return SizedBox(
width: cardWidth,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
transactionController.clearFormData();
homeController.updateSelectedProvider(provider);
transactionController.updateSelectedProvider(provider);
context.push("/recipients");
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.15),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Image(
image: AssetImage("assets/${provider.image}"),
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 20,
color: accent,
),
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: accent.withValues(alpha: 0.55),
),
],
),
const SizedBox(height: 12),
Text(
provider.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
provider.description,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
);
}
// ──────────────────────────────────────────────────────────────
// Transactions Section
// ──────────────────────────────────────────────────────────────
Widget _buildTransactionsSection(
ThemeData theme,
bool isDark,
BoxConstraints constraints,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionLabel(
theme,
isDark,
Icons.history_rounded,
"Recent Activity",
),
const SizedBox(height: 12),
if (homeController.model.transactions.isEmpty)
_buildEmptyTransactions(isDark),
if (homeController.model.transactions.isNotEmpty) ...[
...homeController.model.transactions.map(
(e) => TransactionItemWidget(
transaction: e,
enabled: homeController.model.isLoading,
onRepeat: () => _repeatTransaction(e),
),
),
const SizedBox(height: 8),
if (homeController.model.transactions.length > 2)
Center(
child: OutlinedButton.icon(
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
label: const Text('View All'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
onPressed: () {
context.go('/history');
},
),
),
],
],
);
}
// ──────────────────────────────────────────────────────────────
// Empty Transactions Placeholder
// ──────────────────────────────────────────────────────────────
Widget _buildEmptyTransactions(bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.receipt_long_outlined,
size: 48,
color: isDark ? Colors.white24 : Colors.grey.shade300,
),
const SizedBox(height: 12),
Text(
'No transactions yet',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
const SizedBox(height: 4),
Text(
'Make a payment to see your recent activity.',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white38 : Colors.grey.shade400,
),
),
],
),
);
}
// ──────────────────────────────────────────────────────────────
// Section Label
// ──────────────────────────────────────────────────────────────
Widget _buildSectionLabel(
ThemeData theme,
bool isDark,
IconData icon,
String label,
) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
letterSpacing: 0.3,
),
),
],
);
}
// ──────────────────────────────────────────────────────────────
// Login Prompt
// ──────────────────────────────────────────────────────────────
Widget _buildLoginPrompt(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withValues(alpha: 0.12),
theme.colorScheme.primary.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Register or Login for more',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 2),
InkWell(
onTap: () => _showFeaturesPopup(context),
borderRadius: BorderRadius.circular(4),
child: Text(
'features',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
color: theme.colorScheme.primary,
),
),
),
],
),
),
ElevatedButton(
onPressed: () {
context.push('/onboarding/landing');
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'Get Started',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
);
}
// ──────────────────────────────────────────────────────────────
// Logout Dialog
// ──────────────────────────────────────────────────────────────
void _showLogoutDialog() {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Confirm Logout'),
content: const Text('Are you sure you want to log out?'),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
TextButton(
child: const Text('Logout'),
onPressed: () {
Navigator.of(dialogContext).pop();
prefs.clear();
setState(() {
_isLoggedIn = false;
});
setupDataAndTransitions();
},
),
],
);
},
);
}
// ──────────────────────────────────────────────────────────────
// Features Popup
// ──────────────────────────────────────────────────────────────
void _showFeaturesPopup(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Image(
image: AssetImage("assets/favicon.png"),
width: 45,
),
),
const SizedBox(height: 16),
Text(
'Unlock Additional Features',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Register or login to access these great features',
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
_buildFeatureItem(
Icons.account_balance_wallet,
'Dual Currency Wallets',
'USD and ZWG wallets for prepayments that can be utilized later',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.history,
'Transaction History',
'All transactions are saved for reference from any device',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.people,
'Recipient Management',
'Save and manage recipient details for future use',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.schedule,
'Smart Payments',
'Schedule payments and split bills with friends',
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Maybe Later',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
context.push('/onboarding/landing');
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Get Started',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
],
),
),
),
);
},
);
}
Widget _buildFeatureItem(IconData icon, String title, String description) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
description,
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.4,
),
),
],
),
),
],
);
}
}