feat: Phase 3 — 26 medium-priority gaps implemented

P3-A/K  Profile: Eventify ID glassmorphic badge (tap-to-copy), DiceBear
        Notionists avatar via TierAvatarRing, district picker (14 pills)
        with 183-day cooldown lock, multipart photo upload to server
P3-B    Home: Top Events converted to PageView scroll-snap
        (viewportFraction 0.9 + PageScrollPhysics)
P3-C    Event detail: contributor widget (tier ring + name + navigation),
        related events horizontal row; added getEventsByCategory() to
        EventsService; added contributorId/Name/Tier fields to EventModel
P3-D    Kerala pincodes: 463-entry JSON (all 14 districts), registered as
        asset, async-loaded in SearchScreen replacing hardcoded 32 cities
P3-E    Checkout: promo code field + Apply/Remove button in Step 2,
        discountAmount subtracted from total, applyPromo()/resetPromo()
        methods in CheckoutProvider
P3-F/G  Gamification: reward cycle countdown + EP→RP progress bar (blue→
        amber) in contribute + profile screens; TierAvatarRing in podium
        and all leaderboard rows; GlassCard current-user stats card at
        top of leaderboard tab
P3-H    New ContributorProfileScreen: tier ring, stats, submission grid
        with status chips; getDashboardForUser() in GamificationService;
        wired from leaderboard row taps
P3-I    Achievements: 11 default badges (up from 6), 6 new icon map
        entries; progress % labels already confirmed present
P3-J    Reviews: CustomPainter circular arc rating ring (amber, 84px)
        replaces large rating number in ReviewSummary
P3-L    Share rank card: RepaintBoundary → PNG capture → Share.shareXFiles;
        share button wired in profile header and leaderboard tab
P3-M    SafeArea audit: home bottom nav, contribute/achievements scroll
        padding, profile CustomScrollView top inset

New files: tier_avatar_ring.dart, glass_card.dart,
  eventify_bottom_sheet.dart, contributor_profile_screen.dart,
  share_rank_card.dart, assets/data/kerala_pincodes.json
New dep:   path_provider ^2.1.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 17:17:36 +05:30
parent fe8af7cfe6
commit 632754415d
19 changed files with 2346 additions and 183 deletions

View File

@@ -0,0 +1,465 @@
[
{"city": "Thiruvananthapuram", "district": "Thiruvananthapuram", "pincode": "695001"},
{"city": "Kazhakoottam", "district": "Thiruvananthapuram", "pincode": "695582"},
{"city": "Technopark", "district": "Thiruvananthapuram", "pincode": "695581"},
{"city": "Varkala", "district": "Thiruvananthapuram", "pincode": "695141"},
{"city": "Attingal", "district": "Thiruvananthapuram", "pincode": "695101"},
{"city": "Neyyattinkara", "district": "Thiruvananthapuram", "pincode": "695121"},
{"city": "Nedumangad", "district": "Thiruvananthapuram", "pincode": "695541"},
{"city": "Pothencode", "district": "Thiruvananthapuram", "pincode": "695584"},
{"city": "Peroorkada", "district": "Thiruvananthapuram", "pincode": "695005"},
{"city": "Kazhakkoottam", "district": "Thiruvananthapuram", "pincode": "695582"},
{"city": "Nemom", "district": "Thiruvananthapuram", "pincode": "695020"},
{"city": "Kaniyapuram", "district": "Thiruvananthapuram", "pincode": "695301"},
{"city": "Balaramapuram", "district": "Thiruvananthapuram", "pincode": "695501"},
{"city": "Kallambalam", "district": "Thiruvananthapuram", "pincode": "695605"},
{"city": "Chirayinkeezhu", "district": "Thiruvananthapuram", "pincode": "695304"},
{"city": "Venjaramoodu", "district": "Thiruvananthapuram", "pincode": "695607"},
{"city": "Kattakada", "district": "Thiruvananthapuram", "pincode": "695572"},
{"city": "Aryanad", "district": "Thiruvananthapuram", "pincode": "695542"},
{"city": "Vellanad", "district": "Thiruvananthapuram", "pincode": "695543"},
{"city": "Parassala", "district": "Thiruvananthapuram", "pincode": "695502"},
{"city": "Kollam", "district": "Kollam", "pincode": "691001"},
{"city": "Karunagappally", "district": "Kollam", "pincode": "690518"},
{"city": "Kundara", "district": "Kollam", "pincode": "691501"},
{"city": "Punalur", "district": "Kollam", "pincode": "691305"},
{"city": "Kottarakkara", "district": "Kollam", "pincode": "691506"},
{"city": "Paravur", "district": "Kollam", "pincode": "691301"},
{"city": "Chavara", "district": "Kollam", "pincode": "691583"},
{"city": "Pathanapuram", "district": "Kollam", "pincode": "689695"},
{"city": "Ezhukone", "district": "Kollam", "pincode": "691505"},
{"city": "Anchal", "district": "Kollam", "pincode": "691306"},
{"city": "Kottamkara", "district": "Kollam", "pincode": "691007"},
{"city": "Perinad", "district": "Kollam", "pincode": "691601"},
{"city": "Kulathupuzha", "district": "Kollam", "pincode": "691310"},
{"city": "Ittiva", "district": "Kollam", "pincode": "691311"},
{"city": "Mynagappally", "district": "Kollam", "pincode": "690503"},
{"city": "Pathanamthitta", "district": "Pathanamthitta", "pincode": "689645"},
{"city": "Adoor", "district": "Pathanamthitta", "pincode": "691523"},
{"city": "Thiruvalla", "district": "Pathanamthitta", "pincode": "689101"},
{"city": "Pandalam", "district": "Pathanamthitta", "pincode": "689501"},
{"city": "Ranni", "district": "Pathanamthitta", "pincode": "689673"},
{"city": "Kozhencherry", "district": "Pathanamthitta", "pincode": "689641"},
{"city": "Konni", "district": "Pathanamthitta", "pincode": "689691"},
{"city": "Mallapally", "district": "Pathanamthitta", "pincode": "686501"},
{"city": "Tiruvalla", "district": "Pathanamthitta", "pincode": "689101"},
{"city": "Aranmula", "district": "Pathanamthitta", "pincode": "689533"},
{"city": "Angamaly", "district": "Pathanamthitta", "pincode": "686540"},
{"city": "Alappuzha", "district": "Alappuzha", "pincode": "688001"},
{"city": "Cherthala", "district": "Alappuzha", "pincode": "688524"},
{"city": "Kayamkulam", "district": "Alappuzha", "pincode": "690502"},
{"city": "Mavelikkara", "district": "Alappuzha", "pincode": "690101"},
{"city": "Haripad", "district": "Alappuzha", "pincode": "690514"},
{"city": "Mararikulam", "district": "Alappuzha", "pincode": "688523"},
{"city": "Ambalapuzha", "district": "Alappuzha", "pincode": "688561"},
{"city": "Kuttanad", "district": "Alappuzha", "pincode": "688501"},
{"city": "Mannar", "district": "Alappuzha", "pincode": "689622"},
{"city": "Chengannur", "district": "Alappuzha", "pincode": "689121"},
{"city": "Purakkad", "district": "Alappuzha", "pincode": "688551"},
{"city": "Aroor", "district": "Alappuzha", "pincode": "688534"},
{"city": "Kottayam", "district": "Kottayam", "pincode": "686001"},
{"city": "Changanacherry", "district": "Kottayam", "pincode": "686101"},
{"city": "Pala", "district": "Kottayam", "pincode": "686575"},
{"city": "Ettumanoor", "district": "Kottayam", "pincode": "686631"},
{"city": "Vaikom", "district": "Kottayam", "pincode": "686141"},
{"city": "Kanjirappally", "district": "Kottayam", "pincode": "686507"},
{"city": "Erattupetta", "district": "Kottayam", "pincode": "686121"},
{"city": "Ponkunnam", "district": "Kottayam", "pincode": "686506"},
{"city": "Karukachal", "district": "Kottayam", "pincode": "686540"},
{"city": "Kaduthuruthy", "district": "Kottayam", "pincode": "686604"},
{"city": "Meenachil", "district": "Kottayam", "pincode": "686516"},
{"city": "Thodupuzha", "district": "Idukki", "pincode": "685584"},
{"city": "Idukki", "district": "Idukki", "pincode": "685602"},
{"city": "Munnar", "district": "Idukki", "pincode": "685612"},
{"city": "Kattappana", "district": "Idukki", "pincode": "685508"},
{"city": "Adimali", "district": "Idukki", "pincode": "685561"},
{"city": "Kumily", "district": "Idukki", "pincode": "685509"},
{"city": "Devikulam", "district": "Idukki", "pincode": "685613"},
{"city": "Peermedu", "district": "Idukki", "pincode": "685531"},
{"city": "Nedumkandam", "district": "Idukki", "pincode": "685553"},
{"city": "Rajakkad", "district": "Idukki", "pincode": "685505"},
{"city": "Ernakulam", "district": "Ernakulam", "pincode": "682001"},
{"city": "Kochi", "district": "Ernakulam", "pincode": "682001"},
{"city": "Cochin", "district": "Ernakulam", "pincode": "682001"},
{"city": "Aluva", "district": "Ernakulam", "pincode": "683101"},
{"city": "Angamaly", "district": "Ernakulam", "pincode": "683572"},
{"city": "Perumbavoor", "district": "Ernakulam", "pincode": "683542"},
{"city": "Muvattupuzha", "district": "Ernakulam", "pincode": "686661"},
{"city": "Kalady", "district": "Ernakulam", "pincode": "683574"},
{"city": "Kothamangalam", "district": "Ernakulam", "pincode": "686691"},
{"city": "Kakkanad", "district": "Ernakulam", "pincode": "682030"},
{"city": "Edappally", "district": "Ernakulam", "pincode": "682024"},
{"city": "Kalamassery", "district": "Ernakulam", "pincode": "683104"},
{"city": "Fort Kochi", "district": "Ernakulam", "pincode": "682001"},
{"city": "Vypeen", "district": "Ernakulam", "pincode": "683515"},
{"city": "Cherai", "district": "Ernakulam", "pincode": "683514"},
{"city": "Paravur", "district": "Ernakulam", "pincode": "683513"},
{"city": "Palluruthy", "district": "Ernakulam", "pincode": "682006"},
{"city": "Mattancherry", "district": "Ernakulam", "pincode": "682002"},
{"city": "Ponnurunni", "district": "Ernakulam", "pincode": "682019"},
{"city": "Maradu", "district": "Ernakulam", "pincode": "682304"},
{"city": "Thrikkakara", "district": "Ernakulam", "pincode": "682021"},
{"city": "Vyttila", "district": "Ernakulam", "pincode": "682019"},
{"city": "Palarivattom", "district": "Ernakulam", "pincode": "682025"},
{"city": "Panangad", "district": "Ernakulam", "pincode": "682506"},
{"city": "Cheranalloor", "district": "Ernakulam", "pincode": "682034"},
{"city": "Poothrikka", "district": "Ernakulam", "pincode": "683517"},
{"city": "Manjapra", "district": "Ernakulam", "pincode": "683581"},
{"city": "Mulanthuruthy", "district": "Ernakulam", "pincode": "682314"},
{"city": "Thrissur", "district": "Thrissur", "pincode": "680001"},
{"city": "Irinjalakuda", "district": "Thrissur", "pincode": "680121"},
{"city": "Chalakudy", "district": "Thrissur", "pincode": "680307"},
{"city": "Guruvayur", "district": "Thrissur", "pincode": "680101"},
{"city": "Kodungallur", "district": "Thrissur", "pincode": "680664"},
{"city": "Kunnamkulam", "district": "Thrissur", "pincode": "680503"},
{"city": "Chavakkad", "district": "Thrissur", "pincode": "680506"},
{"city": "Wadakkanchery", "district": "Thrissur", "pincode": "680582"},
{"city": "Trichur", "district": "Thrissur", "pincode": "680001"},
{"city": "Ollur", "district": "Thrissur", "pincode": "680306"},
{"city": "Puthur", "district": "Thrissur", "pincode": "680553"},
{"city": "Cherpu", "district": "Thrissur", "pincode": "680561"},
{"city": "Mala", "district": "Thrissur", "pincode": "680732"},
{"city": "Anthikkad", "district": "Thrissur", "pincode": "680641"},
{"city": "Palakkad", "district": "Palakkad", "pincode": "678001"},
{"city": "Ottapalam", "district": "Palakkad", "pincode": "679101"},
{"city": "Shornur", "district": "Palakkad", "pincode": "679121"},
{"city": "Mannarkkad", "district": "Palakkad", "pincode": "678582"},
{"city": "Chittur", "district": "Palakkad", "pincode": "678101"},
{"city": "Alathur", "district": "Palakkad", "pincode": "678541"},
{"city": "Pattambi", "district": "Palakkad", "pincode": "679303"},
{"city": "Cherpulassery", "district": "Palakkad", "pincode": "679503"},
{"city": "Palakkad Town", "district": "Palakkad", "pincode": "678001"},
{"city": "Kuzhalmannam", "district": "Palakkad", "pincode": "678702"},
{"city": "Vadakkencherry", "district": "Palakkad", "pincode": "678683"},
{"city": "Malampuzha", "district": "Palakkad", "pincode": "678651"},
{"city": "Malappuram", "district": "Malappuram", "pincode": "676505"},
{"city": "Tirur", "district": "Malappuram", "pincode": "676101"},
{"city": "Ponnani", "district": "Malappuram", "pincode": "679577"},
{"city": "Manjeri", "district": "Malappuram", "pincode": "676121"},
{"city": "Perinthalmanna", "district": "Malappuram", "pincode": "679322"},
{"city": "Kondotty", "district": "Malappuram", "pincode": "673638"},
{"city": "Tirurrangadi", "district": "Malappuram", "pincode": "676306"},
{"city": "Kottakkal", "district": "Malappuram", "pincode": "676503"},
{"city": "Nilambur", "district": "Malappuram", "pincode": "679329"},
{"city": "Wandoor", "district": "Malappuram", "pincode": "679328"},
{"city": "Edappal", "district": "Malappuram", "pincode": "679576"},
{"city": "Vengara", "district": "Malappuram", "pincode": "676304"},
{"city": "Valanchery", "district": "Malappuram", "pincode": "676552"},
{"city": "Pandikkad", "district": "Malappuram", "pincode": "676521"},
{"city": "Kozhikode", "district": "Kozhikode", "pincode": "673001"},
{"city": "Calicut", "district": "Kozhikode", "pincode": "673001"},
{"city": "Vatakara", "district": "Kozhikode", "pincode": "673101"},
{"city": "Koyilandy", "district": "Kozhikode", "pincode": "673305"},
{"city": "Ramanattukara", "district": "Kozhikode", "pincode": "673633"},
{"city": "Feroke", "district": "Kozhikode", "pincode": "673631"},
{"city": "Perambra", "district": "Kozhikode", "pincode": "673525"},
{"city": "Quilandy", "district": "Kozhikode", "pincode": "673305"},
{"city": "Balussery", "district": "Kozhikode", "pincode": "673612"},
{"city": "Chelannur", "district": "Kozhikode", "pincode": "673616"},
{"city": "Koduvally", "district": "Kozhikode", "pincode": "673572"},
{"city": "Nanmanda", "district": "Kozhikode", "pincode": "673613"},
{"city": "Thamarassery", "district": "Kozhikode", "pincode": "673573"},
{"city": "Ulliyeri", "district": "Kozhikode", "pincode": "673315"},
{"city": "Elathur", "district": "Kozhikode", "pincode": "673303"},
{"city": "Olavanna", "district": "Kozhikode", "pincode": "673019"},
{"city": "Wayanad", "district": "Wayanad", "pincode": "673121"},
{"city": "Kalpetta", "district": "Wayanad", "pincode": "673121"},
{"city": "Mananthavady", "district": "Wayanad", "pincode": "670645"},
{"city": "Sulthan Bathery", "district": "Wayanad", "pincode": "673592"},
{"city": "Ambalavayal", "district": "Wayanad", "pincode": "673593"},
{"city": "Vythiri", "district": "Wayanad", "pincode": "673576"},
{"city": "Panamaram", "district": "Wayanad", "pincode": "670721"},
{"city": "Meenangadi", "district": "Wayanad", "pincode": "673591"},
{"city": "Pulpally", "district": "Wayanad", "pincode": "673579"},
{"city": "Meppadi", "district": "Wayanad", "pincode": "673577"},
{"city": "Kannur", "district": "Kannur", "pincode": "670001"},
{"city": "Thalassery", "district": "Kannur", "pincode": "670101"},
{"city": "Payyanur", "district": "Kannur", "pincode": "670307"},
{"city": "Iritty", "district": "Kannur", "pincode": "670703"},
{"city": "Mattannur", "district": "Kannur", "pincode": "670702"},
{"city": "Kuthuparamba", "district": "Kannur", "pincode": "670643"},
{"city": "Taliparamba", "district": "Kannur", "pincode": "670141"},
{"city": "Anthoor", "district": "Kannur", "pincode": "670306"},
{"city": "Peravoor", "district": "Kannur", "pincode": "670673"},
{"city": "Sreekandapuram", "district": "Kannur", "pincode": "670631"},
{"city": "Payyavoor", "district": "Kannur", "pincode": "670693"},
{"city": "Kasaragod", "district": "Kasaragod", "pincode": "671121"},
{"city": "Kanhangad", "district": "Kasaragod", "pincode": "671315"},
{"city": "Manjeshwar", "district": "Kasaragod", "pincode": "671323"},
{"city": "Hosdurg", "district": "Kasaragod", "pincode": "671315"},
{"city": "Nileshwar", "district": "Kasaragod", "pincode": "671314"},
{"city": "Cheruvathur", "district": "Kasaragod", "pincode": "671310"},
{"city": "Uppala", "district": "Kasaragod", "pincode": "671322"},
{"city": "Bekal", "district": "Kasaragod", "pincode": "671318"},
{"city": "Edneer", "district": "Kasaragod", "pincode": "671541"},
{"city": "Parappa", "district": "Kasaragod", "pincode": "671533"},
{"city": "Vivekanandapuram", "district": "Thiruvananthapuram", "pincode": "695003"},
{"city": "Kesavadasapuram", "district": "Thiruvananthapuram", "pincode": "695004"},
{"city": "Kowdiar", "district": "Thiruvananthapuram", "pincode": "695003"},
{"city": "Sasthamangalam", "district": "Thiruvananthapuram", "pincode": "695010"},
{"city": "Palayam", "district": "Thiruvananthapuram", "pincode": "695034"},
{"city": "Vazhuthacaud", "district": "Thiruvananthapuram", "pincode": "695014"},
{"city": "Mannanthala", "district": "Thiruvananthapuram", "pincode": "695015"},
{"city": "Karamana", "district": "Thiruvananthapuram", "pincode": "695002"},
{"city": "Enchakkal", "district": "Thiruvananthapuram", "pincode": "695615"},
{"city": "Sreekaryam", "district": "Thiruvananthapuram", "pincode": "695017"},
{"city": "Ulloor", "district": "Thiruvananthapuram", "pincode": "695011"},
{"city": "Medical College", "district": "Thiruvananthapuram", "pincode": "695011"},
{"city": "Nalanchira", "district": "Thiruvananthapuram", "pincode": "695015"},
{"city": "Vellayambalam", "district": "Thiruvananthapuram", "pincode": "695010"},
{"city": "Ambalamukku", "district": "Thiruvananthapuram", "pincode": "695005"},
{"city": "Ookode", "district": "Thiruvananthapuram", "pincode": "695616"},
{"city": "Kariavattom", "district": "Thiruvananthapuram", "pincode": "695581"},
{"city": "Aakkulam", "district": "Thiruvananthapuram", "pincode": "695011"},
{"city": "Pallichal", "district": "Thiruvananthapuram", "pincode": "695020"},
{"city": "Thirumala", "district": "Thiruvananthapuram", "pincode": "695006"},
{"city": "Thiruvanikkulam", "district": "Ernakulam", "pincode": "682305"},
{"city": "Kumbalangi", "district": "Ernakulam", "pincode": "682007"},
{"city": "Thevara", "district": "Ernakulam", "pincode": "682013"},
{"city": "Kadavanthra", "district": "Ernakulam", "pincode": "682020"},
{"city": "Kacheripady", "district": "Ernakulam", "pincode": "682018"},
{"city": "Gandhinagar", "district": "Ernakulam", "pincode": "682020"},
{"city": "Vallarpadam", "district": "Ernakulam", "pincode": "683112"},
{"city": "Willingdon Island", "district": "Ernakulam", "pincode": "682003"},
{"city": "Mulavukad", "district": "Ernakulam", "pincode": "682504"},
{"city": "Aroor", "district": "Ernakulam", "pincode": "688534"},
{"city": "Kumbalam", "district": "Ernakulam", "pincode": "682507"},
{"city": "Chottanikkara", "district": "Ernakulam", "pincode": "682312"},
{"city": "Kolenchery", "district": "Ernakulam", "pincode": "682311"},
{"city": "Kizhakkambalam", "district": "Ernakulam", "pincode": "683562"},
{"city": "Paravur North", "district": "Ernakulam", "pincode": "683513"},
{"city": "Varappuzha", "district": "Ernakulam", "pincode": "683517"},
{"city": "Vadavucode", "district": "Ernakulam", "pincode": "682023"},
{"city": "Karuvelipady", "district": "Ernakulam", "pincode": "682005"},
{"city": "Manjaly", "district": "Ernakulam", "pincode": "683580"},
{"city": "Koratty", "district": "Thrissur", "pincode": "680308"},
{"city": "Puzhakkal", "district": "Thrissur", "pincode": "680553"},
{"city": "Thrissur Town", "district": "Thrissur", "pincode": "680020"},
{"city": "Viyyur", "district": "Thrissur", "pincode": "680010"},
{"city": "Pookattupady", "district": "Thrissur", "pincode": "683577"},
{"city": "Ashtamichira", "district": "Thrissur", "pincode": "680732"},
{"city": "Karalam", "district": "Thrissur", "pincode": "680702"},
{"city": "Erumappetty", "district": "Thrissur", "pincode": "680642"},
{"city": "Pariyaram", "district": "Thrissur", "pincode": "680724"},
{"city": "Mulankunnathukavu", "district": "Thrissur", "pincode": "680581"},
{"city": "Peringottukara", "district": "Thrissur", "pincode": "680586"},
{"city": "Shoranur Town", "district": "Palakkad", "pincode": "679122"},
{"city": "Kalpathy", "district": "Palakkad", "pincode": "678003"},
{"city": "Kollengode", "district": "Palakkad", "pincode": "678506"},
{"city": "Palakkad Fort", "district": "Palakkad", "pincode": "678001"},
{"city": "Nemmara", "district": "Palakkad", "pincode": "678508"},
{"city": "Kongad", "district": "Palakkad", "pincode": "678631"},
{"city": "Ayilur", "district": "Palakkad", "pincode": "679514"},
{"city": "Parali", "district": "Palakkad", "pincode": "679517"},
{"city": "Agali", "district": "Palakkad", "pincode": "678581"},
{"city": "Mannarkkad Town", "district": "Palakkad", "pincode": "678583"},
{"city": "Tenhipalam", "district": "Malappuram", "pincode": "673636"},
{"city": "Calicut University", "district": "Malappuram", "pincode": "673635"},
{"city": "Tirur Town", "district": "Malappuram", "pincode": "676102"},
{"city": "Manjeri Town", "district": "Malappuram", "pincode": "676122"},
{"city": "Parappanangadi", "district": "Malappuram", "pincode": "676303"},
{"city": "Areekode", "district": "Malappuram", "pincode": "673639"},
{"city": "Karuvarakundu", "district": "Malappuram", "pincode": "673523"},
{"city": "Malappuram Town", "district": "Malappuram", "pincode": "676506"},
{"city": "Pottasseri", "district": "Malappuram", "pincode": "679321"},
{"city": "Kolathur", "district": "Malappuram", "pincode": "676103"},
{"city": "Kottappuram", "district": "Thrissur", "pincode": "680663"},
{"city": "Triprayar", "district": "Thrissur", "pincode": "680566"},
{"city": "Pavaratty", "district": "Thrissur", "pincode": "680507"},
{"city": "Mathilakam", "district": "Thrissur", "pincode": "680686"},
{"city": "Pazhayannur", "district": "Thrissur", "pincode": "679533"},
{"city": "Kannayi", "district": "Kannur", "pincode": "670003"},
{"city": "Chirakkal", "district": "Kannur", "pincode": "670011"},
{"city": "Dharmadam", "district": "Kannur", "pincode": "670005"},
{"city": "Pappinisseri", "district": "Kannur", "pincode": "670561"},
{"city": "Chovva", "district": "Kannur", "pincode": "670006"},
{"city": "Thana", "district": "Kannur", "pincode": "670671"},
{"city": "Madayi", "district": "Kannur", "pincode": "670302"},
{"city": "Payyoli", "district": "Kozhikode", "pincode": "673522"},
{"city": "Kuttiyadi", "district": "Kozhikode", "pincode": "673508"},
{"city": "Beypore", "district": "Kozhikode", "pincode": "673015"},
{"city": "Calicut Beach", "district": "Kozhikode", "pincode": "673032"},
{"city": "Nadapuram", "district": "Kozhikode", "pincode": "673504"},
{"city": "Thodannur", "district": "Kozhikode", "pincode": "673307"},
{"city": "Kalpetta Town", "district": "Wayanad", "pincode": "673122"},
{"city": "Pulpally Town", "district": "Wayanad", "pincode": "673578"},
{"city": "Kuppadi", "district": "Wayanad", "pincode": "673596"},
{"city": "Noolpuzha", "district": "Wayanad", "pincode": "673594"},
{"city": "Thrissur City", "district": "Thrissur", "pincode": "680022"},
{"city": "Ollur Town", "district": "Thrissur", "pincode": "680311"},
{"city": "Velur", "district": "Thrissur", "pincode": "680601"},
{"city": "Chazhur", "district": "Thrissur", "pincode": "680613"},
{"city": "Mannuthy", "district": "Thrissur", "pincode": "680651"},
{"city": "Kottuvally", "district": "Ernakulam", "pincode": "686663"},
{"city": "Puthencruz", "district": "Ernakulam", "pincode": "682308"},
{"city": "Thalayolaparambu", "district": "Kottayam", "pincode": "686605"},
{"city": "Nattakom", "district": "Kottayam", "pincode": "686013"},
{"city": "Kumarakom", "district": "Kottayam", "pincode": "686563"},
{"city": "Kidangoor", "district": "Kottayam", "pincode": "686542"},
{"city": "Kottayam City", "district": "Kottayam", "pincode": "686002"},
{"city": "Ettumanoor Town", "district": "Kottayam", "pincode": "686632"},
{"city": "Uzhavoor", "district": "Kottayam", "pincode": "686634"},
{"city": "Athirampuzha", "district": "Kottayam", "pincode": "686562"},
{"city": "Lalam", "district": "Idukki", "pincode": "685505"},
{"city": "Erattayar", "district": "Idukki", "pincode": "685508"},
{"city": "Marayoor", "district": "Idukki", "pincode": "685620"},
{"city": "Udumbanchola", "district": "Idukki", "pincode": "685550"},
{"city": "Vagamon", "district": "Idukki", "pincode": "685503"},
{"city": "Painavu", "district": "Idukki", "pincode": "685603"},
{"city": "Kollam City", "district": "Kollam", "pincode": "691013"},
{"city": "Thevally", "district": "Kollam", "pincode": "691009"},
{"city": "Asramam", "district": "Kollam", "pincode": "691002"},
{"city": "Chinnakada", "district": "Kollam", "pincode": "691001"},
{"city": "Sakthikulangara", "district": "Kollam", "pincode": "691581"},
{"city": "Thangassery", "district": "Kollam", "pincode": "691008"},
{"city": "Eravipuram", "district": "Kollam", "pincode": "691021"},
{"city": "Neendakara", "district": "Kollam", "pincode": "691582"},
{"city": "Thrikkadavathu", "district": "Pathanamthitta", "pincode": "689674"},
{"city": "Manarcaud", "district": "Pathanamthitta", "pincode": "689502"},
{"city": "Kadapra", "district": "Pathanamthitta", "pincode": "689643"},
{"city": "Kulanada", "district": "Pathanamthitta", "pincode": "689503"},
{"city": "Niranam", "district": "Pathanamthitta", "pincode": "689621"},
{"city": "Venmony", "district": "Alappuzha", "pincode": "689544"},
{"city": "Kainakary", "district": "Alappuzha", "pincode": "688504"},
{"city": "Kalavoor", "district": "Alappuzha", "pincode": "688522"},
{"city": "Thanneermukkom", "district": "Alappuzha", "pincode": "688527"},
{"city": "Thycattussery", "district": "Alappuzha", "pincode": "688526"},
{"city": "Kanichukulangara", "district": "Alappuzha", "pincode": "688522"},
{"city": "Kasaragod Town", "district": "Kasaragod", "pincode": "671122"},
{"city": "Kanhangad Town", "district": "Kasaragod", "pincode": "671316"},
{"city": "Badiadka", "district": "Kasaragod", "pincode": "671551"},
{"city": "Vellarikundu", "district": "Kasaragod", "pincode": "671348"},
{"city": "Trikaripur", "district": "Kasaragod", "pincode": "671310"},
{"city": "Chandera", "district": "Kasaragod", "pincode": "671541"},
{"city": "Mulleria", "district": "Kasaragod", "pincode": "671542"},
{"city": "Periya", "district": "Kasaragod", "pincode": "671543"},
{"city": "Manjeswaram", "district": "Kasaragod", "pincode": "671323"},
{"city": "Mavoor", "district": "Kozhikode", "pincode": "673661"},
{"city": "Kappad", "district": "Kozhikode", "pincode": "673521"},
{"city": "Kadalundi", "district": "Kozhikode", "pincode": "673302"},
{"city": "Thiruvambady", "district": "Kozhikode", "pincode": "673603"},
{"city": "Kuttiady", "district": "Kozhikode", "pincode": "673506"},
{"city": "Meppayur", "district": "Kozhikode", "pincode": "673524"},
{"city": "Atholi", "district": "Kozhikode", "pincode": "673315"},
{"city": "Chelembra", "district": "Malappuram", "pincode": "673634"},
{"city": "Tirur Junction", "district": "Malappuram", "pincode": "676103"},
{"city": "Tanur", "district": "Malappuram", "pincode": "676302"},
{"city": "Kuttippuram", "district": "Malappuram", "pincode": "679571"},
{"city": "Vettom", "district": "Malappuram", "pincode": "676551"},
{"city": "Tirurangadi", "district": "Malappuram", "pincode": "676306"},
{"city": "Munderi", "district": "Malappuram", "pincode": "673641"},
{"city": "Iringal", "district": "Kozhikode", "pincode": "673521"},
{"city": "Koyilandy Town", "district": "Kozhikode", "pincode": "673306"},
{"city": "Panniankara", "district": "Kozhikode", "pincode": "673020"},
{"city": "Changuvetty", "district": "Kozhikode", "pincode": "673009"},
{"city": "West Hill", "district": "Kozhikode", "pincode": "673005"},
{"city": "Azhiyur", "district": "Kozhikode", "pincode": "673309"},
{"city": "Puthige", "district": "Kasaragod", "pincode": "671124"},
{"city": "Kallar", "district": "Idukki", "pincode": "685552"},
{"city": "Vandanmedu", "district": "Idukki", "pincode": "685533"},
{"city": "Mankulam", "district": "Idukki", "pincode": "685615"},
{"city": "Pooppara", "district": "Idukki", "pincode": "685580"},
{"city": "Keerithodu", "district": "Idukki", "pincode": "686508"},
{"city": "Sreekantapuram", "district": "Kannur", "pincode": "670632"},
{"city": "Alakode", "district": "Kannur", "pincode": "670571"},
{"city": "Ezhome", "district": "Kannur", "pincode": "670334"},
{"city": "Kottiyoor", "district": "Kannur", "pincode": "670731"},
{"city": "Irikkur", "district": "Kannur", "pincode": "670592"},
{"city": "Arakkal", "district": "Kannur", "pincode": "670301"},
{"city": "Ramanthali", "district": "Kannur", "pincode": "670602"},
{"city": "Panoor", "district": "Kannur", "pincode": "670692"},
{"city": "Calicut University Campus", "district": "Malappuram", "pincode": "673635"},
{"city": "Thenjipalam", "district": "Malappuram", "pincode": "673637"},
{"city": "Irimbiliyam", "district": "Malappuram", "pincode": "676552"},
{"city": "Kuzhimanna", "district": "Malappuram", "pincode": "676501"},
{"city": "Edavanna", "district": "Malappuram", "pincode": "676542"},
{"city": "Kolathur Town", "district": "Malappuram", "pincode": "676103"},
{"city": "Pulamanthole", "district": "Malappuram", "pincode": "679323"},
{"city": "Angadippuram", "district": "Malappuram", "pincode": "679321"},
{"city": "Perumpadappu", "district": "Malappuram", "pincode": "679579"},
{"city": "Thurakkal", "district": "Malappuram", "pincode": "676509"},
{"city": "Kallachi", "district": "Kozhikode", "pincode": "673507"},
{"city": "Thiruvallur", "district": "Ernakulam", "pincode": "682013"},
{"city": "Elamakkara", "district": "Ernakulam", "pincode": "682026"},
{"city": "Periyar Nagar", "district": "Ernakulam", "pincode": "683102"},
{"city": "Njarakkal", "district": "Ernakulam", "pincode": "683503"},
{"city": "Munambam", "district": "Ernakulam", "pincode": "683513"},
{"city": "Cherai Beach", "district": "Ernakulam", "pincode": "683514"},
{"city": "Puthenvelikkara", "district": "Ernakulam", "pincode": "683594"},
{"city": "Valayanchirangara", "district": "Ernakulam", "pincode": "683556"},
{"city": "Kizhillam", "district": "Ernakulam", "pincode": "683547"},
{"city": "Moovattupuzha", "district": "Ernakulam", "pincode": "686662"},
{"city": "Malayattoor", "district": "Ernakulam", "pincode": "683587"},
{"city": "Alangad", "district": "Ernakulam", "pincode": "683511"},
{"city": "Kadungalloor", "district": "Ernakulam", "pincode": "683110"},
{"city": "Piravom", "district": "Ernakulam", "pincode": "686664"},
{"city": "Kunnathunad", "district": "Ernakulam", "pincode": "686691"},
{"city": "Udayamperoor", "district": "Ernakulam", "pincode": "682307"},
{"city": "Vennala", "district": "Ernakulam", "pincode": "682028"},
{"city": "Vaduthala", "district": "Ernakulam", "pincode": "682023"},
{"city": "Pallikara", "district": "Ernakulam", "pincode": "683565"},
{"city": "Keezhmad", "district": "Ernakulam", "pincode": "683583"},
{"city": "Kottuvally Town", "district": "Ernakulam", "pincode": "686664"},
{"city": "Ramamangalam", "district": "Ernakulam", "pincode": "686663"},
{"city": "Karumalloor", "district": "Ernakulam", "pincode": "683576"},
{"city": "Manjapra Town", "district": "Ernakulam", "pincode": "683582"},
{"city": "Pambady", "district": "Kottayam", "pincode": "686502"},
{"city": "Chempu", "district": "Kottayam", "pincode": "686587"},
{"city": "Kottayam Junction", "district": "Kottayam", "pincode": "686003"},
{"city": "Baker Hill", "district": "Kottayam", "pincode": "686001"},
{"city": "Pallom", "district": "Kottayam", "pincode": "686007"},
{"city": "Arpookara", "district": "Kottayam", "pincode": "686008"},
{"city": "Kudamaloor", "district": "Kottayam", "pincode": "686562"},
{"city": "Peroor", "district": "Kottayam", "pincode": "686545"},
{"city": "Changanacherry Town", "district": "Kottayam", "pincode": "686102"},
{"city": "Vakathanam", "district": "Kottayam", "pincode": "686538"},
{"city": "Nattassery", "district": "Kottayam", "pincode": "686535"},
{"city": "Karoor", "district": "Kottayam", "pincode": "686544"},
{"city": "Manarcad", "district": "Kottayam", "pincode": "686543"},
{"city": "Kumarakom Beach", "district": "Kottayam", "pincode": "686563"},
{"city": "Alappuzha Beach", "district": "Alappuzha", "pincode": "688012"},
{"city": "Alappuzha North", "district": "Alappuzha", "pincode": "688001"},
{"city": "Thuravoor", "district": "Alappuzha", "pincode": "688534"},
{"city": "Thakazhi", "district": "Alappuzha", "pincode": "688562"},
{"city": "Chambakkulam", "district": "Alappuzha", "pincode": "688538"},
{"city": "Muttar", "district": "Alappuzha", "pincode": "689623"},
{"city": "Aryad", "district": "Alappuzha", "pincode": "688536"},
{"city": "Punnapra", "district": "Alappuzha", "pincode": "688004"},
{"city": "Pathirapally", "district": "Alappuzha", "pincode": "689652"},
{"city": "Pallippad", "district": "Alappuzha", "pincode": "690515"},
{"city": "Thiruvalla Town", "district": "Pathanamthitta", "pincode": "689102"},
{"city": "Pathanamthitta Town", "district": "Pathanamthitta", "pincode": "689646"},
{"city": "Adoor Town", "district": "Pathanamthitta", "pincode": "691524"},
{"city": "Pandalam Town", "district": "Pathanamthitta", "pincode": "689502"},
{"city": "Elanthoor", "district": "Pathanamthitta", "pincode": "689643"},
{"city": "Naranganam", "district": "Pathanamthitta", "pincode": "686535"},
{"city": "Mezhuveli", "district": "Pathanamthitta", "pincode": "689648"},
{"city": "Murickassery", "district": "Idukki", "pincode": "685512"},
{"city": "Thankamani", "district": "Idukki", "pincode": "685509"},
{"city": "Upputhara", "district": "Idukki", "pincode": "685505"},
{"city": "Vandiperiyar", "district": "Idukki", "pincode": "685533"},
{"city": "Kanjikuzhy", "district": "Idukki", "pincode": "686607"},
{"city": "Karimannoor", "district": "Idukki", "pincode": "686541"},
{"city": "Myladumpara", "district": "Idukki", "pincode": "685515"},
{"city": "Vathikudy", "district": "Idukki", "pincode": "685514"},
{"city": "Kothamangalam Town", "district": "Ernakulam", "pincode": "686692"},
{"city": "Periyapuram", "district": "Thrissur", "pincode": "680025"},
{"city": "Ollur Junction", "district": "Thrissur", "pincode": "680307"},
{"city": "Kunnamkulam Town", "district": "Thrissur", "pincode": "680504"},
{"city": "Guruvayur Town", "district": "Thrissur", "pincode": "680102"},
{"city": "Irinjalakuda Town", "district": "Thrissur", "pincode": "680122"},
{"city": "Chalakudy Town", "district": "Thrissur", "pincode": "680308"},
{"city": "Kodungallur Town", "district": "Thrissur", "pincode": "680665"},
{"city": "Kanjani", "district": "Thrissur", "pincode": "680571"},
{"city": "Puthukkad", "district": "Thrissur", "pincode": "680301"},
{"city": "Moothakunnam", "district": "Ernakulam", "pincode": "683516"},
{"city": "Nayarambalam", "district": "Ernakulam", "pincode": "682509"},
{"city": "Chendamangalam", "district": "Ernakulam", "pincode": "683512"},
{"city": "North Paravur", "district": "Ernakulam", "pincode": "683513"},
{"city": "Alangad Town", "district": "Ernakulam", "pincode": "683512"},
{"city": "Vadavucode Puthencruz", "district": "Ernakulam", "pincode": "682308"},
{"city": "Rayamangalam", "district": "Ernakulam", "pincode": "686692"},
{"city": "Edakkattuvayal", "district": "Ernakulam", "pincode": "683565"},
{"city": "Varapuzha", "district": "Ernakulam", "pincode": "683517"},
{"city": "Pallippuram", "district": "Ernakulam", "pincode": "683513"}
]

View File

@@ -1,6 +1,10 @@
// lib/features/booking/providers/checkout_provider.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/utils/error_utils.dart';
import '../models/booking_models.dart';
import '../services/booking_service.dart';
@@ -24,8 +28,11 @@ class CheckoutProvider extends ChangeNotifier {
// Shipping
ShippingDetails? shippingDetails;
// Coupon
// Coupon / promo
String? couponCode;
double discountAmount = 0.0;
String? promoMessage;
bool promoApplied = false;
// Status
bool loading = false;
@@ -40,6 +47,9 @@ class CheckoutProvider extends ChangeNotifier {
cart = [];
shippingDetails = null;
couponCode = null;
discountAmount = 0.0;
promoMessage = null;
promoApplied = false;
paymentId = null;
error = null;
loading = true;
@@ -65,7 +75,7 @@ class CheckoutProvider extends ChangeNotifier {
}
double get subtotal => cart.fold(0, (sum, item) => sum + item.subtotal);
double get total => subtotal; // expand with discount/tax later
double get total => subtotal - discountAmount;
bool get hasItems => cart.isNotEmpty;
@@ -95,6 +105,62 @@ class CheckoutProvider extends ChangeNotifier {
notifyListeners();
}
/// Apply a promo code against the backend.
Future<bool> applyPromo(String code) async {
if (code.trim().isEmpty) return false;
loading = true;
error = null;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
final response = await http.post(
Uri.parse('${ApiEndpoints.baseUrl}/bookings/apply-promo/'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'code': code.trim(), 'event_id': eventId}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['valid'] == true) {
discountAmount = (data['discount_amount'] as num?)?.toDouble() ?? 0.0;
couponCode = code.trim();
promoMessage = data['message'] as String? ?? 'Promo applied!';
promoApplied = true;
notifyListeners();
return true;
} else {
promoMessage = data['message'] as String? ?? 'Invalid promo code';
promoApplied = false;
discountAmount = 0.0;
couponCode = null;
notifyListeners();
return false;
}
} else {
promoMessage = 'Could not apply promo code';
return false;
}
} catch (e) {
promoMessage = 'Could not apply promo code';
return false;
} finally {
loading = false;
notifyListeners();
}
}
/// Remove applied promo code.
void resetPromo() {
discountAmount = 0.0;
couponCode = null;
promoMessage = null;
promoApplied = false;
notifyListeners();
}
/// Process checkout on backend.
Future<Map<String, dynamic>> processCheckout() async {
loading = true;
@@ -139,6 +205,9 @@ class CheckoutProvider extends ChangeNotifier {
cart = [];
shippingDetails = null;
couponCode = null;
discountAmount = 0.0;
promoMessage = null;
promoApplied = false;
paymentId = null;
error = null;
loading = false;

View File

@@ -72,6 +72,11 @@ class EventModel {
final double? averageRating;
final int? reviewCount;
// Contributor fields (EVT-001)
final String? contributorId;
final String? contributorName;
final String? contributorTier;
EventModel({
required this.id,
required this.name,
@@ -97,6 +102,9 @@ class EventModel {
this.importantInfo = const [],
this.averageRating,
this.reviewCount,
this.contributorId,
this.contributorName,
this.contributorTier,
});
/// Safely parse a double from backend (may arrive as String or num)
@@ -156,6 +164,9 @@ class EventModel {
importantInfo: _parseImportantInfo(j['important_info']),
averageRating: (j['average_rating'] as num?)?.toDouble(),
reviewCount: (j['review_count'] as num?)?.toInt(),
contributorId: j['contributor_id']?.toString(),
contributorName: j['contributor_name'] as String?,
contributorTier: j['contributor_tier'] as String?,
);
}
}

View File

@@ -83,6 +83,28 @@ class EventsService {
return EventModel.fromJson(Map<String, dynamic>.from(res));
}
/// Related events by event_type_id (EVT-002).
/// Fetches events with the same category, silently returns [] on failure.
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
try {
final res = await _api.post(
ApiEndpoints.eventsByCategory,
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
requiresAuth: false,
);
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
if (results is List) {
return results
.whereType<Map<String, dynamic>>()
.map((e) => EventModel.fromJson(e))
.toList();
}
} catch (_) {
// silently fail — related events are non-critical
}
return [];
}
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);

View File

@@ -45,6 +45,33 @@ class GamificationService {
);
}
// ---------------------------------------------------------------------------
// Public contributor profile (any user by userId / email)
// GET /v1/gamification/dashboard?user_id={userId}
// ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboardForUser(String userId) async {
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$userId';
final res = await _api.post(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
final rawSubs = res['submissions'] as List? ?? [];
final rawAchievements = res['achievements'] as List? ?? [];
final submissions = rawSubs
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
.toList();
final achievements = rawAchievements
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
.toList();
return DashboardResponse(
profile: UserGamificationProfile.fromJson(profileJson),
submissions: submissions,
achievements: achievements,
);
}
/// Convenience — returns just the profile (backward-compatible with provider).
Future<UserGamificationProfile> getProfile() async {
final dashboard = await getDashboard();
@@ -152,11 +179,16 @@ class GamificationService {
}
static const _defaultBadges = [
AchievementBadge(id: 'badge-01', title: 'First Submission', description: 'Submitted your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Reached Silver tier.', iconName: 'star', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Reach Gold tier (500 EP).', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Appear in the district leaderboard top 10.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Submit 10 events with 3+ images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first 100 contributors.', iconName: 'verified', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-07', title: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-08', title: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
];
}

View File

@@ -1,7 +1,97 @@
// lib/features/reviews/widgets/review_summary.dart
import 'dart:math' show pi;
import 'package:flutter/material.dart';
import '../models/review_models.dart';
import 'star_display.dart';
class _RatingRingPainter extends CustomPainter {
final double rating;
const _RatingRingPainter({required this.rating});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 6;
// Background track
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
2 * pi,
false,
Paint()
..color = Colors.white12
..style = PaintingStyle.stroke
..strokeWidth = 7
..strokeCap = StrokeCap.round,
);
// Filled arc
if (rating > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
(rating.clamp(0.0, 5.0) / 5.0) * 2 * pi,
false,
Paint()
..color = const Color(0xFFFBBF24)
..style = PaintingStyle.stroke
..strokeWidth = 7
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(_RatingRingPainter old) => old.rating != rating;
}
class _RatingRingWidget extends StatelessWidget {
final double rating;
final int reviewCount;
const _RatingRingWidget({required this.rating, required this.reviewCount});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 84,
height: 84,
child: CustomPaint(
painter: _RatingRingPainter(rating: rating),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
rating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
const Text(
'/5',
style: TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
),
],
),
),
),
),
const SizedBox(height: 4),
Text(
'$reviewCount ${reviewCount == 1 ? 'review' : 'reviews'}',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
],
);
}
}
class ReviewSummary extends StatelessWidget {
final ReviewStatsModel stats;
@@ -24,27 +114,10 @@ class ReviewSummary extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left: average rating + stars + count
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
stats.averageRating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Color(0xFF1E293B),
height: 1.1,
),
),
const SizedBox(height: 4),
StarDisplay(rating: stats.averageRating, size: 18),
const SizedBox(height: 4),
Text(
'${stats.reviewCount} review${stats.reviewCount == 1 ? '' : 's'}',
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
],
// Left: circular rating ring
_RatingRingWidget(
rating: stats.averageRating,
reviewCount: stats.reviewCount,
),
const SizedBox(width: 24),
// Right: distribution bars

View File

@@ -0,0 +1,197 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../widgets/tier_avatar_ring.dart';
class ShareRankCard extends StatefulWidget {
final String username;
final String tier;
final int rank;
final int ep;
final int rewardPoints;
const ShareRankCard({
super.key,
required this.username,
required this.tier,
required this.rank,
required this.ep,
this.rewardPoints = 0,
});
@override
State<ShareRankCard> createState() => _ShareRankCardState();
}
class _ShareRankCardState extends State<ShareRankCard> {
final GlobalKey _boundaryKey = GlobalKey();
bool _sharing = false;
static const _tierGradients = {
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
};
List<Color> get _gradient {
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
}
Future<void> _share() async {
if (_sharing) return;
setState(() => _sharing = true);
try {
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
final bytes = byteData.buffer.asUint8List();
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
await file.writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(file.path)],
text: 'I\'m ranked #${widget.rank} on Eventify with ${widget.ep} EP! 🏆 #Eventify #Kerala',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not share rank card')),
);
}
} finally {
if (mounted) setState(() => _sharing = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
RepaintBoundary(
key: _boundaryKey,
child: Container(
width: 320,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tier gradient header bar
Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(colors: _gradient),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(height: 20),
// Avatar
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
const SizedBox(height: 12),
// Username
Text(
widget.username,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
),
const SizedBox(height: 4),
// Tier badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
gradient: LinearGradient(colors: _gradient),
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.tier.isEmpty ? 'Contributor' : widget.tier,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
),
),
const SizedBox(height: 20),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_stat('Rank', '#${widget.rank}'),
Container(width: 1, height: 40, color: Colors.white12),
_stat('EP', '${widget.ep}'),
Container(width: 1, height: 40, color: Colors.white12),
_stat('RP', '${widget.rewardPoints}'),
],
),
const SizedBox(height: 20),
// Branding
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
SizedBox(width: 4),
Text(
'EVENTIFY',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w900,
color: Color(0xFF3B82F6),
letterSpacing: 2,
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _sharing ? null : _share,
icon: _sharing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.share, size: 18),
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1D4ED8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
);
}
Widget _stat(String label, String value) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
],
);
}
}

View File

@@ -31,6 +31,7 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _promoCtrl = TextEditingController();
@override
void initState() {
@@ -77,6 +78,7 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
_nameCtrl.dispose();
_emailCtrl.dispose();
_phoneCtrl.dispose();
_promoCtrl.dispose();
super.dispose();
}
@@ -253,6 +255,84 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
_field('Full Name', _nameCtrl, validator: (v) => v!.isEmpty ? 'Required' : null),
_field('Email', _emailCtrl, type: TextInputType.emailAddress, validator: (v) => v!.isEmpty ? 'Required' : null),
_field('Phone', _phoneCtrl, type: TextInputType.phone, validator: (v) => v!.isEmpty ? 'Required' : null),
const SizedBox(height: 8),
Consumer<CheckoutProvider>(
builder: (context, provider, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _promoCtrl,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Promo Code (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
filled: true,
fillColor: Colors.grey.shade50,
suffixIcon: provider.promoApplied
? const Icon(Icons.check_circle, color: Colors.green, size: 20)
: null,
),
enabled: !provider.promoApplied,
),
),
const SizedBox(width: 8),
SizedBox(
height: 56,
child: provider.promoApplied
? OutlinedButton(
onPressed: () {
provider.resetPromo();
_promoCtrl.clear();
},
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Remove'),
)
: ElevatedButton(
onPressed: provider.loading
? null
: () async {
final ok = await provider.applyPromo(_promoCtrl.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.promoMessage ??
(ok ? 'Promo applied!' : 'Invalid promo code')),
backgroundColor: ok ? Colors.green : Colors.red,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
foregroundColor: Colors.white,
),
child: const Text('Apply'),
),
),
],
),
if (provider.promoApplied && provider.promoMessage != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.local_offer, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
'${provider.promoMessage} — saves \u20b9${provider.discountAmount.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 12, color: Colors.green),
),
],
),
],
],
);
},
),
],
),
),
@@ -293,6 +373,34 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
),
)),
const Divider(height: 32),
if (provider.promoApplied) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Subtotal', style: TextStyle(color: Colors.grey.shade700)),
Text('Rs ${provider.subtotal.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_offer, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
provider.couponCode != null ? 'Promo (${provider.couponCode})' : 'Promo discount',
style: const TextStyle(color: Colors.green),
),
],
),
Text('- Rs ${provider.discountAmount.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [

View File

@@ -14,7 +14,11 @@ 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/glass_card.dart';
import '../widgets/landscape_section_header.dart';
import '../widgets/tier_avatar_ring.dart';
import '../features/share/share_rank_card.dart';
import 'contributor_profile_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Tier colour map
@@ -29,12 +33,19 @@ const _tierColors = <ContributorTier, Color>{
// Icon map for achievement badges
const _badgeIcons = <String, IconData>{
'edit': Icons.edit_outlined,
'star': Icons.star_outline,
'emoji_events': Icons.emoji_events_outlined,
'leaderboard': Icons.leaderboard_outlined,
'photo_library': Icons.photo_library_outlined,
'verified': Icons.verified_outlined,
'edit': Icons.edit_outlined,
'star': Icons.star_outline,
'emoji_events': Icons.emoji_events_outlined,
'leaderboard': Icons.leaderboard_outlined,
'photo_library': Icons.photo_library_outlined,
'verified': Icons.verified_outlined,
// ACH-002: icons for expanded badge set (badges 02, 0611)
'trending_up': Icons.trending_up,
'rocket_launch': Icons.rocket_launch_outlined,
'event_hunter': Icons.search_outlined,
'location_on': Icons.location_on_outlined,
'diamond': Icons.diamond_outlined,
'workspace_premium': Icons.workspace_premium_outlined,
};
// District list for the contribution form
@@ -254,7 +265,56 @@ class _ContributeScreenState extends State<ContributeScreen>
children: [
_epStatCard('Lifetime EP', '${profile?.lifetimeEp ?? 0}', Icons.star, const Color(0xFFF59E0B)),
const SizedBox(width: 8),
_epStatCard('Liquid EP', '${profile?.currentEp ?? 0}', Icons.bolt, const Color(0xFF3B82F6)),
// GAM-003 + GAM-004: Liquid EP card with cycle countdown and progress
Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.3)),
),
child: Column(
children: [
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 20),
const SizedBox(height: 4),
Text(
'${profile?.currentEp ?? 0}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16),
),
const SizedBox(height: 2),
const Text('Liquid EP', style: TextStyle(color: Colors.white60, fontSize: 10), textAlign: TextAlign.center),
if (provider.currentUserStats?.rewardCycleDays != null) ...[
const SizedBox(height: 4),
Text(
'Converts in ${provider.currentUserStats!.rewardCycleDays}d',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Builder(
builder: (_) {
final days = provider.currentUserStats?.rewardCycleDays ?? 30;
final elapsed = (30 - days).clamp(0, 30);
final ratio = elapsed / 30;
return LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
ratio > 0.7 ? const Color(0xFFFBBF24) : const Color(0xFF3B82F6),
),
);
},
),
),
],
],
),
),
),
const SizedBox(width: 8),
_epStatCard('Reward Pts', '${profile?.currentRp ?? 0}', Icons.redeem, const Color(0xFF10B981)),
],
@@ -1169,20 +1229,34 @@ class _ContributeScreenState extends State<ContributeScreen>
// Badge icon colors
final iconColors = <String, Color>{
'edit': const Color(0xFF3B82F6),
'star': const Color(0xFFF59E0B),
'emoji_events': const Color(0xFFF97316),
'leaderboard': const Color(0xFF8B5CF6),
'photo_library': const Color(0xFF6B7280),
'verified': const Color(0xFF10B981),
'edit': const Color(0xFF3B82F6),
'star': const Color(0xFFF59E0B),
'emoji_events': const Color(0xFFF97316),
'leaderboard': const Color(0xFF8B5CF6),
'photo_library': const Color(0xFF6B7280),
'verified': const Color(0xFF10B981),
// ACH-002: colors for expanded badge set
'trending_up': const Color(0xFF0EA5E9),
'rocket_launch': const Color(0xFFEC4899),
'event_hunter': const Color(0xFF64748B),
'location_on': const Color(0xFF22C55E),
'diamond': const Color(0xFF06B6D4),
'workspace_premium': const Color(0xFFE879F9),
};
final bgColors = <String, Color>{
'edit': const Color(0xFFDBEAFE),
'star': const Color(0xFFFEF3C7),
'emoji_events': const Color(0xFFFED7AA),
'leaderboard': const Color(0xFFEDE9FE),
'photo_library': const Color(0xFFF3F4F6),
'verified': const Color(0xFFD1FAE5),
'edit': const Color(0xFFDBEAFE),
'star': const Color(0xFFFEF3C7),
'emoji_events': const Color(0xFFFED7AA),
'leaderboard': const Color(0xFFEDE9FE),
'photo_library': const Color(0xFFF3F4F6),
'verified': const Color(0xFFD1FAE5),
// ACH-002: backgrounds for expanded badge set
'trending_up': const Color(0xFFE0F2FE),
'rocket_launch': const Color(0xFFFCE7F3),
'event_hunter': const Color(0xFFF1F5F9),
'location_on': const Color(0xFFDCFCE7),
'diamond': const Color(0xFFCFFAFE),
'workspace_premium': const Color(0xFFFAE8FF),
};
final iconColor = isUnlocked ? (iconColors[badge.iconName] ?? const Color(0xFF6B7280)) : const Color(0xFF9CA3AF);
@@ -1520,9 +1594,10 @@ class _ContributeScreenState extends State<ContributeScreen>
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildContributeTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
final bottomInset = MediaQuery.of(context).padding.bottom;
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
padding: EdgeInsets.fromLTRB(16, 20, 16, 32 + bottomInset),
child: Form(
key: _formKey,
child: Column(
@@ -2021,6 +2096,26 @@ class _ContributeScreenState extends State<ContributeScreen>
// ── Time period toggle (top-right) + district scroll ──────────────────
_buildLeaderboardFilters(provider),
// LDR-003: Current user stats card at top of leaderboard
if (provider.currentUserStats != null)
Builder(builder: (context) {
final stats = provider.currentUserStats!;
return GlassCard(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatChip('Rank', '#${stats.rank}', Icons.leaderboard),
Container(width: 1, height: 32, color: Colors.white12),
_buildStatChip('EP', '${stats.points}', Icons.bolt),
Container(width: 1, height: 32, color: Colors.white12),
_buildStatChip('Cycle', '${stats.rewardCycleDays}d', Icons.timelapse),
],
),
);
}),
Expanded(
child: Container(
color: const Color(0xFFFAFBFC),
@@ -2183,29 +2278,16 @@ class _ContributeScreenState extends State<ContributeScreen>
return Expanded(
child: Column(
children: [
// Avatar with rank badge overlaid
// GAM-006: Avatar with tier ring + rank badge overlaid
Stack(
clipBehavior: Clip.none,
children: [
// Avatar circle
Container(
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE0F2FE),
border: Border.all(color: pillarColors[i], width: 2.5),
),
child: Center(
child: Text(
e.username.isNotEmpty ? e.username[0].toUpperCase() : '?',
style: TextStyle(
fontSize: i == 1 ? 24 : 18,
fontWeight: FontWeight.w800,
color: pillarColors[i],
),
),
),
// TierAvatarRing — tier-coloured glow ring
TierAvatarRing(
username: e.username,
tier: tierLabel(e.tier),
size: avatarSize,
imageUrl: e.avatarUrl,
),
// Rank badge — bottom-right corner of avatar
Positioned(
@@ -2267,7 +2349,17 @@ class _ContributeScreenState extends State<ContributeScreen>
final tierColor = _tierColors[entry.tier]!;
final isMe = entry.isCurrentUser;
return Container(
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContributorProfileScreen(
contributorId: entry.username,
contributorName: entry.username,
),
),
),
child: Container(
decoration: BoxDecoration(
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
border: const Border(bottom: BorderSide(color: Color(0xFFE5E7EB), width: 0.8)),
@@ -2287,21 +2379,12 @@ class _ContributeScreenState extends State<ContributeScreen>
),
),
),
// Avatar circle
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE0F2FE),
border: Border.all(color: tierColor.withOpacity(0.5), width: 1.5),
),
child: Center(
child: Text(
entry.username.isNotEmpty ? entry.username[0].toUpperCase() : '?',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: tierColor),
),
),
// GAM-006: TierAvatarRing replaces plain avatar circle
TierAvatarRing(
username: entry.username,
tier: tierLabel(entry.tier),
size: 36,
imageUrl: entry.avatarUrl,
),
const SizedBox(width: 8),
// Name
@@ -2360,6 +2443,7 @@ class _ContributeScreenState extends State<ContributeScreen>
),
],
),
),
);
}
@@ -2386,10 +2470,19 @@ class _ContributeScreenState extends State<ContributeScreen>
),
GestureDetector(
onTap: () {
Share.share(
'I\'m ranked #${me.rank} on @EventifyPlus with ${me.lifetimeEp} EP! 🏆 '
'Discover & contribute to events near you at eventifyplus.com',
subject: 'My Eventify.Plus Leaderboard Rank',
final gam = context.read<GamificationProvider>();
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: ShareRankCard(
username: me.username,
tier: tierLabel(me.tier),
rank: me.rank,
ep: me.lifetimeEp,
rewardPoints: gam.profile?.currentRp ?? 0,
),
),
);
},
child: Container(
@@ -2410,6 +2503,19 @@ class _ContributeScreenState extends State<ContributeScreen>
);
}
// LDR-003: Stat chip helper for current-user leaderboard card
Widget _buildStatChip(String label, String value, IconData icon) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: const Color(0xFF94A3B8)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
Text(label, style: const TextStyle(fontSize: 10, color: Color(0xFF64748B))),
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 2 — ACHIEVEMENTS
// ═══════════════════════════════════════════════════════════════════════════
@@ -2421,9 +2527,10 @@ class _ContributeScreenState extends State<ContributeScreen>
}
final badges = provider.achievements;
final bottomInset = MediaQuery.of(context).padding.bottom;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + bottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -0,0 +1,271 @@
// lib/screens/contributor_profile_screen.dart
// CTR-004 — Public contributor profile page.
// Shows avatar, tier ring, EP stats, and submission grid for any contributor.
import 'package:flutter/material.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/services/gamification_service.dart';
import '../widgets/tier_avatar_ring.dart';
class ContributorProfileScreen extends StatefulWidget {
final String contributorId;
final String contributorName;
const ContributorProfileScreen({
super.key,
required this.contributorId,
required this.contributorName,
});
@override
State<ContributorProfileScreen> createState() => _ContributorProfileScreenState();
}
class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
DashboardResponse? _data;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await GamificationService().getDashboardForUser(widget.contributorId);
if (mounted) {
setState(() {
_data = data;
_loading = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_error = 'Could not load profile';
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF0F172A),
foregroundColor: Colors.white,
title: Text(
widget.contributorName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
elevation: 0,
),
body: _loading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF3B82F6)),
)
: _error != null
? Center(
child: Text(
_error!,
style: const TextStyle(color: Colors.white54),
),
)
: _buildContent(),
);
}
Widget _buildContent() {
final profile = _data!.profile;
final submissions = _data!.submissions;
final tierStr = tierLabel(profile.tier);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Avatar with tier ring
TierAvatarRing(
username: widget.contributorName,
tier: tierStr,
size: 88,
),
const SizedBox(height: 12),
Text(
widget.contributorName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF1E3A8A),
borderRadius: BorderRadius.circular(12),
),
child: Text(
tierStr,
style: const TextStyle(fontSize: 12, color: Color(0xFF93C5FD)),
),
),
const SizedBox(height: 16),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_statCard('EP', '${profile.currentEp}'),
_statCard('Events', '${submissions.length}'),
_statCard(
'Approved',
'${submissions.where((s) => s.status.toUpperCase() == 'APPROVED').length}',
),
],
),
],
),
),
),
if (submissions.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => _buildSubmissionTile(submissions[i]),
childCount: submissions.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.1,
),
),
)
else
const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'No submissions yet',
style: TextStyle(color: Colors.white38),
),
),
),
),
],
);
}
Widget _statCard(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
),
],
);
}
Widget _buildSubmissionTile(SubmissionModel s) {
final Color statusColor;
switch (s.status.toUpperCase()) {
case 'APPROVED':
statusColor = const Color(0xFF22C55E);
break;
case 'REJECTED':
statusColor = const Color(0xFFEF4444);
break;
default:
statusColor = const Color(0xFFFBBF24); // PENDING
}
// SubmissionModel.images is List<String>; use first image if present.
final String? firstImage = s.images.isNotEmpty ? s.images.first : null;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: [
if (firstImage != null && firstImage.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.expand(
child: Image.network(
firstImage,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)),
),
),
),
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(6),
),
child: Text(
s.status,
style: const TextStyle(
fontSize: 9,
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
if (s.eventName.isNotEmpty)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
borderRadius: BorderRadius.vertical(bottom: Radius.circular(10)),
),
child: Text(
s.eventName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
],
),
);
}
}

View File

@@ -50,7 +50,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
bool _loading = true;
// Hero carousel
final PageController _heroPageController = PageController(viewportFraction: 0.88);
final PageController _heroPageController = PageController(viewportFraction: 0.9);
late final ValueNotifier<int> _heroPageNotifier;
Timer? _autoScrollTimer;
@@ -453,10 +453,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
),
// Floating bottom navigation (always visible)
// bottom offset accounts for home indicator on iPhone/Android gesture bar
Positioned(
left: 16,
right: 16,
bottom: 16,
bottom: MediaQuery.of(context).padding.bottom + 16,
child: _buildFloatingBottomNav(),
),
],
@@ -1532,11 +1533,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
style: const TextStyle(color: Color(0xFF9CA3AF)),
))
: ListView.separated(
scrollDirection: Axis.horizontal,
: PageView.builder(
controller: PageController(viewportFraction: 0.85),
physics: const PageScrollPhysics(),
itemCount: _allFilteredByDate.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) => _buildTopEventCard(_allFilteredByDate[index]),
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildTopEventCard(_allFilteredByDate[index]),
),
),
),
const SizedBox(height: 24),

View File

@@ -15,6 +15,8 @@ import '../core/auth/auth_guard.dart';
import '../core/utils/error_utils.dart';
import '../core/constants.dart';
import '../features/reviews/widgets/review_section.dart';
import '../widgets/tier_avatar_ring.dart';
import 'contributor_profile_screen.dart';
import 'checkout_screen.dart';
class LearnMoreScreen extends StatefulWidget {
@@ -59,6 +61,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// Google Map
GoogleMapController? _mapController;
// Related events (EVT-002)
List<EventModel> _relatedEvents = [];
bool _loadingRelated = false;
@override
void initState() {
super.initState();
@@ -100,6 +106,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
_event = ev;
});
_startAutoScroll();
_loadRelatedEvents();
return; // success
} catch (e) {
debugPrint('_loadFullDetails attempt ${attempt + 1} failed for event ${widget.eventId}: $e');
@@ -120,6 +127,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (!mounted) return;
setState(() => _event = ev);
_startAutoScroll();
_loadRelatedEvents();
} catch (e) {
if (!mounted) return;
setState(() => _error = userFriendlyError(e));
@@ -128,6 +136,19 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
}
}
/// Fetch related events by the same event type (EVT-002).
Future<void> _loadRelatedEvents() async {
if (_event?.eventTypeId == null) return;
if (mounted) setState(() => _loadingRelated = true);
try {
final events = await _service.getEventsByCategory(_event!.eventTypeId!, limit: 6);
final filtered = events.where((e) => e.id != widget.eventId).take(5).toList();
if (mounted) setState(() => _relatedEvents = filtered);
} finally {
if (mounted) setState(() => _loadingRelated = false);
}
}
// ---------------------------------------------------------------------------
// Carousel helpers
// ---------------------------------------------------------------------------
@@ -441,8 +462,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
// EVT-001: Contributor widget
_buildContributorSection(theme),
const SizedBox(height: 24),
ReviewSection(eventId: widget.eventId),
// EVT-002: Related events horizontal row
_buildRelatedEventsSection(theme),
],
),
),
@@ -619,11 +644,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
// EVT-001: Contributor widget
_buildContributorSection(theme),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: ReviewSection(eventId: widget.eventId),
),
// EVT-002: Related events horizontal row
_buildRelatedEventsSection(theme),
const SizedBox(height: 100),
],
),
@@ -1335,6 +1364,227 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
return items;
}
// ---------------------------------------------------------------------------
// 8. CONTRIBUTOR WIDGET (EVT-001)
// ---------------------------------------------------------------------------
Widget _buildContributorSection(ThemeData theme) {
final name = _event?.contributorName;
if (name == null || name.isEmpty) return const SizedBox.shrink();
final tier = _event!.contributorTier ?? '';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.brightness == Brightness.dark
? const Color(0xFF1E293B)
: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.brightness == Brightness.dark
? Colors.white.withOpacity(0.08)
: theme.dividerColor,
),
),
child: Row(
children: [
TierAvatarRing(
username: name,
tier: tier,
size: 40,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contributed by',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 11,
),
),
const SizedBox(height: 2),
Text(
name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (tier.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
tier,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
if (_event?.contributorId != null)
IconButton(
icon: Icon(Icons.arrow_forward_ios,
size: 14, color: theme.hintColor),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContributorProfileScreen(
contributorId: _event!.contributorId!,
contributorName: _event!.contributorName!,
),
),
);
},
),
],
),
),
);
}
// ---------------------------------------------------------------------------
// 9. RELATED EVENTS ROW (EVT-002)
// ---------------------------------------------------------------------------
Widget _buildRelatedEventsSection(ThemeData theme) {
if (_loadingRelated) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Related Events',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
const SizedBox(height: 12),
const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
);
}
if (_relatedEvents.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
child: Text(
'Related Events',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _relatedEvents.length,
itemBuilder: (context, i) {
final e = _relatedEvents[i];
final displayName = e.title ?? e.name;
final imageUrl = e.thumbImg ?? '';
return GestureDetector(
onTap: () => Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LearnMoreScreen(eventId: e.id),
),
),
child: Container(
width: 140,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.brightness == Brightness.dark
? const Color(0xFF1E293B)
: theme.cardColor,
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: imageUrl,
height: 100,
width: 140,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(
height: 100,
width: 140,
color: theme.dividerColor,
child: Icon(Icons.event,
size: 32, color: theme.hintColor),
),
)
: Container(
height: 100,
width: 140,
color: theme.dividerColor,
child: Icon(Icons.event,
size: 32, color: theme.hintColor),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
displayName,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
height: 1.35,
),
),
),
],
),
),
);
},
),
),
const SizedBox(height: 8),
],
);
}
Widget _buildImportantInfoFallback(ThemeData theme) {
final parsed = _parseHtmlImportantInfo(_event!.importantInformation!);

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import '../core/utils/error_utils.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -13,11 +16,14 @@ import '../features/events/models/event_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
import '../features/gamification/models/gamification_models.dart';
import '../widgets/skeleton_loader.dart';
import '../widgets/tier_avatar_ring.dart';
import 'learn_more_screen.dart';
import 'settings_screen.dart';
import '../core/api/api_endpoints.dart';
import '../core/app_decoration.dart';
import '../core/constants.dart';
import '../widgets/landscape_section_header.dart';
import '../features/share/share_rank_card.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({Key? key}) : super(key: key);
@@ -31,10 +37,35 @@ class _ProfileScreenState extends State<ProfileScreen>
String _username = '';
String _email = 'not provided';
String _profileImage = '';
String? _eventifyId;
String? _userTier;
String? _district;
DateTime? _districtChangedAt;
final ImagePicker _picker = ImagePicker();
// 14 Kerala districts
static const List<String> _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
final EventsService _eventsService = EventsService();
// AUTH-005: District change cooldown (183-day lock)
bool get _districtLocked {
if (_districtChangedAt == null) return false;
return DateTime.now().difference(_districtChangedAt!) < const Duration(days: 183);
}
String get _districtNextChange {
if (_districtChangedAt == null) return '';
final next = _districtChangedAt!.add(const Duration(days: 183));
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${next.day} ${months[next.month - 1]} ${next.year}';
}
List<EventModel> _ongoingEvents = [];
List<EventModel> _upcomingEvents = [];
List<EventModel> _pastEvents = [];
@@ -149,6 +180,21 @@ class _ProfileScreenState extends State<ProfileScreen>
_profileImage =
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
// AUTH-003/PROF-001: Eventify ID
_eventifyId = prefs.getString('eventify_id');
// PROF-004 partial: tier for avatar ring
_userTier = prefs.getString('user_tier') ?? prefs.getString('level');
// PROF-002: District
_district = prefs.getString('district');
// AUTH-005: District change cooldown
final districtChangedStr = prefs.getString('district_changed_at');
if (districtChangedStr != null) {
_districtChangedAt = DateTime.tryParse(districtChangedStr);
}
await _loadEventsForProfile(prefs);
if (mounted) setState(() {});
}
@@ -266,6 +312,8 @@ class _ProfileScreenState extends State<ProfileScreen>
}
final String path = xfile.path;
await _saveProfile(_username, _email, path);
// PROF-004: Upload to server on mobile
await _uploadProfilePhoto(path);
} catch (e) {
debugPrint('Image pick error: $e');
ScaffoldMessenger.of(context)
@@ -273,6 +321,77 @@ class _ProfileScreenState extends State<ProfileScreen>
}
}
// PROF-004: Upload profile photo to server
Future<void> _uploadProfilePhoto(String filePath) async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
if (token.isEmpty) return;
final request = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
);
request.headers['Authorization'] = 'Bearer $token';
request.files.add(await http.MultipartFile.fromPath('profile_picture', filePath));
final response = await request.send();
if (response.statusCode == 200) {
final body = await response.stream.bytesToString();
final data = jsonDecode(body) as Map<String, dynamic>;
if (data['profile_picture'] != null) {
final newUrl = data['profile_picture'].toString();
final prefs2 = await SharedPreferences.getInstance();
final currentEmail = prefs2.getString('current_email') ?? prefs2.getString('email') ?? '';
final profileImageKey = currentEmail.isNotEmpty ? 'profileImage_$currentEmail' : 'profileImage';
await prefs2.setString(profileImageKey, newUrl);
if (mounted) setState(() => _profileImage = newUrl);
}
}
} catch (e) {
debugPrint('Photo upload error: $e');
}
}
// PROF-002: Update district via API with cooldown check
Future<void> _updateDistrict(String district) async {
if (_districtLocked) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('District locked until $_districtNextChange')),
);
}
return;
}
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
final response = await http.patch(
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({'district': district}),
);
if (response.statusCode == 200) {
final now = DateTime.now();
await prefs.setString('district', district);
await prefs.setString('district_changed_at', now.toIso8601String());
if (mounted) {
setState(() {
_district = district;
_districtChangedAt = now;
});
}
}
} catch (e) {
debugPrint('District update error: $e');
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
}
}
}
Future<void> _enterAssetPathDialog() async {
final ctl = TextEditingController(text: _profileImage);
final result = await showDialog<String?>(
@@ -420,6 +539,87 @@ class _ProfileScreenState extends State<ProfileScreen>
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(height: 20),
// PROF-002: District picker
Align(
alignment: Alignment.centerLeft,
child: Text(
'District',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 8),
// AUTH-005: Cooldown lock indicator
if (_districtLocked)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
const Icon(Icons.lock_outline,
size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'District locked until $_districtNextChange',
style: const TextStyle(
fontSize: 12, color: Colors.amber),
),
],
),
),
// District pill grid
StatefulBuilder(
builder: (ctx2, setInner) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: _districts.map((d) {
final isSelected = _district == d;
return GestureDetector(
onTap: _districtLocked
? null
: () async {
setInner(() {});
await _updateDistrict(d);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: theme.cardColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: theme.dividerColor,
),
),
child: Text(
d,
style: TextStyle(
fontSize: 12,
color: isSelected
? Colors.white
: theme.textTheme.bodyMedium?.color,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
),
);
}).toList(),
);
},
),
const SizedBox(height: 8),
],
),
),
@@ -433,54 +633,22 @@ class _ProfileScreenState extends State<ProfileScreen>
});
}
// ───────── Avatar builder (reused, with size param) ─────────
// ───────── Avatar builder (AUTH-006 / PROF-004: DiceBear via TierAvatarRing) ─────────
Widget _buildProfileAvatar({double size = 96}) {
final path = _profileImage.trim();
// Resolve a network-compatible URL: http URLs pass through directly,
// file paths and assets fall back to null so DiceBear is used.
String? imageUrl;
if (path.startsWith('http')) {
return ClipOval(
child: CachedNetworkImage(
imageUrl: path,
memCacheWidth: (size * 2).toInt(),
memCacheHeight: (size * 2).toInt(),
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(width: size, height: size, color: const Color(0xFFE5E7EB)),
errorWidget: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
imageUrl = path;
}
if (kIsWeb) {
return ClipOval(
child: Image.asset(
path.isNotEmpty ? path : 'assets/images/profile.jpg',
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
}
if (path.isNotEmpty &&
(path.startsWith('/') || path.contains(Platform.pathSeparator))) {
final file = File(path);
if (file.existsSync()) {
return ClipOval(
child: Image.file(file,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
}
}
return ClipOval(
child: Image.asset('assets/images/profile.jpg',
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
return TierAvatarRing(
username: _username.isNotEmpty ? _username : _email,
tier: _userTier ?? '',
size: size,
imageUrl: imageUrl,
);
}
// ───────── Event list tile (updated styling) ─────────
@@ -636,7 +804,34 @@ class _ProfileScreenState extends State<ProfileScreen>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 40), // balance
// Share rank card button
Consumer<GamificationProvider>(
builder: (context, gam, _) => GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: ShareRankCard(
username: _username,
tier: gam.currentUserStats?.level ?? _userTier ?? '',
rank: gam.currentUserStats?.rank ?? 0,
ep: gam.profile?.currentEp ?? 0,
rewardPoints: gam.profile?.currentRp ?? 0,
),
),
);
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.share_outlined, color: Colors.white),
),
),
),
const Spacer(),
Text(
'Profile',
@@ -927,6 +1122,51 @@ class _ProfileScreenState extends State<ProfileScreen>
),
),
),
const SizedBox(height: 6),
// AUTH-003 / PROF-001: Eventify ID badge
if (_eventifyId != null && _eventifyId!.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: _eventifyId!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eventify ID copied'),
duration: Duration(seconds: 2),
),
);
},
child: Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF1E3A8A).withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF3B82F6).withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.badge_outlined,
size: 12, color: Color(0xFF93C5FD)),
const SizedBox(width: 4),
Text(
_eventifyId!,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF93C5FD),
fontFamily: 'monospace',
letterSpacing: 0.5,
),
),
],
),
),
),
),
const SizedBox(height: 8),
// Email
@@ -1581,7 +1821,10 @@ class _ProfileScreenState extends State<ProfileScreen>
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
// CustomScrollView: only visible event cards are built — no full-tree Column renders
body: CustomScrollView(
// SafeArea(bottom: false) — bottom is handled by home screen's floating nav buffer
body: SafeArea(
bottom: false,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// Header gradient + Profile card overlap (same visual as before)
@@ -1667,6 +1910,7 @@ class _ProfileScreenState extends State<ProfileScreen>
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -1688,7 +1932,56 @@ class _ProfileScreenState extends State<ProfileScreen>
children: [
_gamStatCard('Lifetime EP', '${p.lifetimeEp}', Icons.star, const Color(0xFFF59E0B), theme),
const SizedBox(width: 10),
_gamStatCard('Liquid EP', '${p.currentEp}', Icons.bolt, const Color(0xFF3B82F6), theme),
// GAM-003 + GAM-004: Liquid EP with cycle countdown and progress bar
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.2)),
),
child: Column(
children: [
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 22),
const SizedBox(height: 4),
Text(
'${p.currentEp}',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: theme.textTheme.bodyLarge?.color),
),
const SizedBox(height: 2),
Text('Liquid EP', style: TextStyle(color: theme.hintColor, fontSize: 10), textAlign: TextAlign.center),
if (gp.currentUserStats?.rewardCycleDays != null) ...[
const SizedBox(height: 4),
Text(
'Converts in ${gp.currentUserStats!.rewardCycleDays}d',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Builder(
builder: (_) {
final days = gp.currentUserStats?.rewardCycleDays ?? 30;
final elapsed = (30 - days).clamp(0, 30);
final ratio = elapsed / 30;
return LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
ratio > 0.7 ? const Color(0xFFFBBF24) : const Color(0xFF3B82F6),
),
);
},
),
),
],
],
),
),
),
const SizedBox(width: 10),
_gamStatCard('Reward Pts', '${p.currentRp}', Icons.redeem, const Color(0xFF10B981), theme),
],

View File

@@ -1,6 +1,8 @@
// lib/screens/search_screen.dart
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/utils/error_utils.dart';
// Location packages
@@ -46,50 +48,41 @@ class _SearchScreenState extends State<SearchScreen> {
'Kottayam',
];
/// Searchable location database Kerala towns/cities with pincodes.
static const List<_LocationItem> _locationDb = [
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
];
/// Searchable location database loaded from assets/data/kerala_pincodes.json.
List<_LocationItem> _locationDb = [];
bool _pinsLoaded = false;
List<_LocationItem> _searchResults = [];
bool _showSearchResults = false;
bool _loadingLocation = false;
@override
void initState() {
super.initState();
_loadKeralaData();
}
Future<void> _loadKeralaData() async {
if (_pinsLoaded) return;
try {
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
final List<dynamic> list = jsonDecode(jsonStr);
final loaded = list.map((e) => _LocationItem(
city: e['city'] as String,
district: e['district'] as String?,
pincode: e['pincode'] as String?,
)).toList();
if (mounted) {
setState(() {
_locationDb = loaded;
_pinsLoaded = true;
});
}
} catch (_) {
// fallback: keep empty list, search won't crash
}
}
@override
void dispose() {
_ctrl.dispose();

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
void showEventifyBottomSheet(
BuildContext context, {
required String title,
required Widget child,
double initialSize = 0.5,
double minSize = 0.3,
double maxSize = 0.9,
bool isDismissible = true,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: isDismissible,
backgroundColor: Colors.transparent,
builder: (_) => DraggableScrollableSheet(
initialChildSize: initialSize,
minChildSize: minSize,
maxChildSize: maxSize,
expand: false,
builder: (_, scrollController) => _EventifyBottomSheetContent(
title: title,
child: child,
scrollController: scrollController,
),
),
);
}
class _EventifyBottomSheetContent extends StatelessWidget {
const _EventifyBottomSheetContent({
required this.title,
required this.child,
required this.scrollController,
});
final String title;
final Widget child;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFF0F172A),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white54),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
child: child,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class GlassCard extends StatelessWidget {
const GlassCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.borderRadius = 16,
this.blur = 10,
this.backgroundColor,
this.borderColor,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final double borderRadius;
final double blur;
final Color? backgroundColor;
final Color? borderColor;
@override
Widget build(BuildContext context) {
final effectiveBackground =
backgroundColor ?? const Color(0xFF1E293B).withOpacity(0.6);
final effectiveBorder =
borderColor ?? Colors.white.withOpacity(0.08);
Widget card = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: effectiveBackground,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: effectiveBorder, width: 1),
),
child: child,
),
),
);
if (margin != null) {
return Container(margin: margin, child: card);
}
return card;
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
class TierAvatarRing extends StatelessWidget {
final String username;
final String tier;
final double size;
final bool showDiceBear;
final String? imageUrl;
final VoidCallback? onTap;
static const Map<String, Color> _tierColors = {
'Bronze': Color(0xFFFED7AA),
'Silver': Color(0xFFE2E8F0),
'Gold': Color(0xFFFEF3C7),
'Platinum': Color(0xFFEDE9FE),
'Diamond': Color(0xFFE0E7FF),
};
static const Color _fallbackColor = Color(0xFF475569);
const TierAvatarRing({
super.key,
required this.username,
required this.tier,
this.size = 48.0,
this.showDiceBear = true,
this.imageUrl,
this.onTap,
});
Color get _ringColor => _tierColors[tier] ?? _fallbackColor;
String get _avatarUrl {
if (imageUrl != null && imageUrl!.isNotEmpty) {
return imageUrl!;
}
return 'https://api.dicebear.com/9.x/notionists/svg?seed=$username';
}
Widget _buildAvatar() {
final double radius = size / 2 - 5;
if (!showDiceBear) {
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: Icon(
Icons.person,
color: Colors.white54,
size: size * 0.5,
),
);
}
return CachedNetworkImage(
imageUrl: _avatarUrl,
imageBuilder: (context, imageProvider) => CircleAvatar(
radius: radius,
backgroundImage: imageProvider,
),
placeholder: (context, url) => CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: SizedBox(
width: size * 0.4,
height: size * 0.4,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white38,
),
),
),
errorWidget: (context, url, error) => CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: Icon(
Icons.person,
color: Colors.white54,
size: size * 0.5,
),
),
);
}
@override
Widget build(BuildContext context) {
final Color ringColor = _ringColor;
final double containerSize = size + 6;
final Widget ring = Container(
width: containerSize,
height: containerSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: ringColor, width: 3),
boxShadow: [
BoxShadow(
color: ringColor.withOpacity(0.4),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Center(child: _buildAvatar()),
);
if (onTap != null) {
return GestureDetector(
onTap: onTap,
child: ring,
);
}
return ring;
}
}

View File

@@ -641,7 +641,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@@ -19,6 +19,7 @@ dependencies:
google_maps_flutter: ^2.5.0
url_launcher: ^6.2.1
share_plus: ^7.2.1
path_provider: ^2.1.0
provider: ^6.1.2
video_player: ^2.8.1
cached_network_image: ^3.3.1
@@ -39,6 +40,7 @@ flutter:
- assets/images/
- assets/icon/hand_stop.svg
- assets/login-bg.mp4
- assets/data/kerala_pincodes.json
fonts:
- family: Gilroy
fonts: