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:
2026-03-21 13:28:19 +05:30
parent 9dd78be03e
commit bc6fde1b90
21 changed files with 2938 additions and 1285 deletions

View File

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
class AppConstants {
// Layout
// Layout — breakpoints
static const double desktopBreakpoint = 820;
static const double wideDesktopBreakpoint = 1200;
static const double tabletBreakpoint = 600;
// Desktop sidebar
static const double sidebarExpandedWidth = 262;
static const double topBarHeight = 64;
static const double desktopHorizontalPadding = 24;
// Padding & Radius
static const double defaultPadding = 16;
static const double cardRadius = 14;

View File

@@ -9,7 +9,7 @@ class EventsService {
/// Get event types (POST to /events/type-list/)
Future<List<EventTypeModel>> getEventTypes() async {
final res = await _api.post(ApiEndpoints.eventTypes);
final res = await _api.post(ApiEndpoints.eventTypes, requiresAuth: false);
final list = <EventTypeModel>[];
final data = res['event_types'] ?? res['event_types'] ?? res;
if (data is List) {
@@ -27,7 +27,7 @@ class EventsService {
/// Get events filtered by pincode (POST to /events/pincode-events/)
/// Use pincode='all' to fetch all events.
Future<List<EventModel>> getEventsByPincode(String pincode) async {
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode});
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode}, requiresAuth: false);
final list = <EventModel>[];
final events = res['events'] ?? res['data'] ?? [];
if (events is List) {
@@ -40,7 +40,7 @@ class EventsService {
/// Event details
Future<EventModel> getEventDetails(int eventId) async {
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId});
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
return EventModel.fromJson(Map<String, dynamic>.from(res));
}
@@ -48,7 +48,7 @@ class EventsService {
/// Accepts month string and year int.
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year});
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
// expected keys: dates, total_number_of_events, date_events
return res;
}

View File

@@ -6,6 +6,7 @@ import '../features/events/services/events_service.dart';
import '../features/events/models/event_models.dart';
import 'learn_more_screen.dart';
import '../core/app_decoration.dart';
// landscape_section_header no longer needed for this screen
class CalendarScreen extends StatefulWidget {
const CalendarScreen({Key? key}) : super(key: key);
@@ -549,28 +550,261 @@ class _CalendarScreenState extends State<CalendarScreen> {
);
}
// ── Landscape: event card for the right panel ───────────────────────────
Widget _eventCardLandscape(EventModel e) {
final theme = Theme.of(context);
final imgUrl = (e.thumbImg != null && e.thumbImg!.isNotEmpty)
? e.thumbImg!
: (e.images.isNotEmpty ? e.images.first.image : null);
final dateLabel = (e.startDate != null && e.endDate != null && e.startDate == e.endDate)
? '${e.startDate}'
: (e.startDate != null && e.endDate != null
? '${e.startDate} ${e.endDate}'
: (e.startDate ?? ''));
return GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Row(
children: [
// Image
ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(14)),
child: imgUrl != null
? CachedNetworkImage(
imageUrl: imgUrl,
memCacheWidth: 300,
memCacheHeight: 300,
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 100, height: 100, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
width: 100,
height: 100,
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
)
: Container(
width: 100,
height: 100,
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
e.title ?? e.name ?? '',
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Date row with blue dot
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(child: Text(dateLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
const SizedBox(height: 6),
// Venue row with green dot
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF22C55E),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(child: Text(e.place ?? '', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
],
),
),
),
],
),
),
);
}
// ── Landscape: left panel content (calendar on white bg) ─────────────────
Widget _landscapeLeftPanel(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
// Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
"Event's Calendar",
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
),
const SizedBox(height: 12),
// Calendar card — reuses the mobile _calendarCard widget
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
_calendarCard(context),
if (_loadingMonth)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: LinearProgressIndicator(
color: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.primary.withOpacity(0.12),
),
),
],
),
),
),
],
),
);
}
// ── Landscape: right panel (event list for selected day) ────────────────
Widget _landscapeRightPanel(BuildContext context) {
final theme = Theme.of(context);
final dayName = DateFormat('EEEE').format(selectedDate);
final dateFormatted = DateFormat('d MMMM, yyyy').format(selectedDate);
final count = _eventsOfDay.length;
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Date header matching Figma: "Monday, 16 June, 2025 — 2 Events"
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$dayName, $dateFormatted',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'$count ${count == 1 ? "Event" : "Events"}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Divider
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Divider(height: 1, color: theme.dividerColor),
),
const SizedBox(height: 12),
// Scrollable event list
Expanded(
child: _loadingDay
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
: _eventsOfDay.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 56, color: theme.hintColor),
const SizedBox(height: 12),
Text(
'No events on this date',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
],
),
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 4, bottom: 32),
itemCount: _eventsOfDay.length,
itemBuilder: (ctx, i) => _eventCardLandscape(_eventsOfDay[i]),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isMobile = width < 700;
final isLandscape = width >= 820;
final theme = Theme.of(context);
// For non-mobile, keep original split layout
if (!isMobile) {
// ── LANDSCAPE layout ──────────────────────────────────────────────────
if (isLandscape) {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: SafeArea(
child: Row(
children: [
Expanded(flex: 3, child: Padding(padding: const EdgeInsets.all(24.0), child: _calendarCard(context))),
Expanded(flex: 1, child: _detailsPanel()),
],
),
body: Row(
children: [
// Left: Calendar panel with WHITE background (~60%)
Flexible(
flex: 3,
child: RepaintBoundary(
child: Container(
color: theme.cardColor,
child: _landscapeLeftPanel(context),
),
),
),
// Vertical divider between panels
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
// Right: Events panel (~40%)
Flexible(
flex: 2,
child: RepaintBoundary(
child: _landscapeRightPanel(context),
),
),
],
),
);
}
// MOBILE layout
// ── MOBILE layout ─────────────────────────────────────────────────────
// (unchanged from original)
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Stack(
@@ -696,44 +930,4 @@ class _CalendarScreenState extends State<CalendarScreen> {
);
}
Widget _detailsPanel() {
final theme = Theme.of(context);
final shortDate = DateFormat('EEE, d MMM').format(selectedDate);
final eventsCount = _eventsOfDay.length;
Widget _buildHeaderCompact() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: AppDecoration.blueGradientRounded(10),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('${selectedDate.day}', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Text(monthNames[selectedDate.month].substring(0, 3), style: const TextStyle(color: Colors.white70, fontSize: 10))]),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(shortDate, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('$eventsCount ${eventsCount == 1 ? "Event" : "Events"}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor))]),
const Spacer(),
IconButton(onPressed: () {}, icon: Icon(Icons.more_horiz, color: Theme.of(context).iconTheme.color)),
],
),
);
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildHeaderCompact(),
Divider(height: 1, color: theme.dividerColor),
Expanded(
child: _loadingDay
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
: _eventsOfDay.isEmpty
? const SizedBox.shrink()
: ListView.builder(padding: const EdgeInsets.only(top: 6, bottom: 24), itemCount: _eventsOfDay.length, itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx])),
)
]),
);
}
}

View File

@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
import '../core/app_decoration.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
import '../widgets/landscape_section_header.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Tier colour map
@@ -138,156 +139,184 @@ class _ContributeScreenState extends State<ContributeScreen>
static const _desktopTabIcons = [Icons.edit_note, null, null];
Widget _buildDesktopLayout(BuildContext context, GamificationProvider provider) {
return Row(
children: [
Flexible(
flex: 2,
child: RepaintBoundary(
child: Container(
decoration: AppDecoration.blueGradient,
child: _buildContributeLeftPanel(context, provider),
),
),
),
Flexible(
flex: 3,
child: RepaintBoundary(
child: _buildContributeRightPanel(context, provider),
),
),
],
);
}
// ── Landscape left panel: contributor info + vertical nav ───────────────
Widget _buildContributeLeftPanel(BuildContext context, GamificationProvider provider) {
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final lifetimeEp = profile?.lifetimeEp ?? 0;
final currentEp = profile?.currentEp ?? 0;
final currentRp = profile?.currentRp ?? 0;
// Calculate next tier threshold
const thresholds = [0, 100, 500, 1500, 5000];
final tierIdx = tier.index;
final nextThresh = tierIdx < 4 ? thresholds[tierIdx + 1] : thresholds[4];
final prevThresh = thresholds[tierIdx];
final progress = tierIdx >= 4 ? 1.0 : (lifetimeEp - prevThresh) / (nextThresh - prevThresh);
final tierColor = _tierColors[tier] ?? Colors.white;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
const Text('Contributor Dashboard',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: Color(0xFF111827))),
const SizedBox(height: 6),
const Text('Track your impact, earn rewards, and climb the ranks!',
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
const SizedBox(height: 24),
// ── Desktop Tab bar (3 tabs in blue pill) ──
Container(
decoration: BoxDecoration(
color: _primary,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(5),
child: Row(
children: List.generate(_desktopTabs.length, (i) {
final isActive = _activeTab == i;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _activeTab = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == 0) ...[
Icon(Icons.edit_note, size: 18,
color: isActive ? _primary : Colors.white70),
const SizedBox(width: 6),
],
Text(
_desktopTabs[i],
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isActive ? _primary : Colors.white,
),
),
],
),
),
),
);
}),
),
return SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
// Title
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Contributor\nDashboard',
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w800, height: 1.2),
),
const SizedBox(height: 20),
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Track your impact & earn rewards',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
const SizedBox(height: 24),
// ── Contributor Level card ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
// Contributor Level badge
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F45CF), Color(0xFF3B82F6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: tierColor.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Contributor Level',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('Start earning rewards by contributing!',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tierColor.withOpacity(0.6)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(tierLabel(tier),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$lifetimeEp pts',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
if (tierIdx < 4)
Text('Next: ${tierLabel(ContributorTier.values[tierIdx + 1])} (${thresholds[tierIdx + 1]} pts)',
style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
const SizedBox(height: 8),
child: Text(tierLabel(tier), style: TextStyle(color: tierColor, fontWeight: FontWeight.w700, fontSize: 12)),
),
const Spacer(),
Text('$lifetimeEp pts', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14)),
]),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
minHeight: 8,
backgroundColor: Colors.white.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
minHeight: 6,
backgroundColor: Colors.white24,
valueColor: AlwaysStoppedAnimation<Color>(tierColor),
),
),
if (tierIdx < 4) ...[
const SizedBox(height: 6),
Text(
'Next: ${tierLabel(ContributorTier.values[tierIdx + 1])} at ${thresholds[tierIdx + 1]} pts',
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
],
),
),
const SizedBox(height: 20),
),
const SizedBox(height: 24),
// ── Desktop tab body ──
_buildDesktopTabBody(context, provider),
],
),
// Vertical tab navigation
...List.generate(_desktopTabs.length, (i) {
final isActive = _activeTab == i;
final icons = [Icons.edit_note, Icons.leaderboard_outlined, Icons.emoji_events_outlined];
return GestureDetector(
onTap: () => setState(() => _activeTab = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(children: [
Icon(icons[i], size: 20, color: isActive ? _primary : Colors.white70),
const SizedBox(width: 12),
Text(
_desktopTabs[i],
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isActive ? _primary : Colors.white,
),
),
const Spacer(),
if (isActive) Icon(Icons.chevron_right, size: 18, color: _primary),
]),
),
);
}),
const SizedBox(height: 24),
],
),
),
);
}
// ── Landscape right panel: active tab content ────────────────────────────
Widget _buildContributeRightPanel(BuildContext context, GamificationProvider provider) {
String title;
String subtitle;
switch (_activeTab) {
case 1:
title = 'Leaderboard';
subtitle = 'Top contributors this month';
break;
case 2:
title = 'Achievements';
subtitle = 'Your earned badges';
break;
default:
title = 'Submit Event';
subtitle = 'Share events with the community';
}
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LandscapeSectionHeader(title: title, subtitle: subtitle),
Expanded(
child: RepaintBoundary(
child: _buildDesktopTabBody(context, provider),
),
),
],
),
);
}
Widget _buildDesktopTabBody(BuildContext context, GamificationProvider provider) {
switch (_activeTab) {
case 0:

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/auth/auth_guard.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart';
@@ -61,15 +62,15 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
super.dispose();
}
void _startAutoScroll() {
void _startAutoScroll({Duration delay = const Duration(seconds: 5)}) {
_autoScrollTimer?.cancel();
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
_autoScrollTimer = Timer.periodic(delay, (timer) {
if (_heroEvents.isEmpty) return;
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 500),
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
@@ -80,7 +81,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
setState(() => _loading = true);
final prefs = await SharedPreferences.getInstance();
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? '';
_location = prefs.getString('location') ?? 'Whitefield, Bengaluru';
final storedLocation = prefs.getString('location') ?? 'Whitefield, Bengaluru';
// Fix legacy lat,lng strings saved before the reverse-geocoding fix
if (RegExp(r'^-?\d+\.\d+,-?\d+\.\d+$').hasMatch(storedLocation)) {
_location = 'Current Location';
prefs.setString('location', _location);
} else {
_location = storedLocation;
}
_pincode = prefs.getString('pincode') ?? 'all';
try {
@@ -482,7 +490,26 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// Get hero events (first 4 events for the carousel)
List<EventModel> get _heroEvents => _events.take(4).toList();
List<EventModel> get _heroEvents => _events.take(6).toList();
String _formatDate(String dateStr) {
try {
final dt = DateTime.parse(dateStr);
return DateFormat('d MMM yyyy').format(dt);
} catch (_) {
return dateStr;
}
}
String _getEventTypeName(EventModel event) {
if (event.eventTypeId != null && event.eventTypeId! > 0) {
final match = _types.where((t) => t.id == event.eventTypeId);
if (match.isNotEmpty && match.first.name.isNotEmpty) {
return match.first.name.toUpperCase();
}
}
return 'EVENT';
}
// Date filter state
String _selectedDateFilter = '';
@@ -1131,42 +1158,51 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// Featured carousel
_heroEvents.isEmpty
? SizedBox(
height: 280,
child: Center(
child: _loading
? const CircularProgressIndicator(color: Colors.white)
: const Text('No events available', style: TextStyle(color: Colors.white70)),
),
)
? _loading
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 320,
child: _HeroShimmer(),
),
)
: const SizedBox(
height: 280,
child: Center(
child: Text('No events available',
style: TextStyle(color: Colors.white70)),
),
)
: Column(
children: [
SizedBox(
height: 320,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) {
_heroPageNotifier.value = page;
// Reset 3-second countdown so user always gets full read time
_startAutoScroll();
},
itemCount: _heroEvents.length,
itemBuilder: (context, index) {
// Scale animation: active card = 1.0, adjacent = 0.94
return AnimatedBuilder(
animation: _heroPageController,
builder: (context, child) {
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
if (_heroPageController.position.haveDimensions) {
scale = (1.0 -
(_heroPageController.page! - index).abs() * 0.06)
.clamp(0.94, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildHeroEventImage(_heroEvents[index]),
);
},
RepaintBoundary(
child: SizedBox(
height: 320,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) {
_heroPageNotifier.value = page;
// 8s delay after manual swipe for full read time
_startAutoScroll(delay: const Duration(seconds: 8));
},
itemCount: _heroEvents.length,
itemBuilder: (context, index) {
// Scale animation: active card = 1.0, adjacent = 0.94
return AnimatedBuilder(
animation: _heroPageController,
builder: (context, child) {
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
if (_heroPageController.position.haveDimensions) {
scale = (1.0 -
(_heroPageController.page! - index).abs() * 0.06)
.clamp(0.94, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildHeroEventImage(_heroEvents[index]),
);
},
),
),
),
const SizedBox(height: 16),
@@ -1185,7 +1221,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
valueListenable: _heroPageNotifier,
builder: (context, currentPage, _) {
return SizedBox(
height: 12,
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
@@ -1199,13 +1235,21 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.4),
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? Colors.white
: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
),
),
),
);
@@ -1247,7 +1291,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 800,
memCacheWidth: 700,
fit: BoxFit.cover,
placeholder: (_, __) => const _HeroShimmer(radius: radius),
errorWidget: (_, __, ___) =>
@@ -1272,7 +1316,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
),
),
// ── Layer 2: FEATURED glassmorphism badge (top-left) ──
// ── Layer 2: Event type glassmorphism badge (top-left) ──
Positioned(
top: 14,
left: 14,
@@ -1283,18 +1327,18 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.18),
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.28)),
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, color: Colors.amber, size: 13),
SizedBox(width: 4),
const Icon(Icons.star_rounded, color: Colors.amber, size: 13),
const SizedBox(width: 4),
Text(
'FEATURED',
style: TextStyle(
_getEventTypeName(event),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
@@ -1339,7 +1383,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Text(
event.startDate!,
_formatDate(event.startDate!),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
@@ -2198,7 +2242,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
/// Renders a blue-toned scan-line effect matching the app's colour palette.
class _HeroShimmer extends StatefulWidget {
final double radius;
const _HeroShimmer({required this.radius});
const _HeroShimmer({this.radius = 24.0});
@override
State<_HeroShimmer> createState() => _HeroShimmerState();

View File

@@ -11,6 +11,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart';
import '../core/auth/auth_guard.dart';
import '../core/constants.dart';
class LearnMoreScreen extends StatefulWidget {
final int eventId;
@@ -227,10 +228,279 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
}
final mediaQuery = MediaQuery.of(context);
final screenWidth = mediaQuery.size.width;
final screenHeight = mediaQuery.size.height;
final imageHeight = screenHeight * 0.45;
final topPadding = mediaQuery.padding.top;
// ── DESKTOP layout ──────────────────────────────────────────────────
if (screenWidth >= AppConstants.desktopBreakpoint) {
final images = _imageUrls;
final heroImage = images.isNotEmpty ? images[0] : null;
final venueLabel = _event!.locationName ?? _event!.venueName ?? _event!.place ?? '';
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Hero image with gradient overlay ──
SizedBox(
width: double.infinity,
height: 300,
child: Stack(
fit: StackFit.expand,
children: [
// Background image
if (heroImage != null)
CachedNetworkImage(
imageUrl: heroImage,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
errorWidget: (_, __, ___) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
)
else
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.65),
],
),
),
),
// Top bar: back + share + wishlist
Positioned(
top: topPadding + 10,
left: 16,
right: 16,
child: Row(
children: [
_squareIconButton(icon: Icons.arrow_back, onTap: () => Navigator.pop(context)),
const SizedBox(width: 8),
_squareIconButton(icon: Icons.ios_share_outlined, onTap: _shareEvent),
const SizedBox(width: 8),
_squareIconButton(
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
onTap: () {
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
setState(() => _wishlisted = !_wishlisted);
},
),
],
),
),
// Title + date + venue overlaid at bottom-left
Positioned(
left: 32,
bottom: 28,
right: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_event!.title ?? _event!.name,
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 28,
height: 1.2,
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.calendar_today_outlined, size: 16, color: Colors.white70),
const SizedBox(width: 6),
Text(
_formattedDateRange(),
style: const TextStyle(color: Colors.white70, fontSize: 15),
),
if (venueLabel.isNotEmpty) ...[
const SizedBox(width: 16),
const Icon(Icons.location_on_outlined, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Flexible(
child: Text(
venueLabel,
style: const TextStyle(color: Colors.white70, fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
),
),
// "Book Your Spot" CTA on the right
Positioned(
right: 32,
bottom: 36,
child: ElevatedButton(
onPressed: () {
// TODO: implement booking action
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: const Text(
'Book Your Spot',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
],
),
),
const SizedBox(height: 28),
// ── Two-column: About (left 60%) + Venue/Map (right 40%) ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left column — About the Event
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutSection(theme),
if (_event!.importantInfo.isNotEmpty)
_buildImportantInfoSection(theme),
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
],
),
),
const SizedBox(width: 32),
// Right column — Venue / map
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_event!.latitude != null && _event!.longitude != null) ...[
_buildVenueSection(theme),
const SizedBox(height: 12),
_buildGetDirectionsButton(theme),
],
],
),
),
],
),
),
// ── Gallery: horizontal scrollable image strip ──
if (images.length > 1) ...[
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
'Gallery',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 20,
),
),
),
const SizedBox(height: 14),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 32),
itemCount: images.length > 6 ? 6 : images.length,
itemBuilder: (context, i) {
// Show overflow count badge on last visible item
final isLast = i == 5 && images.length > 6;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 220,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: images[i],
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.broken_image, color: theme.hintColor),
),
),
if (isLast)
Container(
color: Colors.black.withOpacity(0.55),
alignment: Alignment.center,
child: Text(
'+${images.length - 6}',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
},
),
),
],
const SizedBox(height: 80),
],
),
),
);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Stack(

View File

@@ -12,6 +12,7 @@ import 'learn_more_screen.dart';
import 'settings_screen.dart';
import '../core/app_decoration.dart';
import '../core/constants.dart';
import '../widgets/landscape_section_header.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({Key? key}) : super(key: key);
@@ -1013,6 +1014,534 @@ class _ProfileScreenState extends State<ProfileScreen>
);
}
// ═══════════════════════════════════════════════
// LANDSCAPE LAYOUT
// ═══════════════════════════════════════════════
Widget _buildLandscapeLeftPanel(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Top bar row — title + settings
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(
children: [
const Text(
'Profile',
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700),
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SettingsScreen())),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.settings, color: Colors.white),
),
),
],
),
),
const SizedBox(height: 20),
// Avatar + name section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 8, offset: const Offset(0, 2))],
),
child: _buildProfileAvatar(size: 64),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_username.isNotEmpty ? _username : 'Guest User',
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_email,
style: const TextStyle(color: Colors.white70, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
const SizedBox(height: 20),
// EXP Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _buildExpBar(),
),
const SizedBox(height: 20),
// Stats row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 16),
child: _buildLandscapeStats(context, textColor: Colors.white),
),
),
const SizedBox(height: 20),
// Edit profile button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: OutlinedButton.icon(
onPressed: _openEditDialog,
icon: const Icon(Icons.edit, size: 16, color: Colors.white),
label: const Text('Edit Profile', style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white38),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildLandscapeStats(BuildContext context, {Color? textColor}) {
final color = textColor ?? Theme.of(context).textTheme.bodyLarge?.color;
final hintColor = textColor?.withOpacity(0.6) ?? Theme.of(context).hintColor;
String fmt(int v) => v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}K' : '$v';
return AnimatedBuilder(
animation: _animController,
builder: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_landscapeStatItem(fmt(_animatedLikes), 'Likes', color, hintColor),
_landscapeStatDivider(),
_landscapeStatItem(fmt(_animatedPosts), 'Posts', color, hintColor),
_landscapeStatDivider(),
_landscapeStatItem(fmt(_animatedViews), 'Views', color, hintColor),
],
),
);
}
Widget _landscapeStatItem(String value, String label, Color? valueColor, Color? labelColor) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: valueColor)),
const SizedBox(height: 2),
Text(label, style: TextStyle(fontSize: 12, color: labelColor, fontWeight: FontWeight.w400)),
],
);
}
Widget _landscapeStatDivider() => Container(width: 1, height: 36, color: Colors.white24);
Widget _buildLandscapeRightPanel(BuildContext context) {
final theme = Theme.of(context);
Widget _eventList(List<EventModel> events, {bool faded = false}) {
if (_loadingEvents) {
return const Center(child: CircularProgressIndicator());
}
if (events.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('No events', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
),
);
}
return ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 32),
itemCount: events.length,
itemBuilder: (ctx, i) => _eventListTileFromModel(events[i], faded: faded),
);
}
return SafeArea(
child: DefaultTabController(
length: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const LandscapeSectionHeader(title: 'My Events'),
// Tab bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Container(
decoration: BoxDecoration(
color: theme.dividerColor.withOpacity(0.5),
borderRadius: BorderRadius.circular(10),
),
child: TabBar(
labelColor: Colors.white,
unselectedLabelColor: theme.hintColor,
indicator: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
tabs: const [
Tab(text: 'Ongoing'),
Tab(text: 'Upcoming'),
Tab(text: 'Past'),
],
),
),
),
Expanded(
child: TabBarView(
children: [
_eventList(_ongoingEvents),
_eventList(_upcomingEvents),
_eventList(_pastEvents, faded: true),
],
),
),
],
),
),
);
}
// ═══════════════════════════════════════════════
// DESKTOP LAYOUT (Figma: full-width banner + 3-col grids)
// ═══════════════════════════════════════════════
Widget _buildDesktopLayout(BuildContext context, ThemeData theme) {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full-width profile header + card (reuse existing widgets)
Stack(
children: [
_buildGradientHeader(context, 200),
Padding(
padding: const EdgeInsets.only(top: 130),
child: _buildProfileCard(context),
),
],
),
const SizedBox(height: 24),
// Ongoing Events (only if non-empty)
if (_ongoingEvents.isNotEmpty)
_buildDesktopEventSection(
context,
title: 'Ongoing Events',
events: _ongoingEvents,
faded: false,
),
// Upcoming Events
_buildDesktopEventSection(
context,
title: 'Upcoming Events',
events: _upcomingEvents,
faded: false,
emptyMessage: 'No upcoming events',
),
// Past Events
_buildDesktopEventSection(
context,
title: 'Past Events',
events: _pastEvents,
faded: true,
emptyMessage: 'No past events',
),
const SizedBox(height: 32),
],
),
),
);
}
/// Section heading row ("Title" + "View All >") followed by a 3-column grid.
Widget _buildDesktopEventSection(
BuildContext context, {
required String title,
required List<EventModel> events,
bool faded = false,
String? emptyMessage,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading row
Row(
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 20,
),
),
const Spacer(),
if (events.isNotEmpty)
TextButton(
onPressed: () {
// View all — no-op for now; could navigate to a full list
},
child: Text(
'View All >',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 12),
// Content
if (_loadingEvents)
const Center(child: CircularProgressIndicator())
else if (events.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
emptyMessage ?? 'No events',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.82,
),
itemCount: events.length,
itemBuilder: (ctx, i) =>
_buildDesktopEventGridCard(events[i], faded: faded),
),
const SizedBox(height: 24),
],
),
);
}
/// A single event card for the desktop grid: image on top, title, date (blue dot), venue (green dot).
Widget _buildDesktopEventGridCard(EventModel ev, {bool faded = false}) {
final theme = Theme.of(context);
final title = ev.title ?? ev.name ?? '';
final dateLabel =
(ev.startDate != null && ev.endDate != null && ev.startDate == ev.endDate)
? ev.startDate!
: ((ev.startDate != null && ev.endDate != null)
? '${ev.startDate} - ${ev.endDate}'
: (ev.startDate ?? ''));
final location = ev.place ?? '';
final imageUrl = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty)
? ev.thumbImg!
: (ev.images.isNotEmpty ? ev.images.first.image : null);
final titleColor = faded ? theme.hintColor : (theme.textTheme.bodyLarge?.color);
final subtitleColor = faded
? theme.hintColor.withValues(alpha: 0.7)
: theme.hintColor;
return GestureDetector(
onTap: () {
if (ev.id != null) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id)),
);
}
},
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
Expanded(
flex: 3,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
child: _buildCardImage(imageUrl, theme),
),
),
// Text content
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: titleColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
// Date row with blue dot
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
// Venue row with green dot
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF22C55E),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
location,
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
],
),
),
);
}
/// Helper to build the image widget for a desktop grid card.
Widget _buildCardImage(String? imageUrl, ThemeData theme) {
if (imageUrl != null && imageUrl.trim().isNotEmpty) {
if (imageUrl.startsWith('http')) {
return CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 400,
memCacheHeight: 400,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
if (!kIsWeb) {
final path = imageUrl;
if (path.startsWith('/') || path.contains(Platform.pathSeparator)) {
final file = File(path);
if (file.existsSync()) {
return Image.file(
file,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
}
}
return Image.asset(
imageUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
return Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
);
}
// ═══════════════════════════════════════════════
// BUILD
// ═══════════════════════════════════════════════
@@ -1022,6 +1551,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final theme = Theme.of(context);
const double headerHeight = 200.0;
const double cardTopOffset = 130.0;
final width = MediaQuery.of(context).size.width;
Widget sectionTitle(String text) => Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
@@ -1032,6 +1562,12 @@ class _ProfileScreenState extends State<ProfileScreen>
),
);
// ── DESKTOP / LANDSCAPE layout ─────────────────────────────────────────
if (width >= AppConstants.desktopBreakpoint) {
return _buildDesktopLayout(context, theme);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
// CustomScrollView: only visible event cards are built — no full-tree Column renders

View File

@@ -149,7 +149,7 @@ class _SearchScreenState extends State<SearchScreen> {
}
} catch (_) {}
if (mounted) Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
if (mounted) Navigator.of(context).pop('Current Location');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));

View File

@@ -3,7 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'login_screen.dart';
import 'desktop_login_screen.dart';
import '../core/theme_manager.dart';
import 'privacy_policy_screen.dart'; // new import
import 'privacy_policy_screen.dart';
import '../core/app_decoration.dart';
class SettingsScreen extends StatefulWidget {
@@ -15,7 +15,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true;
String _appVersion = '1.2(p)';
String _appVersion = '1.6(p)';
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
@override
void initState() {
@@ -100,16 +101,209 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
// ── Settings content sections ────────────────────────────────────────────
Widget _buildPreferencesSection() {
const primary = Color(0xFF0B63D6);
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: _notifications,
onChanged: (v) => _saveNotifications(v),
title: const Text('Reminders'),
secondary: const Icon(Icons.notifications, color: primary),
),
),
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) {
return SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: mode == ThemeMode.dark,
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
title: const Text('Dark Mode'),
secondary: const Icon(Icons.dark_mode, color: primary),
);
},
),
),
],
),
);
}
Widget _buildAccountSection() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTile(
icon: Icons.person,
title: 'Edit Profile',
subtitle: 'Change username, email or photo',
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab'))),
),
const SizedBox(height: 24),
Center(
child: ElevatedButton(
onPressed: _confirmLogout,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade600,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
child: const Text('Logout', style: TextStyle(color: Colors.white)),
),
),
],
),
);
}
Widget _buildAboutSection() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
const SizedBox(height: 12),
_buildTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
),
],
),
);
}
Widget _buildActiveSection() {
switch (_selectedSection) {
case 1: return _buildAccountSection();
case 2: return _buildAboutSection();
default: return _buildPreferencesSection();
}
}
@override
Widget build(BuildContext context) {
const primary = Color(0xFF0B63D6);
final width = MediaQuery.of(context).size.width;
final isLandscape = width >= 820;
// ── LANDSCAPE layout ──────────────────────────────────────────────────
if (isLandscape) {
const navIcons = [Icons.tune, Icons.person_outline, Icons.info_outline];
const navLabels = ['Preferences', 'Account', 'About'];
return Row(
children: [
// Left: settings nav on gradient
Flexible(
flex: 1,
child: RepaintBoundary(
child: Container(
decoration: AppDecoration.blueGradient,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 24, 20, 20),
child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700)),
),
...List.generate(navLabels.length, (i) {
final isActive = _selectedSection == i;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => setState(() => _selectedSection = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(children: [
Icon(navIcons[i], size: 20, color: isActive ? primary : Colors.white70),
const SizedBox(width: 12),
Text(navLabels[i], style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: isActive ? primary : Colors.white)),
]),
),
),
);
}),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: OutlinedButton.icon(
onPressed: _confirmLogout,
icon: const Icon(Icons.logout, color: Colors.white70, size: 18),
label: const Text('Logout', style: TextStyle(color: Colors.white70)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
),
),
// Right: settings content
Flexible(
flex: 2,
child: RepaintBoundary(
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
child: Text(
navLabels[_selectedSection],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
Expanded(child: _buildActiveSection()),
],
),
),
),
),
],
);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
@@ -131,41 +325,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
const SizedBox(height: 18),
// Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Account
const Text('Account', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildTile(
icon: Icons.person,
title: 'Edit Profile',
subtitle: 'Change username, email or photo',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)')));
},
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
),
const SizedBox(height: 12),
// Preferences
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
// Reminders switch wrapped in card-like container
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -175,54 +355,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
secondary: const Icon(Icons.notifications, color: primary),
),
),
const SizedBox(height: 8),
// Dark Mode switch wrapped in card-like container and hooked to ThemeManager
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) {
final isDark = mode == ThemeMode.dark;
return SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: isDark,
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
title: const Text('Dark Mode'),
secondary: const Icon(Icons.dark_mode, color: primary),
);
},
builder: (context, mode, _) => SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: mode == ThemeMode.dark,
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
title: const Text('Dark Mode'),
secondary: const Icon(Icons.dark_mode, color: primary),
),
),
),
const SizedBox(height: 18),
// About
const Text('About', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
const SizedBox(height: 12),
// Privacy Policy tile now navigates to PrivacyPolicyScreen
_buildTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
subtitle: 'Demo app',
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen()));
},
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
),
const SizedBox(height: 24),
// Logout area
Center(
child: Column(
children: [
@@ -240,7 +400,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
const SizedBox(height: 32),
],
),

View 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);
}

View 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,
),
),
);
}
}

View 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!,
],
),
);
}
}

View 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,
),
),
),
],
);
}
}

View 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),
),
],
),
),
],
),
);
});
}
}