Files
velocity-pay-flutter/lib/screens/home/home_screen.dart
2026-06-11 23:48:26 +02:00

845 lines
31 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/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/widgets/account_balance_widget.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/app_snack_bar.dart';
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;
bool _isZWGSelected = 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(_isZWGSelected ? "ZWG" : "USD");
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(_isZWGSelected ? "ZWG" : "USD");
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) {
final isMobile = constraints.maxWidth < ResponsivePolicy.md;
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: [
if (isMobile)
SliverAppBar(
pinned: false,
floating: true,
stretch: true,
surfaceTintColor: Colors.transparent,
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
title: Image(
image: const AssetImage('assets/velocity.png'),
width: 130,
),
actions: _buildAppBarActions(theme, isDark),
),
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: [
if (!isMobile) ...[
_buildWebHeader(theme, isDark),
const SizedBox(height: 16),
],
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,
),
),
),
],
);
},
),
),
),
),
),
],
),
);
},
),
);
},
);
}
// ──────────────────────────────────────────────────────────────
// App Bar Actions (shared between mobile SliverAppBar and web header)
// ──────────────────────────────────────────────────────────────
List<Widget> _buildAppBarActions(ThemeData theme, bool isDark) {
return [
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,
),
],
];
}
// ──────────────────────────────────────────────────────────────
// Web Header (replaces SliverAppBar on web/tablet)
// ──────────────────────────────────────────────────────────────
Widget _buildWebHeader(ThemeData theme, bool isDark) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: _buildAppBarActions(theme, isDark),
),
],
);
}
// ──────────────────────────────────────────────────────────────
// 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),
if (_isLoggedIn) const AccountBalanceWidget(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'USD',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: !_isZWGSelected
? theme.colorScheme.primary
: isDark
? Colors.white54
: Colors.black45,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: () {
setState(() {
_isZWGSelected = !_isZWGSelected;
});
if (_isZWGSelected) {
AppSnackBar.show(
context,
'ZWG currency not supported yet',
customContent: const Center(
child: Text('ZWG currency not supported yet'),
),
backgroundColor: theme.colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
);
}
Future.delayed(
const Duration(milliseconds: 1000),
() {
setState(() {
_isZWGSelected = !_isZWGSelected;
});
},
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 32,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: _isZWGSelected
? theme.colorScheme.primary
: Colors.grey.shade300,
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
alignment: _isZWGSelected
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
width: 14,
height: 14,
margin: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
),
Text(
'ZWG',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _isZWGSelected
? theme.colorScheme.primary
: isDark
? Colors.white54
: Colors.black45,
),
),
],
),
),
const SizedBox(width: 4),
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 features',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
),
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;
});
authState.refresh();
setupDataAndTransitions();
},
),
],
);
},
);
}
}