Initial commit: Eventify frontend
This commit is contained in:
183
lib/core/api/api_client.dart
Normal file
183
lib/core/api/api_client.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
// lib/core/api/api_client.dart
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
/// POST request
|
||||
///
|
||||
/// - `url` should be a fully qualified endpoint (ApiEndpoints.*)
|
||||
/// - `body` is the JSON object to send (Map)
|
||||
/// - when `requiresAuth == true` token & username are added to the request body
|
||||
Future<Map<String, dynamic>> post(
|
||||
String url, {
|
||||
Map<String, dynamic>? body,
|
||||
bool requiresAuth = true,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
final Map<String, dynamic> finalBody = await _buildAuthBody(body, requiresAuth);
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: headers,
|
||||
body: jsonEncode(finalBody),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
|
||||
throw Exception('Network error: $e');
|
||||
}
|
||||
|
||||
return _handleResponse(url, response, finalBody);
|
||||
}
|
||||
|
||||
/// GET request
|
||||
///
|
||||
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
||||
/// - `params` will be appended as query parameters.
|
||||
Future<Map<String, dynamic>> get(
|
||||
String url, {
|
||||
Map<String, dynamic>? params,
|
||||
bool requiresAuth = true,
|
||||
}) async {
|
||||
// build final query params including auth if needed
|
||||
final Map<String, dynamic> finalParams = {};
|
||||
|
||||
if (requiresAuth) {
|
||||
final token = await TokenStorage.getToken();
|
||||
final username = await TokenStorage.getUsername();
|
||||
if (token == null || username == null) {
|
||||
throw Exception('Authentication required');
|
||||
}
|
||||
finalParams['token'] = token;
|
||||
finalParams['username'] = username;
|
||||
}
|
||||
|
||||
if (params != null) finalParams.addAll(params);
|
||||
|
||||
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
response = await http.get(uri).timeout(_timeout);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('ApiClient.get network error: $e');
|
||||
throw Exception('Network error: $e');
|
||||
}
|
||||
|
||||
return _handleResponse(url, response, finalParams);
|
||||
}
|
||||
|
||||
/// Build request body and attach token + username if required
|
||||
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
||||
final Map<String, dynamic> finalBody = {};
|
||||
|
||||
if (requiresAuth) {
|
||||
final token = await TokenStorage.getToken();
|
||||
final username = await TokenStorage.getUsername();
|
||||
|
||||
if (token == null || username == null) {
|
||||
throw Exception('Authentication required');
|
||||
}
|
||||
|
||||
finalBody['token'] = token;
|
||||
finalBody['username'] = username;
|
||||
}
|
||||
|
||||
if (body != null) finalBody.addAll(body);
|
||||
|
||||
return finalBody;
|
||||
}
|
||||
|
||||
/// Centralized response handling and error parsing
|
||||
Map<String, dynamic> _handleResponse(String url, http.Response response, Map<String, dynamic> requestBody) {
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (e) {
|
||||
decoded = response.body;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('API -> $url');
|
||||
debugPrint('Status: ${response.statusCode}');
|
||||
debugPrint('Request body: ${jsonEncode(requestBody)}');
|
||||
debugPrint('Response body: ${response.body}');
|
||||
}
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
return {'data': decoded};
|
||||
}
|
||||
|
||||
// Build human-friendly message from common server patterns
|
||||
String errorMessage = 'Request failed (status ${response.statusCode})';
|
||||
|
||||
if (decoded is Map) {
|
||||
// 1) If there's an explicit top-level 'message' (string), prefer it
|
||||
if (decoded.containsKey('message') && decoded['message'] is String) {
|
||||
errorMessage = decoded['message'] as String;
|
||||
}
|
||||
// 2) If 'errors' exists and is a map, collect inner messages
|
||||
else if (decoded.containsKey('errors')) {
|
||||
final errs = decoded['errors'];
|
||||
final messages = <String>[];
|
||||
if (errs is String) {
|
||||
messages.add(errs);
|
||||
} else if (errs is List) {
|
||||
for (final e in errs) messages.add(e.toString());
|
||||
} else if (errs is Map) {
|
||||
// collect first-level messages (prefer the text, not the key)
|
||||
errs.forEach((k, v) {
|
||||
if (v is List && v.isNotEmpty) {
|
||||
messages.add(v.first.toString());
|
||||
} else if (v is String) {
|
||||
messages.add(v);
|
||||
} else {
|
||||
messages.add(v.toString());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
messages.add(errs.toString());
|
||||
}
|
||||
if (messages.isNotEmpty) {
|
||||
errorMessage = messages.join(' | ');
|
||||
}
|
||||
}
|
||||
// 3) If '__all__' present (DRF default), show it
|
||||
else if (decoded.containsKey('__all__')) {
|
||||
final all = decoded['__all__'];
|
||||
if (all is List) {
|
||||
errorMessage = all.join(' | ');
|
||||
} else {
|
||||
errorMessage = all.toString();
|
||||
}
|
||||
}
|
||||
// 4) fallback - join map values' messages (prefer strings inside lists)
|
||||
else {
|
||||
final messages = <String>[];
|
||||
decoded.forEach((k, v) {
|
||||
if (v is List && v.isNotEmpty) {
|
||||
messages.add(v.first.toString());
|
||||
} else {
|
||||
messages.add(v.toString());
|
||||
}
|
||||
});
|
||||
errorMessage = messages.isNotEmpty ? messages.join(' | ') : decoded.toString();
|
||||
}
|
||||
} else if (decoded is String) {
|
||||
errorMessage = decoded;
|
||||
}
|
||||
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
26
lib/core/api/api_endpoints.dart
Normal file
26
lib/core/api/api_endpoints.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// lib/core/api/api_endpoints.dart
|
||||
class ApiEndpoints {
|
||||
// Change this to your desired backend base URL (local or UAT)
|
||||
// For local Django dev use: "http://127.0.0.1:8000/api"
|
||||
// For UAT: "https://uat.eventifyplus.com/api"
|
||||
static const String baseUrl = "https://uat.eventifyplus.com/api";
|
||||
|
||||
// Auth
|
||||
static const String register = "$baseUrl/user/register/";
|
||||
static const String login = "$baseUrl/user/login/";
|
||||
static const String logout = "$baseUrl/user/logout/";
|
||||
static const String status = "$baseUrl/user/status/";
|
||||
|
||||
// Events
|
||||
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
||||
static const String eventsByPincode = "$baseUrl/events/pincode-events/"; // pincode-events
|
||||
static const String eventDetails = "$baseUrl/events/event-details/"; // event-details
|
||||
static const String eventImages = "$baseUrl/events/event-images/"; // event-images
|
||||
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
||||
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
||||
|
||||
// Bookings
|
||||
// static const String bookEvent = "$baseUrl/events/book-event/";
|
||||
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
|
||||
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
|
||||
}
|
||||
21
lib/core/app_decoration.dart
Normal file
21
lib/core/app_decoration.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppDecoration {
|
||||
static const BoxDecoration blueGradient = BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/images/gradient_dark_blue.png'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
|
||||
/// Returns a BoxDecoration with the gradient image and specific border radius
|
||||
static BoxDecoration blueGradientRounded(double radius) {
|
||||
return BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/gradient_dark_blue.png'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/core/constants.dart
Normal file
25
lib/core/constants.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppConstants {
|
||||
// Layout
|
||||
static const double desktopBreakpoint = 820;
|
||||
static const double tabletBreakpoint = 600;
|
||||
|
||||
// Padding & Radius
|
||||
static const double defaultPadding = 16;
|
||||
static const double cardRadius = 14;
|
||||
|
||||
// Animation Durations
|
||||
static const Duration fastAnimation = Duration(milliseconds: 200);
|
||||
static const Duration normalAnimation = Duration(milliseconds: 350);
|
||||
static const Duration slowAnimation = Duration(milliseconds: 600);
|
||||
|
||||
// Colors
|
||||
static const Color primaryColor = Color(0xFF2563EB); // Blue-600
|
||||
static const Color backgroundColor = Color(0xFFF9FAFB);
|
||||
static const Color textPrimary = Color(0xFF111827);
|
||||
static const Color textSecondary = Color(0xFF6B7280);
|
||||
|
||||
// API
|
||||
static const int apiTimeoutSeconds = 30;
|
||||
}
|
||||
62
lib/core/storage/token_storage.dart
Normal file
62
lib/core/storage/token_storage.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// lib/core/storage/token_storage.dart
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class TokenStorage {
|
||||
static const _tokenKey = "auth_token";
|
||||
static const _authUsernameKey = "auth_username"; // new key used for backend identity
|
||||
static const _oldUsernameKey = "username"; // old key (may have been used for UI or auth)
|
||||
static const _displayNameKey = "display_name"; // UI display name
|
||||
|
||||
/// Save token and backend username.
|
||||
/// Will save `auth_username` always. It will only overwrite the old `username` key
|
||||
/// if that key is missing or already looks like an email (avoid overwriting user display names).
|
||||
static Future<void> saveToken(String token, String username) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, token);
|
||||
await prefs.setString(_authUsernameKey, username);
|
||||
|
||||
final oldVal = prefs.getString(_oldUsernameKey);
|
||||
// If old key not present OR old value looks like an email (so it's probably the backend username),
|
||||
// keep it in sync. Do not overwrite if it's a display name.
|
||||
if (oldVal == null || oldVal.contains('@')) {
|
||||
await prefs.setString(_oldUsernameKey, username);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> getToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_tokenKey);
|
||||
}
|
||||
|
||||
/// Return backend username (auth identity).
|
||||
/// Tries new key first, falls back to old key for compatibility.
|
||||
static Future<String?> getUsername() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final auth = prefs.getString(_authUsernameKey);
|
||||
if (auth != null && auth.isNotEmpty) return auth;
|
||||
// fallback (older apps used 'username' key)
|
||||
final old = prefs.getString(_oldUsernameKey);
|
||||
return old;
|
||||
}
|
||||
|
||||
/// Helper to read display name (UI)
|
||||
static Future<String?> getDisplayName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_displayNameKey);
|
||||
}
|
||||
|
||||
/// Save display name for UI (do NOT use this key for backend authentication)
|
||||
static Future<void> saveDisplayName(String displayName) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_displayNameKey, displayName);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Intentionally clear only auth-related keys (keeps user display name if you want)
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_authUsernameKey);
|
||||
// do NOT remove _oldUsernameKey or _displayNameKey automatically to avoid losing UI settings
|
||||
}
|
||||
}
|
||||
32
lib/core/theme_manager.dart
Normal file
32
lib/core/theme_manager.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
// lib/core/theme_manager.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ThemeManager {
|
||||
ThemeManager._();
|
||||
|
||||
static const String _prefKey = 'is_dark_mode';
|
||||
static final ValueNotifier<ThemeMode> themeMode = ValueNotifier(ThemeMode.light);
|
||||
|
||||
/// Call during app startup to load saved preference.
|
||||
static Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isDark = prefs.getBool(_prefKey) ?? false;
|
||||
themeMode.value = isDark ? ThemeMode.dark : ThemeMode.light;
|
||||
}
|
||||
|
||||
/// Set theme and persist
|
||||
static Future<void> setThemeMode(ThemeMode mode) async {
|
||||
themeMode.value = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKey, mode == ThemeMode.dark);
|
||||
}
|
||||
|
||||
static bool get isDark => themeMode.value == ThemeMode.dark;
|
||||
|
||||
/// Toggle helper
|
||||
static Future<void> toggle() async {
|
||||
final newMode = themeMode.value == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
|
||||
await setThemeMode(newMode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user