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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
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