mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -1,13 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import '../../features/auth/presentation/auth_gate.dart';
|
||||
import '../../features/auth/presentation/auth_choice_screen.dart';
|
||||
import '../../features/auth/presentation/sign_in_screen.dart';
|
||||
import '../../features/auth/presentation/sign_up_screen.dart';
|
||||
import '../../features/auth/presentation/auth_loading_screen.dart';
|
||||
import '../../features/onboarding/presentation/onboarding_intro_screen.dart';
|
||||
import '../../features/onboarding/presentation/onboarding_how_it_works_screen.dart';
|
||||
import '../../features/onboarding/presentation/onboarding_motivation_screen.dart';
|
||||
import '../../features/countdown/presentation/home_countdown_screen.dart';
|
||||
import '../../features/goals/presentation/goals_list_screen.dart';
|
||||
import '../../features/goals/presentation/goal_edit_screen.dart';
|
||||
import '../../features/goals/presentation/bucket_goal_create_screen.dart';
|
||||
import '../../features/goals/presentation/goal_detail_screen.dart';
|
||||
import '../../features/goals/presentation/location_picker_screen.dart';
|
||||
import '../../features/goals/presentation/osm_location_picker_screen.dart';
|
||||
import '../../features/social/presentation/social_feed_screen.dart';
|
||||
import '../../features/social/presentation/leaderboards_screen.dart';
|
||||
import '../../features/social/presentation/public_profile_screen.dart';
|
||||
import '../../features/profile/presentation/profile_screen.dart';
|
||||
import '../../features/profile/presentation/profile_setup_screen.dart';
|
||||
import '../../features/countdown/presentation/bucket_list_confirmation_screen.dart';
|
||||
import '../../features/settings/presentation/settings_home_screen.dart';
|
||||
import '../../features/settings/presentation/appearance_settings_screen.dart';
|
||||
import '../../features/settings/presentation/notification_settings_screen.dart';
|
||||
import '../../features/settings/presentation/privacy_settings_screen.dart';
|
||||
import '../../features/settings/presentation/about_challenge_screen.dart';
|
||||
import '../../features/achievements/presentation/achievements_screen.dart';
|
||||
import '../../features/analytics/presentation/insights_screen.dart';
|
||||
import '../../features/ai_chat/presentation/ai_chat_screen.dart';
|
||||
import '../../features/calendar/presentation/calendar_screen.dart';
|
||||
import '../../features/voice_recording/presentation/voice_recording_screen.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
@@ -17,10 +42,42 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/',
|
||||
builder: (context, state) => const AuthGate(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth-choice',
|
||||
builder: (context, state) => const AuthChoiceScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/sign-in',
|
||||
builder: (context, state) => const SignInScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/sign-up',
|
||||
builder: (context, state) => const SignUpScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth-loading',
|
||||
builder: (context, state) => const AuthLoadingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding',
|
||||
builder: (context, state) => const OnboardingIntroScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/how-it-works',
|
||||
builder: (context, state) => const OnboardingHowItWorksScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/motivation',
|
||||
builder: (context, state) => const OnboardingMotivationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile-setup',
|
||||
builder: (context, state) => const ProfileSetupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/bucket-list-confirmation',
|
||||
builder: (context, state) => const BucketListConfirmationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const HomeCountdownScreen(),
|
||||
@@ -29,10 +86,65 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/goals',
|
||||
builder: (context, state) => const GoalsListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/goals/create',
|
||||
builder: (context, state) => const BucketGoalCreateScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/goals/:id',
|
||||
builder: (context, state) {
|
||||
final goalId = state.pathParameters['id']!;
|
||||
return GoalDetailScreen(goalId: goalId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/goals/:id/edit',
|
||||
builder: (context, state) {
|
||||
final goalId = state.pathParameters['id']!;
|
||||
return GoalEditScreen(goalId: goalId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/location-picker',
|
||||
builder: (context, state) {
|
||||
final lat = state.uri.queryParameters['lat'];
|
||||
final lng = state.uri.queryParameters['lng'];
|
||||
LatLng? initialPosition;
|
||||
if (lat != null && lng != null) {
|
||||
initialPosition = LatLng(
|
||||
double.parse(lat),
|
||||
double.parse(lng),
|
||||
);
|
||||
}
|
||||
return LocationPickerScreen(initialPosition: initialPosition);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/osm-location-picker',
|
||||
builder: (context, state) {
|
||||
final lat = state.uri.queryParameters['lat'];
|
||||
final lng = state.uri.queryParameters['lng'];
|
||||
return OsmLocationPickerScreen(
|
||||
initialLatitude: lat != null ? double.tryParse(lat) : null,
|
||||
initialLongitude: lng != null ? double.tryParse(lng) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/social',
|
||||
builder: (context, state) => const SocialFeedScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/social/leaderboards',
|
||||
builder: (context, state) => const LeaderboardsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/u/:userId',
|
||||
builder: (context, state) {
|
||||
final userId = state.pathParameters['userId']!;
|
||||
return PublicProfileScreen(userId: userId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
@@ -41,6 +153,44 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/appearance',
|
||||
builder: (context, state) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/notifications',
|
||||
builder: (context, state) => const NotificationSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/privacy',
|
||||
builder: (context, state) => const PrivacySettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/about',
|
||||
builder: (context, state) => const AboutChallengeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/achievements',
|
||||
builder: (context, state) => const AchievementsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/insights',
|
||||
builder: (context, state) => const InsightsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/ai-chat',
|
||||
builder: (context, state) => const AIChatScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calendar',
|
||||
builder: (context, state) => CalendarScreen(
|
||||
initialGoalId: state.uri.queryParameters['goalId'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/recording',
|
||||
builder: (context, state) => const VoiceRecordingScreen(),
|
||||
),
|
||||
],
|
||||
errorBuilder: (context, state) => Scaffold(
|
||||
body: Center(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
final Map<String, dynamic> _userProperties = {};
|
||||
|
||||
Future<void> initialize() async {
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
void setUserId(String userId) {
|
||||
_userProperties['user_id'] = userId;
|
||||
}
|
||||
|
||||
void setUserProperty(String name, dynamic value) {
|
||||
_userProperties[name] = value;
|
||||
}
|
||||
|
||||
void logEvent(String eventName, {Map<String, dynamic>? parameters}) {
|
||||
if (!_isInitialized) {
|
||||
developer.log(
|
||||
'Analytics not initialized. Event: $eventName',
|
||||
name: 'AnalyticsService',
|
||||
level: 900, // warning
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final eventData = {
|
||||
'event_name': eventName,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
..._userProperties,
|
||||
if (parameters != null) ...parameters,
|
||||
};
|
||||
|
||||
developer.log(
|
||||
'Analytics Event: $eventData',
|
||||
name: 'AnalyticsService',
|
||||
level: 800, // info
|
||||
);
|
||||
}
|
||||
|
||||
void logSignUp({required String method}) {
|
||||
logEvent('sign_up', parameters: {
|
||||
'method': method,
|
||||
});
|
||||
}
|
||||
|
||||
void logSignIn({required String method}) {
|
||||
logEvent('sign_in', parameters: {
|
||||
'method': method,
|
||||
});
|
||||
}
|
||||
|
||||
void logSignOut() {
|
||||
logEvent('sign_out');
|
||||
}
|
||||
|
||||
void logGoalCreated({required String goalId, required String hasLocation, required String hasImage}) {
|
||||
logEvent('goal_created', parameters: {
|
||||
'goal_id': goalId,
|
||||
'has_location': hasLocation,
|
||||
'has_image': hasImage,
|
||||
});
|
||||
}
|
||||
|
||||
void logGoalUpdated({required String goalId}) {
|
||||
logEvent('goal_updated', parameters: {
|
||||
'goal_id': goalId,
|
||||
});
|
||||
}
|
||||
|
||||
void logGoalCompleted({required String goalId, required int daysInChallenge}) {
|
||||
logEvent('goal_completed', parameters: {
|
||||
'goal_id': goalId,
|
||||
'days_in_challenge': daysInChallenge,
|
||||
});
|
||||
}
|
||||
|
||||
void logGoalDeleted({required String goalId}) {
|
||||
logEvent('goal_deleted', parameters: {
|
||||
'goal_id': goalId,
|
||||
});
|
||||
}
|
||||
|
||||
void logCountdownStarted({required String startDate, required String endDate}) {
|
||||
logEvent('countdown_started', parameters: {
|
||||
'start_date': startDate,
|
||||
'end_date': endDate,
|
||||
});
|
||||
}
|
||||
|
||||
void logCountdownViewed() {
|
||||
logEvent('countdown_viewed');
|
||||
}
|
||||
|
||||
void logProfileUpdated({required String fieldsUpdated}) {
|
||||
logEvent('profile_updated', parameters: {
|
||||
'fields_updated': fieldsUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
void logProfileVisibilityChanged({required bool isPublic}) {
|
||||
logEvent('profile_visibility_changed', parameters: {
|
||||
'is_public': isPublic,
|
||||
});
|
||||
}
|
||||
|
||||
void logOnboardingCompleted() {
|
||||
logEvent('onboarding_completed');
|
||||
}
|
||||
|
||||
void logOnboardingStepCompleted({required String stepName}) {
|
||||
logEvent('onboarding_step_completed', parameters: {
|
||||
'step_name': stepName,
|
||||
});
|
||||
}
|
||||
|
||||
void logSettingsChanged({required String settingName, required String value}) {
|
||||
logEvent('settings_changed', parameters: {
|
||||
'setting_name': settingName,
|
||||
'value': value,
|
||||
});
|
||||
}
|
||||
|
||||
void logNotificationEnabled({required String notificationType}) {
|
||||
logEvent('notification_enabled', parameters: {
|
||||
'notification_type': notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
void logNotificationDisabled({required String notificationType}) {
|
||||
logEvent('notification_disabled', parameters: {
|
||||
'notification_type': notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
void logError({required String error, String? context}) {
|
||||
logEvent('error', parameters: {
|
||||
'error_message': error,
|
||||
if (context != null) 'context': context,
|
||||
});
|
||||
}
|
||||
|
||||
void logScreenView({required String screenName}) {
|
||||
logEvent('screen_view', parameters: {
|
||||
'screen_name': screenName,
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_userProperties.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
enum NotificationFrequency { daily, weekly, custom }
|
||||
|
||||
enum NotificationType {
|
||||
countdownReminder,
|
||||
milestoneReminder,
|
||||
streakReminder,
|
||||
countdownCheckpoint,
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final StreamController<String?> _onNotificationClickController =
|
||||
StreamController<String?>.broadcast();
|
||||
bool _isInitialized = false;
|
||||
|
||||
Stream<String?> get onNotificationClick => _onNotificationClickController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
tz.initializeTimeZones();
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: (NotificationResponse response) {
|
||||
_onNotificationClickController.add(response.payload);
|
||||
},
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final android = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final result = await android?.requestNotificationsPermission();
|
||||
|
||||
return result ?? true;
|
||||
}
|
||||
|
||||
Future<void> scheduleCountdownReminder({
|
||||
required NotificationFrequency frequency,
|
||||
required String title,
|
||||
required String body,
|
||||
int hour = 9,
|
||||
int minute = 0,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'countdown_reminders',
|
||||
'Countdown Reminders',
|
||||
channelDescription: 'Reminders for your 1356-day countdown',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
switch (frequency) {
|
||||
case NotificationFrequency.daily:
|
||||
await _notificationsPlugin.zonedSchedule(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
_nextInstanceOfTime(hour, minute),
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
);
|
||||
break;
|
||||
case NotificationFrequency.weekly:
|
||||
await _notificationsPlugin.zonedSchedule(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
_nextInstanceOfDayAndTime(hour, minute),
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime,
|
||||
);
|
||||
break;
|
||||
case NotificationFrequency.custom:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scheduleMilestoneReminder({
|
||||
required String goalId,
|
||||
required String goalTitle,
|
||||
required DateTime dueDate,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'milestone_reminders',
|
||||
'Milestone Reminders',
|
||||
channelDescription: 'Reminders for your goal milestones',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.zonedSchedule(
|
||||
int.parse(goalId.replaceAll('-', '')),
|
||||
'Milestone Due Soon',
|
||||
'Your goal "$goalTitle" has an upcoming milestone!',
|
||||
tz.TZDateTime.from(dueDate, tz.local).subtract(const Duration(days: 1)),
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> scheduleStreakReminder({
|
||||
required int streakDays,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'streak_reminders',
|
||||
'Streak Reminders',
|
||||
channelDescription: 'Celebrations for your active streaks',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
'🔥 $streakDays Day Streak!',
|
||||
'Keep going! You\'re on fire!',
|
||||
notificationDetails,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> scheduleCountdownCheckpoint({
|
||||
required String checkpointType,
|
||||
required DateTime checkpointDate,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'countdown_checkpoints',
|
||||
'Countdown Checkpoints',
|
||||
channelDescription: 'Important countdown milestones',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
String title;
|
||||
String body;
|
||||
|
||||
switch (checkpointType) {
|
||||
case '50_percent':
|
||||
title = 'Halfway There! 🎉';
|
||||
body = 'You\'ve completed 50% of your 1356-day journey!';
|
||||
break;
|
||||
case '25_percent':
|
||||
title = '25% Remaining ⏰';
|
||||
body = 'Only 25% of your challenge remains. Make it count!';
|
||||
break;
|
||||
default:
|
||||
title = 'Countdown Milestone';
|
||||
body = 'An important milestone in your journey has been reached!';
|
||||
}
|
||||
|
||||
await _notificationsPlugin.zonedSchedule(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(checkpointDate, tz.local),
|
||||
notificationDetails,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelAll() async {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
}
|
||||
|
||||
Future<void> cancel(int id) async {
|
||||
await _notificationsPlugin.cancel(id);
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
var scheduledDate = tz.TZDateTime(
|
||||
tz.local,
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
);
|
||||
|
||||
if (scheduledDate.isBefore(now)) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfDayAndTime(int hour, int minute) {
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
var scheduledDate = tz.TZDateTime(
|
||||
tz.local,
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
);
|
||||
|
||||
if (scheduledDate.isBefore(now)) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 7));
|
||||
}
|
||||
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_onNotificationClickController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.light);
|
||||
|
||||
@@ -1,160 +1,470 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const Color primaryColor = Color(0xFF6366F1);
|
||||
static const Color secondaryColor = Color(0xFF8B5CF6);
|
||||
static const Color accentColor = Color(0xFFEC4899);
|
||||
static const Color backgroundColor = Color(0xFFFAFAFA);
|
||||
static const Color primaryColor = Color(0xFF111827);
|
||||
static const Color secondaryColor = Color(0xFF4B5563);
|
||||
static const Color accentColor = Color(0xFF38BDF8);
|
||||
static const Color backgroundColor = Color(0xFFF5F5F5);
|
||||
static const Color surfaceColor = Color(0xFFFFFFFF);
|
||||
static const Color errorColor = Color(0xFFEF4444);
|
||||
static const Color warningColor = Color(0xFFF59E0B);
|
||||
static const Color successColor = Color(0xFF10B981);
|
||||
|
||||
static const Color pastelBlue = Color(0xFFBFDBFE);
|
||||
static const Color pastelGreen = Color(0xFFBBF7D0);
|
||||
static const Color pastelPurple = Color(0xFFDDD6FE);
|
||||
static const Color pastelPink = Color(0xFFFBCFE8);
|
||||
static const Color pastelYellow = Color(0xFFFDE68A);
|
||||
|
||||
static const Color neonGreen = Color(0xFF39FF14);
|
||||
static const Color neonBlue = Color(0xFF00F0FF);
|
||||
static const Color neonPink = Color(0xFFFF10F0);
|
||||
|
||||
static const ColorScheme lightColorScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: primaryColor,
|
||||
onPrimary: Color(0xFFFFFFFF),
|
||||
secondary: secondaryColor,
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondary: accentColor,
|
||||
onSecondary: Color(0xFF111827),
|
||||
error: errorColor,
|
||||
onError: Color(0xFFFFFFFF),
|
||||
surface: surfaceColor,
|
||||
onSurface: Color(0xFF1F2937),
|
||||
onSurface: Color(0xFF111827),
|
||||
background: backgroundColor,
|
||||
onBackground: Color(0xFF1F2937),
|
||||
onBackground: Color(0xFF111827),
|
||||
);
|
||||
|
||||
static const ColorScheme darkColorScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: primaryColor,
|
||||
onPrimary: Color(0xFFFFFFFF),
|
||||
secondary: secondaryColor,
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
primary: accentColor,
|
||||
onPrimary: Color(0xFF020617),
|
||||
secondary: accentColor,
|
||||
onSecondary: Color(0xFF020617),
|
||||
error: errorColor,
|
||||
onError: Color(0xFFFFFFFF),
|
||||
surface: Color(0xFF1F2937),
|
||||
onSurface: Color(0xFFF9FAFB),
|
||||
background: Color(0xFF111827),
|
||||
onBackground: Color(0xFFF9FAFB),
|
||||
surface: Color(0xFF020617),
|
||||
onSurface: Color(0xFFE5E7EB),
|
||||
background: Color(0xFF020617),
|
||||
onBackground: Color(0xFFE5E7EB),
|
||||
);
|
||||
|
||||
static ThemeData get light {
|
||||
final textTheme = _buildLightTextTheme();
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: lightColorScheme,
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: surfaceColor,
|
||||
foregroundColor: Color(0xFF1F2937),
|
||||
foregroundColor: Color(0xFF111827),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: surfaceColor,
|
||||
elevation: 2,
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.all(0),
|
||||
shadowColor: Colors.black.withValues(alpha: 0.06),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
backgroundColor: surfaceColor,
|
||||
indicatorColor: primaryColor.withValues(alpha: 0.14),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
iconTheme: MaterialStateProperty.resolveWith(
|
||||
(states) {
|
||||
final color = states.contains(MaterialState.selected)
|
||||
? const Color(0xFF111827)
|
||||
: const Color(0xFF9CA3AF);
|
||||
return IconThemeData(
|
||||
color: color,
|
||||
size: 24,
|
||||
);
|
||||
},
|
||||
),
|
||||
labelTextStyle: MaterialStateProperty.resolveWith(
|
||||
(states) {
|
||||
final color = states.contains(MaterialState.selected)
|
||||
? const Color(0xFF111827)
|
||||
: const Color(0xFF9CA3AF);
|
||||
return GoogleFonts.spaceGrotesk(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
letterSpacing: 0.2,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Color(0xFFFFFFFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
textStyle: GoogleFonts.spaceGrotesk(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1F2937),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceColor,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1F2937),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1F2937),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide:
|
||||
BorderSide(color: primaryColor.withValues(alpha: 0.7), width: 1.5),
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6B7280),
|
||||
hintStyle: textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData.fromDefaults(
|
||||
secondaryColor: primaryColor,
|
||||
brightness: Brightness.light,
|
||||
labelStyle: textTheme.bodyMedium!,
|
||||
).copyWith(
|
||||
backgroundColor: const Color(0xFFF3F4F6),
|
||||
selectedColor: primaryColor.withValues(alpha: 0.16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
textTheme: textTheme,
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get dark {
|
||||
final textTheme = _buildDarkTextTheme();
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: darkColorScheme,
|
||||
scaffoldBackgroundColor: darkColorScheme.background,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1F2937),
|
||||
backgroundColor: Color(0xFF020617),
|
||||
foregroundColor: Color(0xFFF9FAFB),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF374151),
|
||||
elevation: 2,
|
||||
color: const Color(0xFF020617),
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.all(0),
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
backgroundColor: const Color(0xFF020617),
|
||||
indicatorColor: primaryColor.withValues(alpha: 0.18),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
iconTheme: MaterialStateProperty.resolveWith(
|
||||
(states) {
|
||||
final color = states.contains(MaterialState.selected)
|
||||
? primaryColor
|
||||
: const Color(0xFF6B7280);
|
||||
return IconThemeData(
|
||||
color: color,
|
||||
size: 24,
|
||||
);
|
||||
},
|
||||
),
|
||||
labelTextStyle: MaterialStateProperty.resolveWith(
|
||||
(states) {
|
||||
final color = states.contains(MaterialState.selected)
|
||||
? primaryColor
|
||||
: const Color(0xFF9CA3AF);
|
||||
return GoogleFonts.spaceGrotesk(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
letterSpacing: 0.2,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Color(0xFFFFFFFF),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF020617),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
textStyle: GoogleFonts.spaceGrotesk(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFF9FAFB),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Color(0xFF020617),
|
||||
elevation: 0,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF020617),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFF9FAFB),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFF9FAFB),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide:
|
||||
BorderSide(color: primaryColor.withValues(alpha: 0.7), width: 1.5),
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFF9FAFB),
|
||||
hintStyle: textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFFD1D5DB),
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF9CA3AF),
|
||||
),
|
||||
chipTheme: ChipThemeData.fromDefaults(
|
||||
secondaryColor: primaryColor,
|
||||
brightness: Brightness.dark,
|
||||
labelStyle: textTheme.bodyMedium!,
|
||||
).copyWith(
|
||||
backgroundColor: const Color(0xFF020617),
|
||||
selectedColor: primaryColor.withValues(alpha: 0.18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: primaryColor,
|
||||
linearTrackColor: primaryColor.withValues(alpha: 0.2),
|
||||
circularTrackColor: primaryColor.withValues(alpha: 0.2),
|
||||
),
|
||||
textTheme: textTheme,
|
||||
);
|
||||
}
|
||||
|
||||
static TextTheme _buildLightTextTheme() {
|
||||
const primary = Color(0xFF111827);
|
||||
const secondary = Color(0xFF4B5563);
|
||||
const muted = Color(0xFF9CA3AF);
|
||||
|
||||
return TextTheme(
|
||||
displayLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.5,
|
||||
color: primary,
|
||||
),
|
||||
displayMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
color: primary,
|
||||
),
|
||||
displaySmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: primary,
|
||||
),
|
||||
headlineLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primary,
|
||||
),
|
||||
headlineMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
headlineSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
titleLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
titleMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: primary,
|
||||
),
|
||||
titleSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: primary,
|
||||
),
|
||||
bodyLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: secondary,
|
||||
),
|
||||
bodyMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: secondary,
|
||||
),
|
||||
bodySmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: muted,
|
||||
),
|
||||
labelLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
color: primary,
|
||||
),
|
||||
labelMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.3,
|
||||
color: secondary,
|
||||
),
|
||||
labelSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
color: muted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static TextTheme _buildDarkTextTheme() {
|
||||
const primary = Color(0xFFF9FAFB);
|
||||
const secondary = Color(0xFFD1D5DB);
|
||||
const muted = Color(0xFF9CA3AF);
|
||||
|
||||
return TextTheme(
|
||||
displayLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.5,
|
||||
color: primary,
|
||||
),
|
||||
displayMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
color: primary,
|
||||
),
|
||||
displaySmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: primary,
|
||||
),
|
||||
headlineLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primary,
|
||||
),
|
||||
headlineMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
headlineSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
titleLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primary,
|
||||
),
|
||||
titleMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: primary,
|
||||
),
|
||||
titleSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: primary,
|
||||
),
|
||||
bodyLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: secondary,
|
||||
),
|
||||
bodyMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: secondary,
|
||||
),
|
||||
bodySmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: muted,
|
||||
),
|
||||
labelLarge: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
color: primary,
|
||||
),
|
||||
labelMedium: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.3,
|
||||
color: secondary,
|
||||
),
|
||||
labelSmall: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
color: muted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ class DateTimeUtils {
|
||||
return DateFormat('MMM dd, yyyy').format(date);
|
||||
}
|
||||
|
||||
static String formatShortDate(DateTime date) {
|
||||
return DateFormat('MMM yyyy').format(date);
|
||||
}
|
||||
|
||||
static String formatDateTime(DateTime dateTime) {
|
||||
return DateFormat('MMM dd, yyyy • HH:mm').format(dateTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../data/providers/image_cache_provider.dart';
|
||||
|
||||
class CachedNetworkImage extends ConsumerStatefulWidget {
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
|
||||
const CachedNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<CachedNetworkImage> createState() => _CachedNetworkImageState();
|
||||
}
|
||||
|
||||
class _CachedNetworkImageState extends ConsumerState<CachedNetworkImage> {
|
||||
File? _cachedFile;
|
||||
bool _isLoading = true;
|
||||
bool _hasError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
try {
|
||||
final cacheService = ref.read(imageCacheServiceProvider);
|
||||
await cacheService.init();
|
||||
|
||||
final cached = await cacheService.getCachedImage(widget.imageUrl);
|
||||
|
||||
if (cached != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cachedFile = cached;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(widget.imageUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final imageData = response.bodyBytes;
|
||||
final cached = await cacheService.cacheImage(widget.imageUrl, imageData);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cachedFile = cached;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return widget.placeholder ??
|
||||
Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasError) {
|
||||
return widget.errorWidget ??
|
||||
Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_cachedFile != null) {
|
||||
return Image.file(
|
||||
_cachedFile!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: widget.fit,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return widget.errorWidget ??
|
||||
Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return widget.errorWidget ??
|
||||
Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
@@ -27,7 +29,7 @@ class EmptyState extends StatelessWidget {
|
||||
Icon(
|
||||
icon,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
|
||||
@@ -24,30 +24,44 @@ class PrimaryButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height ?? 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: (isLoading || isDisabled) ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
foregroundColor ?? Theme.of(context).colorScheme.onPrimary,
|
||||
final bool isEnabled = !isLoading && !isDisabled;
|
||||
|
||||
final ButtonStyle? buttonStyle =
|
||||
(backgroundColor == null && foregroundColor == null)
|
||||
? null
|
||||
: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: isEnabled,
|
||||
label: text,
|
||||
hint: isLoading ? 'Loading' : null,
|
||||
excludeSemantics: true,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height ?? 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isEnabled ? onPressed : null,
|
||||
style: buttonStyle,
|
||||
child: isLoading
|
||||
? Semantics(
|
||||
label: 'Loading',
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
foregroundColor ?? Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(text),
|
||||
)
|
||||
: Text(text),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user