Merge branch 'dev/implement-notification-system'
* dev/implement-notification-system: feat: performe last clean up and add notification service
This commit is contained in:
commit
fe6a1c6ee0
12 changed files with 436 additions and 46 deletions
|
|
@ -1,5 +1,12 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<application
|
||||
android:label="streetlifting_rpg"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -22,6 +29,17 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
|
|
|||
|
|
@ -514,6 +514,14 @@
|
|||
"profileDeleteAccountSubtitle": "Löscht Account und Daten dauerhaft",
|
||||
"profileDeleteConfirmTitle": "Account löschen?",
|
||||
"profileDeleteConfirmBody": "Bist du sicher? Alle Daten gehen verloren.",
|
||||
"profileTrainingSchedule": "Trainingsplan",
|
||||
"profileSelectTrainingDays": "Trainingstage wählen",
|
||||
"profileThreeDaysLimit": "Wähle genau 3 Tage pro Woche.",
|
||||
"profileNotificationSettings": "Benachrichtigungen",
|
||||
"profileRestNotification": "Pausen-Ende",
|
||||
"profileRestNotificationDesc": "Melden, wenn die Pause vorbei ist",
|
||||
"profileDailyReminder": "Tägliche Erinnerung",
|
||||
"profileDailyReminderDesc": "Erinnern, wenn ein Training ansteht",
|
||||
|
||||
"templateStrengthOnly": "Nur Stärke",
|
||||
"templateStrengthOnlyDesc": "Hauptübungen + FSL. Pur & Schnell.",
|
||||
|
|
|
|||
|
|
@ -528,6 +528,14 @@
|
|||
"profileDeleteAccountSubtitle": "Permanently delete your account and data",
|
||||
"profileDeleteConfirmTitle": "Delete Account?",
|
||||
"profileDeleteConfirmBody": "Are you sure you want to delete your account? All data will be lost forever.",
|
||||
"profileTrainingSchedule": "Training Schedule",
|
||||
"profileSelectTrainingDays": "Select Training Days",
|
||||
"profileThreeDaysLimit": "Select exactly 3 days per week.",
|
||||
"profileNotificationSettings": "Notification Settings",
|
||||
"profileRestNotification": "Rest Finished",
|
||||
"profileRestNotificationDesc": "Notify when your rest period ends",
|
||||
"profileDailyReminder": "Daily Reminder",
|
||||
"profileDailyReminderDesc": "Remind me when a workout is planned",
|
||||
|
||||
"templateStrengthOnly": "Strength Only",
|
||||
"templateStrengthOnlyDesc": "Main Lifts + FSL. Pure & Fast.",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:slrpg_app/src/app.dart';
|
|||
import 'package:slrpg_app/src/shared/data/local/app_database.dart';
|
||||
import 'package:slrpg_app/src/shared/data/remote/api_client.dart';
|
||||
import 'package:slrpg_app/src/shared/data/remote/pb_auth_store.dart';
|
||||
import 'package:slrpg_app/src/core/utils/notification_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -22,6 +23,9 @@ void main() async {
|
|||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.init();
|
||||
|
||||
final database = AppDatabase();
|
||||
|
||||
final authStore = PbAuthStore();
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
|||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final user = await userRepo.getLocalUser();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (user == null) {
|
||||
context.go('/login');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ class ErrorHandler {
|
|||
|
||||
final e = error.toString();
|
||||
|
||||
// Check for network errors first, but be more specific
|
||||
if (e.contains('SocketException') ||
|
||||
e.contains('Connection refused') ||
|
||||
e.contains('ClientException') ||
|
||||
e.contains('HandshakeException')) {
|
||||
e.contains('HandshakeException') ||
|
||||
e.contains('Network is unreachable')) {
|
||||
return l10n.errorNoInternet;
|
||||
}
|
||||
|
||||
|
|
@ -24,11 +25,13 @@ class ErrorHandler {
|
|||
return l10n.errorNotFound;
|
||||
}
|
||||
|
||||
// PocketBase often returns 400 for multiple things
|
||||
if (e.contains('400')) {
|
||||
if (e.contains('validation_not_unique')) {
|
||||
return l10n.errorEntryNotUnique;
|
||||
}
|
||||
if (e.contains('Failed to authenticate')) {
|
||||
// PocketBase specific error for failed login
|
||||
if (e.contains('Failed to authenticate') || e.contains('identity or password')) {
|
||||
return l10n.errorAuthenticationFailed;
|
||||
}
|
||||
return l10n.errorIllegalRequest;
|
||||
|
|
@ -38,17 +41,23 @@ class ErrorHandler {
|
|||
}
|
||||
|
||||
static void showErrorSnackBar(BuildContext context, Object error) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.hideCurrentSnackBar();
|
||||
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(getReadableError(context, error)),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
dismissDirection: DismissDirection.horizontal, // Easier to swipe away
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 60), // Move it up to not block navigation
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
|
||||
onPressed: () {
|
||||
scaffoldMessenger.hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
197
lib/src/core/utils/notification_service.dart
Normal file
197
lib/src/core/utils/notification_service.dart
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart' show TimeOfDay;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Provider for the [NotificationService].
|
||||
final notificationServiceProvider = Provider<NotificationService>((ref) {
|
||||
return NotificationService();
|
||||
});
|
||||
|
||||
/// Service responsible for managing local notifications across Android, iOS, and Linux.
|
||||
class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
/// Initializes the notification system and requests necessary permissions.
|
||||
Future<void> init() async {
|
||||
tz.initializeTimeZones();
|
||||
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const DarwinInitializationSettings initializationSettingsDarwin =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const LinuxInitializationSettings initializationSettingsLinux =
|
||||
LinuxInitializationSettings(
|
||||
defaultActionName: 'Open notification',
|
||||
);
|
||||
|
||||
const InitializationSettings initializationSettings = InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsDarwin,
|
||||
linux: initializationSettingsLinux,
|
||||
);
|
||||
|
||||
await _notifications.initialize(
|
||||
initializationSettings,
|
||||
);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
await androidImplementation?.requestNotificationsPermission();
|
||||
await androidImplementation?.requestExactAlarmsPermission();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a notification immediately indicating that a rest period has finished.
|
||||
Future<void> showRestFinishedNotification({String? title, String? body}) async {
|
||||
const AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
AndroidNotificationDetails(
|
||||
'rest_timer_channel',
|
||||
'Rest Timer Notifications',
|
||||
channelDescription: 'Notifications when the rest timer finishes',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails platformChannelSpecifics =
|
||||
NotificationDetails(
|
||||
android: androidPlatformChannelSpecifics,
|
||||
linux: LinuxNotificationDetails(),
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
1001,
|
||||
title ?? 'Rest Finished',
|
||||
body ?? 'Get back to your workout!',
|
||||
platformChannelSpecifics,
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedules recurring notifications for specified training days at a given time.
|
||||
///
|
||||
/// Fallbacks to immediate notification on Linux as [zonedSchedule] is not implemented.
|
||||
Future<void> scheduleDailyTrainingReminder({
|
||||
required List<String> trainingDays,
|
||||
required TimeOfDay reminderTime,
|
||||
String? title,
|
||||
String? body,
|
||||
}) async {
|
||||
await _notifications.cancel(1002);
|
||||
|
||||
if (trainingDays.isEmpty) return;
|
||||
|
||||
if (Platform.isLinux) {
|
||||
await _notifications.show(
|
||||
1002,
|
||||
title ?? 'Daily Reminder Configured',
|
||||
body ?? 'Training reminders are set for: ${trainingDays.join(', ')}',
|
||||
const NotificationDetails(linux: LinuxNotificationDetails()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
AndroidNotificationDetails(
|
||||
'daily_training_channel',
|
||||
'Daily Training Reminders',
|
||||
channelDescription: 'Reminders for scheduled training days',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
const NotificationDetails platformChannelSpecifics =
|
||||
NotificationDetails(
|
||||
android: androidPlatformChannelSpecifics,
|
||||
linux: LinuxNotificationDetails(),
|
||||
);
|
||||
|
||||
for (int i = 0; i < trainingDays.length; i++) {
|
||||
final day = trainingDays[i];
|
||||
final weekday = _getWeekdayNumber(day);
|
||||
if (weekday == null) continue;
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
1002 + i,
|
||||
title ?? 'Training Day!',
|
||||
body ?? 'Today is a scheduled training day. Time to hit the iron!',
|
||||
_nextInstanceOfDay(weekday, reminderTime),
|
||||
platformChannelSpecifics,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int? _getWeekdayNumber(String day) {
|
||||
switch (day.toLowerCase()) {
|
||||
case 'mo':
|
||||
case 'monday':
|
||||
return DateTime.monday;
|
||||
case 'tu':
|
||||
case 'tuesday':
|
||||
return DateTime.tuesday;
|
||||
case 'we':
|
||||
case 'wednesday':
|
||||
return DateTime.wednesday;
|
||||
case 'th':
|
||||
case 'thursday':
|
||||
return DateTime.thursday;
|
||||
case 'fr':
|
||||
case 'friday':
|
||||
return DateTime.friday;
|
||||
case 'sa':
|
||||
case 'saturday':
|
||||
return DateTime.saturday;
|
||||
case 'su':
|
||||
case 'sunday':
|
||||
return DateTime.sunday;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfDay(int weekday, TimeOfDay time) {
|
||||
tz.TZDateTime scheduledDate = _nextInstanceOfTime(time);
|
||||
while (scheduledDate.weekday != weekday) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||
}
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfTime(TimeOfDay time) {
|
||||
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
|
||||
tz.TZDateTime scheduledDate = tz.TZDateTime(
|
||||
tz.local, now.year, now.month, now.day, time.hour, time.minute);
|
||||
if (scheduledDate.isBefore(now)) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||
}
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
/// Cancels all active and scheduled notifications.
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple time representation for scheduling notifications.
|
||||
class TimeOfDay {
|
||||
final int hour;
|
||||
final int minute;
|
||||
const TimeOfDay({required this.hour, required this.minute});
|
||||
}
|
||||
|
|
@ -58,29 +58,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = _parseErrorMessage(e.toString(), l10n);
|
||||
_errorMessage = ErrorHandler.getReadableError(context, e);
|
||||
});
|
||||
ErrorHandler.showErrorSnackBar(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _parseErrorMessage(String error, AppLocalizations l10n) {
|
||||
if (error.contains('400')) {
|
||||
return l10n.loginErrorInvalid;
|
||||
} else if (error.contains('SocketException') ||
|
||||
error.contains('Connection refused') ||
|
||||
error.contains('Network is unreachable')) {
|
||||
return l10n.loginErrorConnection;
|
||||
} else if (error.contains('timeout')) {
|
||||
return l10n.loginErrorTimeout;
|
||||
}
|
||||
return l10n.loginErrorGeneric;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
|||
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
||||
import '../../../gamification/domain/entities/item_catalog.dart';
|
||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||
import '../../../../core/utils/notification_service.dart' as ns;
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
|
@ -400,10 +401,87 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateNotificationSetting(String key, bool value) async {
|
||||
if (_user == null) return;
|
||||
|
||||
final currentSettings =
|
||||
Map<String, dynamic>.from(_user!.notificationSettings ?? {});
|
||||
currentSettings[key] = value;
|
||||
|
||||
final updatedUser = _user!.copyWith(
|
||||
notificationSettings: Value(currentSettings),
|
||||
isDirty: true,
|
||||
);
|
||||
|
||||
setState(() => _user = updatedUser);
|
||||
await ref.read(userRepositoryProvider).saveLocalUser(updatedUser);
|
||||
|
||||
if (key == 'daily_reminder_enabled') {
|
||||
_rescheduleDailyReminder(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateTrainingDays(String day) async {
|
||||
if (_user == null) return;
|
||||
|
||||
final currentDays = List<String>.from(_user!.trainingDays ?? []);
|
||||
if (currentDays.contains(day)) {
|
||||
currentDays.remove(day);
|
||||
} else {
|
||||
if (currentDays.length >= 3) return; // Limit to 3 days
|
||||
currentDays.add(day);
|
||||
}
|
||||
|
||||
final updatedUser = _user!.copyWith(
|
||||
trainingDays: Value(currentDays),
|
||||
isDirty: true,
|
||||
);
|
||||
|
||||
setState(() => _user = updatedUser);
|
||||
await ref.read(userRepositoryProvider).saveLocalUser(updatedUser);
|
||||
|
||||
_rescheduleDailyReminder(updatedUser);
|
||||
}
|
||||
|
||||
void _rescheduleDailyReminder(UserCollection user) {
|
||||
final settings = user.notificationSettings ?? {};
|
||||
final enabled = settings['daily_reminder_enabled'] ?? false;
|
||||
final days = List<String>.from(user.trainingDays ?? []);
|
||||
|
||||
if (enabled && days.isNotEmpty) {
|
||||
ref.read(ns.notificationServiceProvider).scheduleDailyTrainingReminder(
|
||||
trainingDays: days,
|
||||
reminderTime: const ns.TimeOfDay(hour: 8, minute: 0),
|
||||
);
|
||||
} else {
|
||||
// We don't have a cancel specific notification ID but we can cancel 1002+
|
||||
// For simplicity, we just won't schedule.
|
||||
// In a real app we might want to cancel specifically.
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDaySelector() {
|
||||
final days = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
final selectedDays = List<String>.from(_user?.trainingDays ?? []);
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: days.map((day) {
|
||||
final isSelected = selectedDays.contains(day);
|
||||
return FilterChip(
|
||||
label: Text(day),
|
||||
selected: isSelected,
|
||||
onSelected: (val) => _updateTrainingDays(day),
|
||||
selectedColor: AppTheme.primaryColor.withValues(alpha: 0.3),
|
||||
checkmarkColor: AppTheme.primaryColor,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
|
@ -513,6 +591,57 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(l10n.profileTrainingSchedule,
|
||||
style: Theme.of(context)
|
||||
.textTheme.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.profileSelectTrainingDays,
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 12),
|
||||
_buildDaySelector(),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.profileThreeDaysLimit,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(l10n.profileNotificationSettings,
|
||||
style: Theme.of(context)
|
||||
.textTheme.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: (_user?.notificationSettings ?? {})['rest_finished_enabled'] ?? true,
|
||||
title: Text(l10n.profileRestNotification),
|
||||
subtitle: Text(l10n.profileRestNotificationDesc),
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (val) => _updateNotificationSetting('rest_finished_enabled', val),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
value: (_user?.notificationSettings ?? {})['daily_reminder_enabled'] ?? false,
|
||||
title: Text(l10n.profileDailyReminder),
|
||||
subtitle: Text(l10n.profileDailyReminderDesc),
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (val) => _updateNotificationSetting('daily_reminder_enabled', val),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(l10n.profileTrainingFocus,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart';
|
|||
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/utils/notification_service.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/domain/logic/plate_calculator.dart';
|
||||
import '../../../multiplayer/data/repositories/party_repository.dart';
|
||||
|
|
@ -139,15 +140,31 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
|
|||
if (!mounted) return;
|
||||
|
||||
final controller = ref.read(battleControllerProvider.notifier);
|
||||
if (controller.state.isResting) {
|
||||
final battleState = ref.read(battleControllerProvider);
|
||||
|
||||
if (battleState.isResting) {
|
||||
controller.tickRest();
|
||||
if (controller.state.restSeconds > 0) {
|
||||
final newState = ref.read(battleControllerProvider);
|
||||
|
||||
if (newState.isResting) {
|
||||
_runRestTimer();
|
||||
} else {
|
||||
_handleRestFinished();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleRestFinished() async {
|
||||
final user = await ref.read(userRepositoryProvider).getLocalUser();
|
||||
final settings = user?.notificationSettings ?? {};
|
||||
final restEnabled = settings['rest_finished_enabled'] ?? true;
|
||||
|
||||
if (restEnabled) {
|
||||
ref.read(notificationServiceProvider).showRestFinishedNotification();
|
||||
}
|
||||
}
|
||||
|
||||
void _skipRest() {
|
||||
ref.read(battleControllerProvider.notifier).skipRest();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -24,6 +24,10 @@ class AppDatabase extends _$AppDatabase {
|
|||
if (from < 3) {
|
||||
await m.addColumn(users, users.exerciseVariants);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.addColumn(users, users.trainingDays);
|
||||
await m.addColumn(users, users.notificationSettings);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,38 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import 'pb_auth_store.dart';
|
||||
|
||||
// final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
/// Provider for the [ApiClient] instance.
|
||||
///
|
||||
/// The provider must be overridden in the [ProviderScope] to provide a concrete implementation.
|
||||
final apiClientProvider =
|
||||
Provider<ApiClient>((ref) => throw UnimplementedError());
|
||||
|
||||
/// Client for communicating with the PocketBase backend.
|
||||
class ApiClient {
|
||||
late final PocketBase _pb;
|
||||
// final PbAuthStore _authStore;
|
||||
final Logger _logger;
|
||||
|
||||
/// Underlying PocketBase instance.
|
||||
PocketBase get pb => _pb;
|
||||
|
||||
/// Stream of authentication state changes.
|
||||
Stream<AuthStoreEvent> get authStateChanges => _pb.authStore.onChange;
|
||||
|
||||
/// Creates a new ApiClient.
|
||||
ApiClient({
|
||||
required AuthStore authStore,
|
||||
Logger? logger,
|
||||
}) : _logger = logger ?? Logger() {
|
||||
_pb = PocketBase(
|
||||
AppConstants.apiBaseUrl,
|
||||
authStore: authStore, // Hier kommt der geladene Store rein
|
||||
authStore: authStore,
|
||||
);
|
||||
}
|
||||
// ApiClient({
|
||||
// PbAuthStore? authStore,
|
||||
// FlutterSecureStorage? storage,
|
||||
// Logger? logger,
|
||||
// }) : _logger = logger ?? Logger(),
|
||||
// _authStore = authStore ?? PbAuthStore(storage: storage) {
|
||||
// _pb = PocketBase(
|
||||
// AppConstants.apiBaseUrl,
|
||||
// authStore: _authStore,
|
||||
// );
|
||||
// if (authStore == null) {
|
||||
// _authStore.loadFromStorage();
|
||||
// }
|
||||
// }
|
||||
|
||||
/// Wrapper to handle PocketBase requests with automatic token refreshing.
|
||||
Future<T> _handleRequest<T>(Future<T> Function() request) async {
|
||||
try {
|
||||
return await request();
|
||||
|
|
@ -67,6 +57,7 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Logs in a user using email and password.
|
||||
Future<RecordAuth> login(String email, String password) async {
|
||||
try {
|
||||
final authData = await _pb.collection('users').authWithPassword(
|
||||
|
|
@ -81,6 +72,7 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Registers a new user account.
|
||||
Future<RecordAuth> register({
|
||||
required String email,
|
||||
required String username,
|
||||
|
|
@ -91,7 +83,7 @@ class ApiClient {
|
|||
Map<String, dynamic>? avatarConfig,
|
||||
}) async {
|
||||
try {
|
||||
final user = await _pb.collection('users').create(body: {
|
||||
await _pb.collection('users').create(body: {
|
||||
'email': email,
|
||||
'name': username,
|
||||
'password': password,
|
||||
|
|
@ -112,6 +104,7 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Requests a verification email for the given address.
|
||||
Future<void> requestVerification(String email) async {
|
||||
try {
|
||||
await _pb.collection('users').requestVerification(email);
|
||||
|
|
@ -122,6 +115,7 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Forces a token refresh.
|
||||
Future<void> refreshAuth() async {
|
||||
try {
|
||||
await _pb.collection('users').authRefresh();
|
||||
|
|
@ -133,10 +127,12 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Clears the authentication store.
|
||||
Future<void> logout() async {
|
||||
_pb.authStore.clear();
|
||||
}
|
||||
|
||||
/// Synchronizes local data with the server.
|
||||
Future<Map<String, dynamic>> sync({
|
||||
required String lastSyncTimestamp,
|
||||
required Map<String, dynamic> pushData,
|
||||
|
|
@ -154,6 +150,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Creates a new training cycle on the server.
|
||||
Future<Map<String, dynamic>> createCycle(
|
||||
Map<String, double> trainingMaxes) async {
|
||||
return _handleRequest(() async {
|
||||
|
|
@ -166,6 +163,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Marks a training cycle as finished on the server.
|
||||
Future<Map<String, dynamic>> finishCycle(String cycleId) async {
|
||||
return _handleRequest(() async {
|
||||
final result = await _pb.send(
|
||||
|
|
@ -177,6 +175,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Retrieves the current active cycle from the server.
|
||||
Future<Map<String, dynamic>> getCurrentCycle() async {
|
||||
return _handleRequest(() async {
|
||||
final result = await _pb.send(ApiEndpoints.cycleCurrent, method: 'GET');
|
||||
|
|
@ -184,6 +183,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Fetches history statistics for a specific exercise and time range.
|
||||
Future<Map<String, dynamic>> getStatsHistory({
|
||||
required String exercise,
|
||||
required String range,
|
||||
|
|
@ -201,6 +201,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Retrieves a summary of all statistics.
|
||||
Future<Map<String, dynamic>> getStatsSummary() async {
|
||||
return _handleRequest(() async {
|
||||
final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET');
|
||||
|
|
@ -208,6 +209,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Updates the user's bodyweight on the server.
|
||||
Future<void> updateBodyweight(double bodyweight) async {
|
||||
await _handleRequest(() async {
|
||||
await _pb.send(
|
||||
|
|
@ -218,6 +220,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Updates the user's inventory settings on the server.
|
||||
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
||||
await _handleRequest(() async {
|
||||
await _pb.send(
|
||||
|
|
@ -228,6 +231,7 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Updates the user's password.
|
||||
Future<void> updatePassword({
|
||||
required String userId,
|
||||
required String oldPassword,
|
||||
|
|
@ -243,12 +247,14 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Deletes the user's account.
|
||||
Future<void> deleteAccount(String userId) async {
|
||||
await _handleRequest(() async {
|
||||
await _pb.collection('users').delete(userId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Resets all training progress on the server.
|
||||
Future<void> resetProgress() async {
|
||||
await _handleRequest(() async {
|
||||
await _pb.send(
|
||||
|
|
@ -258,10 +264,12 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/// Returns the current authentication token.
|
||||
String? getToken() {
|
||||
return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null;
|
||||
}
|
||||
|
||||
/// Returns the current user's ID.
|
||||
String? getUserId() {
|
||||
return _pb.authStore.record?.id;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue