Web approach - basic look and feel done

This commit is contained in:
2026-08-29 21:52:11 +05:30
parent 364961c9de
commit 372c2bc14d
71 changed files with 2209 additions and 1044 deletions

View File

@@ -0,0 +1,454 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../features/inventory/providers/commodity_rates_provider.dart';
import '../../features/business/providers/business_provider.dart';
import '../../features/business/providers/business_mode_provider.dart';
import '../../features/transactions/presentation/add_transaction_screen.dart';
import '../../features/sales/presentation/invoice_builder_screen.dart';
import '../../features/vendor/presentation/purchase_order_builder_screen.dart';
import '../../features/inventory/presentation/add_product_screen.dart';
import '../../features/auth/presentation/profile_screen.dart';
class DesktopSidebar extends ConsumerWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
const DesktopSidebar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
final businessProfile = ref.watch(businessProfileProvider).asData?.value;
final ratesAsync = ref.watch(commodityRatesProvider);
return Container(
width: 260,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF131720) : Colors.white,
border: Border(
right: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Brand Logo Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFD4AF37), Color(0xFFAA771C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Icon(LucideIcons.gem, color: Colors.white, size: 22),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'KIFI',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
color: isDark ? Colors.white : const Color(0xFF1E293B),
),
),
Text(
businessProfile?.businessName ?? 'Financial Ledger',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.black45,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
// 2. Quick Action Button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'transaction':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddTransactionScreen()),
);
break;
case 'invoice':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()),
);
break;
case 'po':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PurchaseOrderBuilderScreen()),
);
break;
case 'product':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddProductScreen()),
);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'transaction',
child: Row(
children: [
Icon(LucideIcons.arrowDownUp, size: 18, color: Colors.blueAccent),
SizedBox(width: 12),
Text('New Transaction'),
],
),
),
if (isBusinessMode) ...[
const PopupMenuItem(
value: 'invoice',
child: Row(
children: [
Icon(LucideIcons.fileText, size: 18, color: Colors.green),
SizedBox(width: 12),
Text('New Sales Invoice'),
],
),
),
const PopupMenuItem(
value: 'po',
child: Row(
children: [
Icon(LucideIcons.shoppingBag, size: 18, color: Colors.purpleAccent),
SizedBox(width: 12),
Text('New Purchase Order'),
],
),
),
const PopupMenuItem(
value: 'product',
child: Row(
children: [
Icon(LucideIcons.packagePlus, size: 18, color: Color(0xFFD4AF37)),
SizedBox(width: 12),
Text('Add Inventory Product'),
],
),
),
],
],
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [const Color(0xFF2563EB), const Color(0xFF1D4ED8)]
: [const Color(0xFF3B82F6), const Color(0xFF2563EB)],
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.plus, color: Colors.white, size: 18),
SizedBox(width: 8),
Text(
'Create New',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
Spacer(),
Icon(LucideIcons.chevronDown, color: Colors.white70, size: 16),
],
),
),
),
),
const SizedBox(height: 8),
// 3. Navigation List
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
_buildNavItem(
context,
index: 0,
icon: LucideIcons.layoutDashboard,
label: 'Dashboard',
isSelected: selectedIndex == 0,
),
_buildNavItem(
context,
index: 1,
icon: LucideIcons.arrowDownUp,
label: 'Transactions',
isSelected: selectedIndex == 1,
),
_buildNavItem(
context,
index: 2,
icon: LucideIcons.wallet,
label: 'Wallets & Accounts',
isSelected: selectedIndex == 2,
),
_buildNavItem(
context,
index: 3,
icon: LucideIcons.target,
label: 'Budgets & Goals',
isSelected: selectedIndex == 3,
),
if (isBusinessMode) ...[
const Padding(
padding: EdgeInsets.fromLTRB(12, 20, 12, 8),
child: Text(
'BUSINESS & ERP',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
color: Colors.grey,
),
),
),
_buildNavItem(
context,
index: 4,
icon: LucideIcons.briefcase,
label: 'Business Hub',
isSelected: selectedIndex == 4,
),
],
],
),
),
// 4. Live Metal Rate Widget in Sidebar
ratesAsync.when(
data: (rates) {
if (rates.isEmpty) return const SizedBox.shrink();
final goldRate = rates.firstWhere(
(r) => r.commodityCode == 'GOLD',
orElse: () => rates.first,
);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E2330) : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFFD4AF37).withValues(alpha: 0.2),
),
),
child: Row(
children: [
const Icon(LucideIcons.coins, color: Color(0xFFD4AF37), size: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gold (24K/10g)',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white60 : Colors.black54,
),
),
Text(
'${(goldRate.rate * 10).toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFFD4AF37),
),
),
],
),
),
const Icon(LucideIcons.trendingUp, color: Colors.green, size: 16),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
// 5. Sidebar Footer (Profile & Settings)
Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
),
),
),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProfileScreen()),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: isDark ? const Color(0xFF2563EB) : const Color(0xFF3B82F6),
child: const Icon(LucideIcons.user, size: 16, color: Colors.white),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Profile & Settings',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
IconButton(
tooltip: 'Settings',
icon: const Icon(LucideIcons.settings, size: 18),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProfileScreen()),
);
},
),
],
),
),
],
),
);
}
Widget _buildNavItem(
BuildContext context, {
required int index,
required IconData icon,
required String label,
required bool isSelected,
}) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: InkWell(
onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? (isDark
? const Color(0xFF2563EB).withValues(alpha: 0.18)
: const Color(0xFF2563EB).withValues(alpha: 0.1))
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(
color: const Color(0xFF2563EB).withValues(alpha: 0.3),
)
: null,
),
child: Row(
children: [
Icon(
icon,
size: 19,
color: isSelected
? const Color(0xFF3B82F6)
: (isDark ? Colors.white60 : Colors.black54),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? Colors.white : const Color(0xFF1D4ED8))
: (isDark ? Colors.white70 : Colors.black87),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
class ResponsiveBreakpoints {
static const double mobile = 768.0;
static const double desktop = 1024.0;
static const double wideDesktop = 1440.0;
}
class ResponsiveLayout {
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < ResponsiveBreakpoints.mobile;
static bool isTablet(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return width >= ResponsiveBreakpoints.mobile && width < ResponsiveBreakpoints.desktop;
}
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= ResponsiveBreakpoints.mobile;
static bool isWideDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= ResponsiveBreakpoints.wideDesktop;
}
class MaxContentWidth extends StatelessWidget {
final Widget child;
final double maxWidth;
final EdgeInsetsGeometry padding;
const MaxContentWidth({
super.key,
required this.child,
this.maxWidth = 1200.0,
this.padding = EdgeInsets.zero,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: padding,
child: child,
),
),
);
}
}
class ResponsiveBuilder extends StatelessWidget {
final Widget Function(BuildContext context) mobile;
final Widget Function(BuildContext context)? tablet;
final Widget Function(BuildContext context) desktop;
const ResponsiveBuilder({
super.key,
required this.mobile,
this.tablet,
required this.desktop,
});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width >= ResponsiveBreakpoints.desktop) {
return desktop(context);
} else if (width >= ResponsiveBreakpoints.mobile) {
return (tablet ?? desktop)(context);
} else {
return mobile(context);
}
}
}

View File

@@ -33,11 +33,10 @@ class SmartSearchDropdown<T> extends StatefulWidget {
}
class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
final OverlayPortalController _overlayController = OverlayPortalController();
final LayerLink _layerLink = LayerLink();
final FocusNode _focusNode = FocusNode();
final TextEditingController _controller = TextEditingController();
OverlayEntry? _overlayEntry;
bool _showAll = false;
List<T> _filteredItems = [];
@override
@@ -49,15 +48,10 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
}
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_showOverlay();
} else {
_removeOverlay();
// Reset text to selected value if focus lost without selection
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
}
setState(() {
_filteredItems = widget.items;
});
_overlayController.show();
}
});
}
@@ -81,7 +75,6 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
void dispose() {
_focusNode.dispose();
_controller.dispose();
_removeOverlay();
super.dispose();
}
@@ -98,147 +91,164 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
}).toList();
}
});
_overlayEntry?.markNeedsBuild();
}
void _showOverlay() {
_removeOverlay();
_showAll = true;
_filteredItems = widget.items;
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
return OverlayEntry(
builder: (context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(0.0, size.height + 5.0),
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(8.0),
color: Theme.of(context).cardColor,
child: StatefulBuilder(
builder: (context, setOverlayState) {
final displayItems = _showAll ? _filteredItems : (_filteredItems.isNotEmpty ? [_filteredItems.first] : <T>[]);
return Container(
constraints: const BoxConstraints(maxHeight: 250),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (displayItems.isEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text('No matches found'),
if (widget.emptyActionText != null && widget.onEmptyActionPressed != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextButton(
onPressed: () {
_focusNode.unfocus();
widget.onEmptyActionPressed!();
},
child: Text(widget.emptyActionText!),
),
),
],
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: displayItems.length,
itemBuilder: (context, index) {
final item = displayItems[index];
return InkWell(
onTap: () {
widget.onChanged(item);
_controller.text = widget.itemAsString(item);
_focusNode.unfocus();
},
child: widget.itemBuilder != null
? widget.itemBuilder!(context, item)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Text(widget.itemAsString(item)),
),
);
},
),
),
if (!_showAll && _controller.text.isEmpty && widget.items.length > 1)
InkWell(
onTap: () {
setOverlayState(() {
_showAll = true;
});
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.2))),
),
child: const Text(
'Show all',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}
),
),
),
),
);
void _hideOverlay() {
_overlayController.hide();
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
}
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
labelText: widget.labelText,
labelStyle: TextStyle(color: Theme.of(context).brightness == Brightness.dark ? Colors.white70 : Colors.grey[600]),
hintText: widget.hintText,
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey),
filled: Theme.of(context).inputDecorationTheme.filled ?? true,
fillColor: widget.fillColor ?? Theme.of(context).inputDecorationTheme.fillColor ?? (Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100]),
border: Theme.of(context).inputDecorationTheme.border ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
child: TapRegion(
groupId: _layerLink,
child: OverlayPortal(
controller: _overlayController,
overlayChildBuilder: (BuildContext overlayContext) {
final renderBox = context.findRenderObject() as RenderBox?;
final width = (renderBox != null && renderBox.hasSize) ? renderBox.size.width : 300.0;
return CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0.0, 6.0),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width,
child: TapRegion(
groupId: _layerLink,
onTapOutside: (event) {
_focusNode.unfocus();
_hideOverlay();
},
child: Material(
elevation: 8.0,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(16.0),
color: Theme.of(context).cardColor,
clipBehavior: Clip.antiAlias,
child: Container(
constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.15),
),
),
child: _filteredItems.isEmpty
? Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('No matches found', style: TextStyle(color: Colors.grey)),
if (widget.emptyActionText != null && widget.onEmptyActionPressed != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextButton(
onPressed: () {
_focusNode.unfocus();
_overlayController.hide();
widget.onEmptyActionPressed!();
},
child: Text(widget.emptyActionText!),
),
),
],
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _filteredItems.length,
separatorBuilder: (context, i) => Divider(
height: 1,
color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
),
itemBuilder: (context, index) {
final item = _filteredItems[index];
final isSelected = widget.value == item;
return InkWell(
onTap: () {
widget.onChanged(item);
_controller.text = widget.itemAsString(item);
_focusNode.unfocus();
_overlayController.hide();
},
child: Container(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)
: null,
child: widget.itemBuilder != null
? widget.itemBuilder!(context, item)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0),
child: Text(
widget.itemAsString(item),
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
),
),
),
);
},
),
),
),
),
),
),
);
},
child: TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText,
suffixIcon: IconButton(
icon: Icon(
_overlayController.isShowing ? Icons.arrow_drop_up : Icons.arrow_drop_down,
color: Colors.grey,
),
onPressed: () {
if (_overlayController.isShowing) {
_focusNode.unfocus();
_overlayController.hide();
} else {
_focusNode.requestFocus();
_filterItems(_controller.text);
_overlayController.show();
}
},
),
),
onTap: () {
if (!_overlayController.isShowing) {
_filterItems(_controller.text);
_overlayController.show();
}
},
onChanged: (val) {
if (!_overlayController.isShowing) {
_overlayController.show();
}
_filterItems(val);
},
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onChanged: (val) {
_showAll = true; // when user types, we want to show all matching results
_filterItems(val);
},
),
);
}