feat: rebuild desktop UI to match Figma + website, hero slider improvements
- Desktop sidebar (262px, blue gradient, white pill nav), topbar (search + bell + avatar), responsive shell rewritten - Desktop homepage: immersive hero with Ken Burns animation, pill category chips, date badge cards matching mvnew.eventifyplus.com/home - Desktop calendar: 60/40 two-column layout with white background - Desktop profile: full-width banner + 3-column event grids - Desktop learn more: hero image + about/venue columns + gallery strip - Desktop settings/contribute: polished to match design system - Mobile hero slider: RepaintBoundary, animated dots with 44px tap targets, 5s auto-scroll, 8s post-swipe delay, shimmer loading, dynamic event type badge, human-readable dates - Guest access: requiresAuth false on read endpoints - Location fix: show place names instead of lat/lng coordinates - Version 1.6.1+17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
141
lib/widgets/desktop_sidebar.dart
Normal file
141
lib/widgets/desktop_sidebar.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/app_decoration.dart';
|
||||
import '../core/constants.dart';
|
||||
|
||||
class DesktopSidebar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onIndexChanged;
|
||||
|
||||
const DesktopSidebar({
|
||||
Key? key,
|
||||
required this.selectedIndex,
|
||||
required this.onIndexChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
static const _navItems = <_NavDef>[
|
||||
_NavDef(Icons.home_outlined, Icons.home, 'Home', 0),
|
||||
_NavDef(Icons.calendar_today_outlined, Icons.calendar_today, 'Calendar', 1),
|
||||
_NavDef(Icons.person_outline, Icons.person, 'Profile', 2),
|
||||
];
|
||||
|
||||
static const _bottomItems = <_NavDef>[
|
||||
_NavDef(Icons.settings_outlined, Icons.settings, 'Settings', 5),
|
||||
_NavDef(Icons.help_outline, Icons.help, 'Help', -1),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RepaintBoundary(
|
||||
child: Container(
|
||||
width: AppConstants.sidebarExpandedWidth,
|
||||
decoration: AppDecoration.blueGradient,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Logo
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24, top: 20, right: 24),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.auto_awesome,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'EVENTIFY',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Main nav items
|
||||
Column(
|
||||
children: _navItems
|
||||
.map((item) => _buildNavItem(item))
|
||||
.toList(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Bottom nav items
|
||||
Column(
|
||||
children: _bottomItems
|
||||
.map((item) => _buildNavItem(item))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(_NavDef item) {
|
||||
final selected = selectedIndex == item.index;
|
||||
final icon = selected ? item.activeIcon : item.icon;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: () => onIndexChanged(item.index),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 48,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: selected
|
||||
? const Color(0xFF0F45CF)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: selected
|
||||
? const Color(0xFF0F45CF)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavDef {
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String label;
|
||||
final int index;
|
||||
const _NavDef(this.icon, this.activeIcon, this.label, this.index);
|
||||
}
|
||||
142
lib/widgets/desktop_topbar.dart
Normal file
142
lib/widgets/desktop_topbar.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopTopBar extends StatelessWidget {
|
||||
final String username;
|
||||
final String? profileImage;
|
||||
final VoidCallback? onSearchTap;
|
||||
final VoidCallback? onNotificationTap;
|
||||
final VoidCallback? onAvatarTap;
|
||||
|
||||
const DesktopTopBar({
|
||||
Key? key,
|
||||
required this.username,
|
||||
this.profileImage,
|
||||
this.onSearchTap,
|
||||
this.onNotificationTap,
|
||||
this.onAvatarTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left: search bar
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
onTap: onSearchTap,
|
||||
readOnly: onSearchTap != null,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
prefixIcon: Icon(Icons.search, color: theme.hintColor),
|
||||
hintText: 'Search',
|
||||
hintStyle: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.hintColor),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Right: notification bell + avatar
|
||||
Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onNotificationTap,
|
||||
icon: Icon(
|
||||
Icons.notifications_none,
|
||||
color: theme.iconTheme.color,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 6,
|
||||
child: CircleAvatar(
|
||||
radius: 8,
|
||||
backgroundColor: Colors.red,
|
||||
child: Text(
|
||||
'2',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onAvatarTap,
|
||||
child: _buildAvatar(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
if (profileImage != null && profileImage!.trim().isNotEmpty) {
|
||||
final url = profileImage!.trim();
|
||||
if (url.startsWith('http')) {
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
backgroundImage: NetworkImage(url),
|
||||
onBackgroundImageError: (_, __) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final name = username.trim();
|
||||
String initials = 'U';
|
||||
if (name.isNotEmpty) {
|
||||
if (name.contains('@')) {
|
||||
initials = name[0].toUpperCase();
|
||||
} else {
|
||||
final parts = name.split(' ').where((p) => p.isNotEmpty).toList();
|
||||
initials = parts.isEmpty
|
||||
? 'U'
|
||||
: parts.take(2).map((p) => p[0].toUpperCase()).join();
|
||||
}
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.blue.shade600,
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/widgets/landscape_section_header.dart
Normal file
58
lib/widgets/landscape_section_header.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
// lib/widgets/landscape_section_header.dart
|
||||
//
|
||||
// Consistent section header for the right panel of landscape layouts.
|
||||
// Shows a title, optional subtitle, and optional trailing action widget.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LandscapeSectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const LandscapeSectionHeader({
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/widgets/landscape_shell.dart
Normal file
67
lib/widgets/landscape_shell.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
// lib/widgets/landscape_shell.dart
|
||||
//
|
||||
// Reusable two-panel landscape scaffold for all desktop/wide screens.
|
||||
// Left panel uses the brand dark-blue gradient; right panel is the content area.
|
||||
//
|
||||
// Usage:
|
||||
// LandscapeShell(
|
||||
// leftPanel: MyLeftContent(),
|
||||
// rightPanel: MyRightContent(),
|
||||
// )
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/app_decoration.dart';
|
||||
|
||||
class LandscapeShell extends StatelessWidget {
|
||||
final Widget leftPanel;
|
||||
final Widget rightPanel;
|
||||
|
||||
/// Flex weight for left panel (default 2 → ~40% of width)
|
||||
final int leftFlex;
|
||||
|
||||
/// Flex weight for right panel (default 3 → ~60% of width)
|
||||
final int rightFlex;
|
||||
|
||||
/// Optional background color for right panel (defaults to scaffold background)
|
||||
final Color? rightBackground;
|
||||
|
||||
const LandscapeShell({
|
||||
Key? key,
|
||||
required this.leftPanel,
|
||||
required this.rightPanel,
|
||||
this.leftFlex = 2,
|
||||
this.rightFlex = 3,
|
||||
this.rightBackground,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = rightBackground ?? Theme.of(context).scaffoldBackgroundColor;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Left panel — dark blue gradient ──────────────────────────────
|
||||
Flexible(
|
||||
flex: leftFlex,
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
decoration: AppDecoration.blueGradient,
|
||||
child: leftPanel,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Right panel — content area ────────────────────────────────────
|
||||
Flexible(
|
||||
flex: rightFlex,
|
||||
child: RepaintBoundary(
|
||||
child: ColoredBox(
|
||||
color: bg,
|
||||
child: rightPanel,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/widgets/responsive_shell.dart
Normal file
84
lib/widgets/responsive_shell.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../core/constants.dart';
|
||||
import 'desktop_sidebar.dart';
|
||||
import 'desktop_topbar.dart';
|
||||
|
||||
class ResponsiveShell extends StatefulWidget {
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onIndexChanged;
|
||||
final Widget child;
|
||||
final bool showTopBar;
|
||||
|
||||
const ResponsiveShell({
|
||||
Key? key,
|
||||
required this.currentIndex,
|
||||
required this.onIndexChanged,
|
||||
required this.child,
|
||||
this.showTopBar = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ResponsiveShell> createState() => _ResponsiveShellState();
|
||||
}
|
||||
|
||||
class _ResponsiveShellState extends State<ResponsiveShell> {
|
||||
String _username = 'Guest';
|
||||
String? _profileImage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_username = prefs.getString('display_name') ??
|
||||
prefs.getString('username') ??
|
||||
'Guest';
|
||||
_profileImage = prefs.getString('profileImage');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
|
||||
// Mobile — no shell
|
||||
if (width < AppConstants.desktopBreakpoint) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
DesktopSidebar(
|
||||
selectedIndex: widget.currentIndex,
|
||||
onIndexChanged: widget.onIndexChanged,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.showTopBar)
|
||||
DesktopTopBar(
|
||||
username: _username,
|
||||
profileImage: _profileImage,
|
||||
onAvatarTap: () => widget.onIndexChanged(2),
|
||||
),
|
||||
Expanded(
|
||||
child: RepaintBoundary(child: widget.child),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user