initial commit with working version
This commit is contained in:
commit
7e4dd30599
235 changed files with 23683 additions and 0 deletions
38
lib/main.dart
Normal file
38
lib/main.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'src/app.dart';
|
||||
import 'src/shared/data/local/collections/user_collection.dart';
|
||||
import 'src/shared/data/local/collections/cycle_collection.dart';
|
||||
import 'src/shared/data/local/collections/workout_collection.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Lock orientation to portrait
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Initialize Isar database
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final isar = await Isar.open(
|
||||
[UserCollectionSchema, CycleCollectionSchema, WorkoutCollectionSchema],
|
||||
directory: dir.path,
|
||||
name: 'slrpg_db',
|
||||
);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [isarProvider.overrideWithValue(isar)],
|
||||
child: const SLRPGApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Global Isar provider
|
||||
final isarProvider = Provider<Isar>((ref) => throw UnimplementedError());
|
||||
23
lib/src/app.dart
Normal file
23
lib/src/app.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
|
||||
class SLRPGApp extends ConsumerWidget {
|
||||
const SLRPGApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'SLRPG - Streetlifting RPG',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.darkTheme,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
99
lib/src/core/constants/app_constants.dart
Normal file
99
lib/src/core/constants/app_constants.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppConstants {
|
||||
// API Configuration
|
||||
static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
|
||||
static const String apiVersion = 'v1';
|
||||
|
||||
// Wendler 5/3/1 Constants
|
||||
static const double trainingMaxPercentage = 0.9;
|
||||
static const double upperBodyIncrement = 2.5; // kg
|
||||
static const double lowerBodyIncrement = 5.0; // kg
|
||||
|
||||
// XP System
|
||||
static const int baseXP = 1000;
|
||||
static const double xpMultiplier = 1.15;
|
||||
static const int maxLevel = 100;
|
||||
|
||||
// XP Rewards
|
||||
static const int workoutCompleteXP = 100;
|
||||
static const double volumeXPRate = 0.1; // XP per kg
|
||||
static const int amrapBonusXPPerRep = 25;
|
||||
static const int prBonusXP = 500;
|
||||
static const int cycleCompleteXP = 500;
|
||||
|
||||
// Rounding Steps
|
||||
static const double squatRoundingStep = 2.5;
|
||||
static const double calisthenicsRoundingStep = 1.25;
|
||||
|
||||
// Default Inventory
|
||||
static const double defaultBarWeight = 20.0;
|
||||
static const List<double> defaultPlates = [
|
||||
25,
|
||||
25,
|
||||
20,
|
||||
20,
|
||||
15,
|
||||
15,
|
||||
10,
|
||||
10,
|
||||
5,
|
||||
5,
|
||||
2.5,
|
||||
2.5,
|
||||
1.25,
|
||||
1.25
|
||||
];
|
||||
|
||||
// Resistance Bands (Color: Resistance in KG approx)
|
||||
// Negative values imply assistance force
|
||||
static const Map<String, double> defaultBands = {
|
||||
'Blue': 30.0,
|
||||
'Green': 20.0,
|
||||
'Orange': 10.0,
|
||||
'Red': 5.0,
|
||||
};
|
||||
|
||||
// Bodyweight Limits
|
||||
static const double minBodyweight = 40.0;
|
||||
static const double maxBodyweight = 200.0;
|
||||
|
||||
// Animation Durations
|
||||
static const Duration shortAnimation = Duration(milliseconds: 200);
|
||||
static const Duration mediumAnimation = Duration(milliseconds: 400);
|
||||
static const Duration longAnimation = Duration(milliseconds: 600);
|
||||
|
||||
// Storage Keys
|
||||
static const String keyAuthToken = 'auth_token';
|
||||
static const String keyUserId = 'user_id';
|
||||
static const String keyLastSync = 'last_sync';
|
||||
static const String keyIsFirstLaunch = 'is_first_launch';
|
||||
}
|
||||
|
||||
class ApiEndpoints {
|
||||
static const String login = '/api/collections/users/auth-with-password';
|
||||
static const String register = '/api/collections/users/records';
|
||||
static const String sync = '/api/v1/sync';
|
||||
static const String cycleCreate = '/api/v1/cycle/create';
|
||||
static const String cycleFinish = '/api/v1/cycle/finish';
|
||||
static const String cycleCurrent = '/api/v1/cycle/current';
|
||||
static const String statsHistory = '/api/v1/stats/history';
|
||||
static const String statsSummary = '/api/v1/stats/summary';
|
||||
static const String profileBodyweight = '/api/v1/profile/bodyweight';
|
||||
static const String profileInventory = '/api/v1/profile/inventory';
|
||||
static const String userUpdate = '/api/collections/users/records'; // + /:id
|
||||
static const String userDelete = '/api/collections/users/records'; // + /:id
|
||||
static const String profileReset = '/api/v1/profile/reset';
|
||||
}
|
||||
|
||||
class ExerciseIds {
|
||||
static const String squat = 'squat';
|
||||
static const String pullup = 'pullup_weighted';
|
||||
static const String dip = 'dip_weighted';
|
||||
}
|
||||
|
||||
class ExerciseNames {
|
||||
static const String squat = 'Back Squat';
|
||||
static const String pullup = 'Weighted Pull-up';
|
||||
static const String dip = 'Weighted Dip';
|
||||
}
|
||||
135
lib/src/core/constants/asset_paths.dart
Normal file
135
lib/src/core/constants/asset_paths.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// class AssetPaths {
|
||||
// // Backgrounds
|
||||
// static const String bgStreetParkDay =
|
||||
// 'assets/images/backgrounds/street_park_day.png';
|
||||
// static const String bgStreetParkNight =
|
||||
// 'assets/images/backgrounds/street_park_night.png';
|
||||
// static const String bgUndergroundGym =
|
||||
// 'assets/images/backgrounds/underground_gym.png';
|
||||
// static const String bgCommercialGym =
|
||||
// 'assets/images/backgrounds/commercial_gym.png';
|
||||
|
||||
// // Avatars
|
||||
// static const String avatarMaleBase = 'assets/images/avatars/male_base.png';
|
||||
// static const String avatarFemaleBase =
|
||||
// 'assets/images/avatars/female_base.png';
|
||||
|
||||
// // Plates
|
||||
// static const String plate25kg = 'assets/images/plates/plate_25kg.png';
|
||||
// static const String plate20kg = 'assets/images/plates/plate_20kg.png';
|
||||
// static const String plate15kg = 'assets/images/plates/plate_15kg.png';
|
||||
// static const String plate10kg = 'assets/images/plates/plate_10kg.png';
|
||||
// static const String plate5kg = 'assets/images/plates/plate_5kg.png';
|
||||
// static const String plate2_5kg = 'assets/images/plates/plate_2_5kg.png';
|
||||
// static const String plate1_25kg = 'assets/images/plates/plate_1_25kg.png';
|
||||
|
||||
// // Enemies
|
||||
// static const String enemyIronGolem = 'assets/images/enemies/iron_golem.png';
|
||||
// static const String enemyGravityDemon =
|
||||
// 'assets/images/enemies/gravity_demon.png';
|
||||
// static const String enemyPressurePhantom =
|
||||
// 'assets/images/enemies/pressure_phantom.png';
|
||||
|
||||
// // Icons
|
||||
// static const String iconXP = 'assets/images/icons/xp.png';
|
||||
// static const String iconLevel = 'assets/images/icons/level.png';
|
||||
// }
|
||||
|
||||
// class PlateColors {
|
||||
// static final Map<double, int> colors = {
|
||||
// 25.0: 0xFFD32F2F, // Red
|
||||
// 20.0: 0xFF1976D2, // Blue
|
||||
// 15.0: 0xFFFBC02D, // Yellow
|
||||
// 10.0: 0xFF388E3C, // Green
|
||||
// 5.0: 0xFFFAFAFA, // White
|
||||
// 2.5: 0xFF212121, // Black
|
||||
// 1.25: 0xFF9E9E9E, // Silver
|
||||
// };
|
||||
// }
|
||||
import 'dart:ui';
|
||||
|
||||
class AssetPaths {
|
||||
// Backgrounds
|
||||
static const String bgSplash = 'assets/images/backgrounds/splash.png';
|
||||
static const String bgStreetParkDay =
|
||||
'assets/images/backgrounds/street_park_day.png';
|
||||
static const String bgStreetParkNight =
|
||||
'assets/images/backgrounds/street_park_night.png';
|
||||
static const String bgUndergroundGym =
|
||||
'assets/images/backgrounds/underground_gym.png';
|
||||
static const String bgCommercialGym =
|
||||
'assets/images/backgrounds/commercial_gym.png';
|
||||
|
||||
// Avatars - Bases
|
||||
static const String avatarMaleBase = 'assets/images/avatars/base/male.png';
|
||||
static const String avatarFemaleBase =
|
||||
'assets/images/avatars/base/female.png';
|
||||
|
||||
// Avatars - Hair (Beispiele)
|
||||
static const String hairShort = 'assets/images/avatars/hair/short.png';
|
||||
static const String hairLong = 'assets/images/avatars/hair/long.png';
|
||||
static const String hairBald =
|
||||
'assets/images/avatars/hair/bald.png'; // Transparent/Empty
|
||||
|
||||
// Avatars - Clothing (Beispiele)
|
||||
static const String outfitBasicTee =
|
||||
'assets/images/avatars/clothing/basic_tee.png';
|
||||
static const String outfitHoodie =
|
||||
'assets/images/avatars/clothing/hoodie.png';
|
||||
static const String outfitTank = 'assets/images/avatars/clothing/tank.png';
|
||||
|
||||
// Plates
|
||||
static const String plate25kg = 'assets/images/plates/plate_25kg.png';
|
||||
static const String plate20kg = 'assets/images/plates/plate_20kg.png';
|
||||
static const String plate15kg = 'assets/images/plates/plate_15kg.png';
|
||||
static const String plate10kg = 'assets/images/plates/plate_10kg.png';
|
||||
static const String plate5kg = 'assets/images/plates/plate_5kg.png';
|
||||
static const String plate2_5kg = 'assets/images/plates/plate_2_5kg.png';
|
||||
static const String plate1_25kg = 'assets/images/plates/plate_1_25kg.png';
|
||||
|
||||
// Enemies & Icons (wie vorher...)
|
||||
static const String enemyIronGolem = 'assets/images/enemies/iron_golem.png';
|
||||
static const String enemyGravityDemon =
|
||||
'assets/images/enemies/gravity_demon.png';
|
||||
static const String enemyPressurePhantom =
|
||||
'assets/images/enemies/pressure_phantom.png';
|
||||
static const String iconXP = 'assets/images/icons/xp.png';
|
||||
static const String iconLevel = 'assets/images/icons/level.png';
|
||||
static String getAvatarPath(String gender, int variant) {
|
||||
return 'assets/images/avatars/$gender/$variant.png';
|
||||
}
|
||||
}
|
||||
|
||||
class PlateColors {
|
||||
static final Map<double, int> colors = {
|
||||
25.0: 0xFFD32F2F,
|
||||
20.0: 0xFF1976D2,
|
||||
15.0: 0xFFFBC02D,
|
||||
10.0: 0xFF388E3C,
|
||||
5.0: 0xFFFAFAFA,
|
||||
2.5: 0xFF212121,
|
||||
1.25: 0xFF9E9E9E,
|
||||
};
|
||||
}
|
||||
|
||||
class AvatarConstants {
|
||||
static const Map<String, Color> skinTones = {
|
||||
'pale': Color(0xFFFFDFC4),
|
||||
'fair': Color(0xFFF0D5BE),
|
||||
'medium': Color(0xFFD1A384),
|
||||
'olive': Color(0xFF9E7C63),
|
||||
'dark': Color(0xFF5C4033),
|
||||
};
|
||||
|
||||
static const Map<String, String> hairStyles = {
|
||||
'short_01': AssetPaths.hairShort,
|
||||
'long_01': AssetPaths.hairLong,
|
||||
'bald': AssetPaths.hairBald,
|
||||
};
|
||||
|
||||
static const Map<String, String> clothing = {
|
||||
'basic_tee': AssetPaths.outfitBasicTee,
|
||||
'hoodie': AssetPaths.outfitHoodie,
|
||||
'tank': AssetPaths.outfitTank,
|
||||
};
|
||||
}
|
||||
272
lib/src/core/routing/app_router.dart
Normal file
272
lib/src/core/routing/app_router.dart
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/authentication/presentation/screens/login_screen.dart';
|
||||
import '../../features/authentication/presentation/screens/profile_screen.dart';
|
||||
import '../../features/authentication/presentation/screens/register_screen.dart';
|
||||
import '../../features/onboarding/presentation/screens/avatar_setup_screen.dart';
|
||||
import '../../features/onboarding/presentation/screens/welcome_screen.dart';
|
||||
import '../../features/onboarding/presentation/screens/bodyweight_input_screen.dart';
|
||||
import '../../features/onboarding/presentation/screens/strength_test_screen.dart';
|
||||
import '../../features/onboarding/presentation/screens/inventory_setup_screen.dart';
|
||||
import '../../features/dashboard/presentation/screens/hub_screen.dart';
|
||||
import '../../features/workout_runner/presentation/screens/battle_screen.dart';
|
||||
import '../../features/inventory/presentation/screens/inventory_screen.dart';
|
||||
import '../../features/history/presentation/screens/history_screen.dart';
|
||||
import '../../shared/data/repositories/user_repository.dart';
|
||||
import '../../features/stats/presentation/screens/stats_screen.dart';
|
||||
import '../constants/asset_paths.dart';
|
||||
import '../../features/gamification/presentation/screens/codex_screen.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final userRepo = ref.watch(userRepositoryProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
routes: [
|
||||
// Splash / Initial Route
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
|
||||
// Authentication
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
|
||||
// Onboarding Flow
|
||||
GoRoute(
|
||||
path: '/onboarding/welcome',
|
||||
name: 'welcome',
|
||||
builder: (context, state) => const WelcomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/bodyweight',
|
||||
name: 'bodyweight',
|
||||
builder: (context, state) => const BodyweightInputScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/strength-test',
|
||||
name: 'strength-test',
|
||||
builder: (context, state) => const StrengthTestScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/inventory',
|
||||
name: 'inventory-setup',
|
||||
builder: (context, state) => const InventorySetupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/avatar',
|
||||
name: 'avatar-setup',
|
||||
builder: (context, state) => const AvatarSetupScreen(),
|
||||
),
|
||||
|
||||
// Main App
|
||||
GoRoute(
|
||||
path: '/hub',
|
||||
name: 'hub',
|
||||
builder: (context, state) => const HubScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/battle',
|
||||
name: 'battle',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
return BattleScreen(
|
||||
week: extra?['week'] ?? 1,
|
||||
day: extra?['day'] ?? 1,
|
||||
workoutId: extra?['workoutId'],
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory',
|
||||
name: 'inventory',
|
||||
builder: (context, state) => const InventoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
builder: (context, state) => const HistoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
builder: (context, state) => const StatsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/codex',
|
||||
name: 'codex',
|
||||
builder: (context, state) => const CodexScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// Splash Screen to determine initial route
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkInitialRoute();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialRoute() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final user = await userRepo.getLocalUser();
|
||||
|
||||
if (user == null) {
|
||||
// No user, go to login
|
||||
context.go('/login');
|
||||
} else {
|
||||
// User exists, go to hub
|
||||
context.go('/hub');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 1. Hintergrundbild
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
AssetPaths.bgSplash, // Nutzt den Splash
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Overlay (Dunkel), damit Text lesbar ist
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Inhalt
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo Container (mit leichtem Glow)
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00E5FF).withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00E5FF).withOpacity(0.6),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fitness_center,
|
||||
size: 64,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'S.L.R.P.G.',
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
const Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Streetlifting RPG',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const CircularProgressIndicator(
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Scaffold(
|
||||
// backgroundColor: const Color(0xFF121212),
|
||||
// body: Center(
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// // Logo placeholder
|
||||
// Container(
|
||||
// width: 120,
|
||||
// height: 120,
|
||||
// decoration: BoxDecoration(
|
||||
// color: const Color(0xFF00E5FF),
|
||||
// borderRadius: BorderRadius.circular(24),
|
||||
// ),
|
||||
// child: const Icon(
|
||||
// Icons.fitness_center,
|
||||
// size: 64,
|
||||
// color: Colors.black,
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(height: 24),
|
||||
// Text(
|
||||
// 'S.L.R.P.G.',
|
||||
// style: Theme.of(context).textTheme.displayLarge,
|
||||
// ),
|
||||
// const SizedBox(height: 8),
|
||||
// Text(
|
||||
// 'Streetlifting RPG',
|
||||
// style: Theme.of(context).textTheme.bodyMedium,
|
||||
// ),
|
||||
// const SizedBox(height: 48),
|
||||
// const CircularProgressIndicator(
|
||||
// color: Color(0xFF00E5FF),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
128
lib/src/core/theme/app_theme.dart
Normal file
128
lib/src/core/theme/app_theme.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Color Palette (Dark RPG Theme)
|
||||
static const Color primaryColor = Color(0xFF00E5FF); // Cyan
|
||||
static const Color secondaryColor = Color(0xFFFF6E40); // Deep Orange
|
||||
static const Color backgroundColor = Color(0xFF121212);
|
||||
static const Color surfaceColor = Color(0xFF1E1E1E);
|
||||
static const Color errorColor = Color(0xFFCF6679);
|
||||
static const Color successColor = Color(0xFF4CAF50);
|
||||
|
||||
// XP Bar Colors
|
||||
static const Color xpBarBackground = Color(0xFF2C2C2C);
|
||||
static const Color xpBarFill = Color(0xFF00E5FF);
|
||||
|
||||
// Text Colors
|
||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||
static const Color textSecondary = Color(0xFFB0B0B0);
|
||||
static const Color textDisabled = Color(0xFF666666);
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: primaryColor,
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
surface: surfaceColor,
|
||||
error: errorColor,
|
||||
onPrimary: Colors.black,
|
||||
onSecondary: Colors.white,
|
||||
onSurface: textPrimary,
|
||||
onError: Colors.white,
|
||||
),
|
||||
textTheme: GoogleFonts.robotoTextTheme().copyWith(
|
||||
displayLarge: GoogleFonts.orbitron(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textPrimary,
|
||||
),
|
||||
displayMedium: GoogleFonts.orbitron(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textPrimary,
|
||||
),
|
||||
headlineMedium: GoogleFonts.orbitron(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary,
|
||||
),
|
||||
bodyLarge: GoogleFonts.roboto(
|
||||
fontSize: 16,
|
||||
color: textPrimary,
|
||||
),
|
||||
bodyMedium: GoogleFonts.roboto(
|
||||
fontSize: 14,
|
||||
color: textSecondary,
|
||||
),
|
||||
labelLarge: GoogleFonts.orbitron(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 8,
|
||||
textStyle: GoogleFonts.orbitron(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
// CardTheme -> CardThemeData
|
||||
color: surfaceColor,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: primaryColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: primaryColor.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: primaryColor.withOpacity(0.3)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: errorColor),
|
||||
),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: GoogleFonts.orbitron(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
await userRepo.login(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
context.go('/hub');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Login failed: ${e.toString()}'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fitness_center,
|
||||
size: 56,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
'WELCOME BACK',
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Time to level up your strength',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Email Field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password Field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _obscurePassword = !_obscurePassword);
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Login Button
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: const Text('LOGIN'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Register Link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Don't have an account? ",
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/register'),
|
||||
child: const Text('REGISTER'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/local/collections/user_collection.dart';
|
||||
import '../../../gamification/domain/entities/avatar_config.dart';
|
||||
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
||||
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
||||
import 'dart:convert'; // Für jsonDecode
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
bool _isLoading = false;
|
||||
double _currentBodyweight = 80.0;
|
||||
bool _hasChanges = false;
|
||||
UserCollection? _user;
|
||||
AvatarConfig? _tempAvatarConfig;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUser();
|
||||
}
|
||||
|
||||
Future<void> _loadUser() async {
|
||||
final user = await ref.read(userRepositoryProvider).getLocalUser();
|
||||
if (user != null && mounted) {
|
||||
setState(() {
|
||||
_user = user;
|
||||
_currentBodyweight = user.currentBodyweight;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Future<void> _loadUser() async {
|
||||
// final user = await ref.read(userRepositoryProvider).getLocalUser();
|
||||
// if (user != null && mounted) {
|
||||
// setState(() {
|
||||
// _currentBodyweight = user.currentBodyweight;
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<void> _saveBodyweight() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await ref
|
||||
.read(userRepositoryProvider)
|
||||
.updateBodyweight(_currentBodyweight);
|
||||
if (mounted) {
|
||||
setState(() => _hasChanges = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bodyweight updated')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showChangePasswordDialog() {
|
||||
final oldPassCtrl = TextEditingController();
|
||||
final newPassCtrl = TextEditingController();
|
||||
final confirmPassCtrl = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Change Password'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: oldPassCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Old Password'),
|
||||
validator: (v) => v?.isEmpty == true ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: newPassCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'New Password'),
|
||||
validator: (v) => (v?.length ?? 0) < 8 ? 'Min 8 chars' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: confirmPassCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Confirm New'),
|
||||
validator: (v) => v != newPassCtrl.text ? 'Mismatch' : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
Navigator.pop(context);
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await ref.read(userRepositoryProvider).changePassword(
|
||||
oldPassCtrl.text,
|
||||
newPassCtrl.text,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password changed successfully')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('UPDATE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDangerAction(
|
||||
String title, String content, VoidCallback onConfirm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title, style: const TextStyle(color: AppTheme.errorColor)),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style:
|
||||
ElevatedButton.styleFrom(backgroundColor: AppTheme.errorColor),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onConfirm();
|
||||
},
|
||||
child: const Text('CONFIRM'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAvatarEditor() {
|
||||
final currentConfig = _user?.avatarConfigJson != null
|
||||
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
|
||||
: const AvatarConfig();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true, // Wichtig für Fullscreen-Feeling
|
||||
useSafeArea: true,
|
||||
backgroundColor: AppTheme.backgroundColor,
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Edit Appearance'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Speichern wird hier ausgelöst durch den Save-Callback im Editor Wrapper
|
||||
// Aber da der Editor im BottomSheet state hat, müssen wir die Config rausbekommen.
|
||||
// Einfacher: Wir wrappen den Editor in ein Stateful Widget im Dialog oder übergeben einen Callback.
|
||||
// Da wir hier im ProfileScreen sind, können wir eine temporäre Variable nutzen und beim Schließen speichern.
|
||||
Navigator.pop(context, _tempAvatarConfig);
|
||||
},
|
||||
child: const Text('SAVE',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AvatarEditor(
|
||||
initialConfig: currentConfig,
|
||||
onChanged: (conf) => _tempAvatarConfig =
|
||||
conf, // _tempAvatarConfig muss in State definiert werden
|
||||
),
|
||||
),
|
||||
).then((result) async {
|
||||
if (result is AvatarConfig) {
|
||||
setState(() => _isLoading = true);
|
||||
// Speichern
|
||||
_user!.avatarConfigJson = jsonEncode(result.toJson());
|
||||
_user!.isDirty = true;
|
||||
await ref.read(userRepositoryProvider).saveLocalUser(_user!);
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userRepo = ref.watch(userRepositoryProvider);
|
||||
final avatarConfig = _user?.avatarConfigJson != null
|
||||
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
|
||||
: const AvatarConfig();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Edit Profile'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/hub'),
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : _saveBodyweight,
|
||||
child: const Text('SAVE',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
AvatarRenderer(
|
||||
config: avatarConfig,
|
||||
size: 120,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: AppTheme.surfaceColor,
|
||||
radius: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, size: 16),
|
||||
onPressed: _showAvatarEditor,
|
||||
// onPressed: () {
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// const SnackBar(
|
||||
// content: Text(
|
||||
// 'Avatar customization coming soon!')),
|
||||
// );
|
||||
// },
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bodyweight Section
|
||||
Text('Physical Stats',
|
||||
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('Current Bodyweight',
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _currentBodyweight,
|
||||
min: 40,
|
||||
max: 150,
|
||||
divisions: 220, // 0.5 steps
|
||||
label: _currentBodyweight.toStringAsFixed(1),
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (val) => setState(() {
|
||||
_currentBodyweight = val;
|
||||
_hasChanges = true;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_currentBodyweight.toStringAsFixed(1)} kg',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Account Actions
|
||||
Text('Account Security',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: const Text('Change Password'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _showChangePasswordDialog,
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Danger Zone
|
||||
const SizedBox(height: 24),
|
||||
Text('Danger Zone',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.errorColor)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border:
|
||||
Border.all(color: AppTheme.errorColor.withOpacity(0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: AppTheme.errorColor.withOpacity(0.05),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh,
|
||||
color: AppTheme.errorColor),
|
||||
title: const Text('Reset Progress',
|
||||
style: TextStyle(color: AppTheme.errorColor)),
|
||||
subtitle:
|
||||
const Text('Resets Level, XP and Training History'),
|
||||
onTap: () => _confirmDangerAction(
|
||||
'Reset Progress?',
|
||||
'This will delete all your workouts and reset your Level to 1. This cannot be undone.',
|
||||
() async {
|
||||
setState(() => _isLoading = true);
|
||||
await userRepo.resetProgress();
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
context.go('/hub');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_forever,
|
||||
color: AppTheme.errorColor),
|
||||
title: const Text('Delete Account',
|
||||
style: TextStyle(color: AppTheme.errorColor)),
|
||||
subtitle: const Text(
|
||||
'Permanently delete your account and data'),
|
||||
onTap: () => _confirmDangerAction(
|
||||
'Delete Account?',
|
||||
'Are you sure you want to delete your account? All data will be lost forever.',
|
||||
() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await userRepo.deleteAccount();
|
||||
if (mounted) context.go('/login');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await userRepo.logout();
|
||||
if (mounted) context.go('/login');
|
||||
},
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('LOGOUT'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../onboarding/presentation/screens/bodyweight_input_screen.dart';
|
||||
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleRegister() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
||||
'email': _emailController.text.trim(),
|
||||
'password': _passwordController.text,
|
||||
});
|
||||
|
||||
context.go('/onboarding/welcome');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/login'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'CREATE ACCOUNT',
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Begin your strength journey',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _obscurePassword = !_obscurePassword);
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _obscureConfirmPassword =
|
||||
!_obscureConfirmPassword);
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _handleRegister,
|
||||
child: const Text('CONTINUE'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Already have an account? ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: const Text('LOGIN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
488
lib/src/features/dashboard/presentation/screens/hub_screen.dart
Normal file
488
lib/src/features/dashboard/presentation/screens/hub_screen.dart
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/cycle_repository.dart';
|
||||
import '../../../../shared/domain/logic/xp_calculator.dart';
|
||||
import '../widgets/xp_bar_widget.dart';
|
||||
import '../widgets/level_display.dart';
|
||||
import '../widgets/start_raid_button.dart';
|
||||
import '../../../../shared/data/local/collections/user_collection.dart';
|
||||
import '../../../../shared/data/local/collections/cycle_collection.dart';
|
||||
import '../../../../shared/data/repositories/workout_repository.dart';
|
||||
import '../../../../shared/domain/entities/exercise.dart';
|
||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||
import '../../../../shared/data/remote/sync_service.dart';
|
||||
import '../../../../shared/domain/entities/workout_set.dart';
|
||||
import '../../../gamification/domain/entities/avatar_config.dart';
|
||||
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
||||
|
||||
class HubScreen extends ConsumerStatefulWidget {
|
||||
const HubScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HubScreen> createState() => _HubScreenState();
|
||||
}
|
||||
|
||||
class _HubScreenState extends ConsumerState<HubScreen> {
|
||||
bool _isSyncing = false;
|
||||
|
||||
Future<void> _runSync() async {
|
||||
setState(() => _isSyncing = true);
|
||||
await ref.read(syncServiceProvider).sync();
|
||||
if (mounted) setState(() => _isSyncing = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_runSync();
|
||||
});
|
||||
}
|
||||
|
||||
List<Exercise> _generateExercises({
|
||||
required int week,
|
||||
required int day,
|
||||
required Map<String, double> trainingMaxes,
|
||||
required double bodyweight,
|
||||
}) {
|
||||
final exercises = <Exercise>[];
|
||||
|
||||
void addExercise(String id, String name, ExerciseType type, bool isMain) {
|
||||
final tm = trainingMaxes[id] ?? 0.0;
|
||||
List<WorkoutSet> sets;
|
||||
|
||||
if (isMain) {
|
||||
sets = WendlerCalculator.generateSets(
|
||||
week: week,
|
||||
trainingMax: tm,
|
||||
exerciseType: type,
|
||||
currentBodyweight: bodyweight,
|
||||
);
|
||||
} else {
|
||||
if (week == 4) return;
|
||||
|
||||
sets = WendlerCalculator.generateFSLSets(
|
||||
trainingMax: tm,
|
||||
exerciseType: type,
|
||||
currentBodyweight: bodyweight,
|
||||
);
|
||||
}
|
||||
|
||||
if (sets.isNotEmpty) {
|
||||
exercises.add(Exercise(
|
||||
exerciseId: id,
|
||||
exerciseName: isMain ? name : '$name (FSL)',
|
||||
bodyweightAtSession: bodyweight,
|
||||
sets: sets,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (day == 1) {
|
||||
addExercise('squat', 'Back Squat', ExerciseType.squat, true);
|
||||
addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, false);
|
||||
} else if (day == 2) {
|
||||
addExercise('dip', 'Weighted Dip', ExerciseType.dip, true);
|
||||
addExercise('squat', 'Back Squat', ExerciseType.squat, false);
|
||||
} else if (day == 3) {
|
||||
addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, true);
|
||||
addExercise('dip', 'Weighted Dip', ExerciseType.dip, false);
|
||||
}
|
||||
|
||||
return exercises;
|
||||
}
|
||||
|
||||
Future<void> _startNextWorkout(
|
||||
CycleCollection cycle, UserCollection user) async {
|
||||
try {
|
||||
final workoutRepo = ref.read(workoutRepositoryProvider);
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
|
||||
int targetWeek = 1;
|
||||
int targetDay = 1;
|
||||
bool found = false;
|
||||
|
||||
final cycleRefId = cycle.serverId ?? cycle.id.toString();
|
||||
final localCycleId = cycle.id.toString();
|
||||
|
||||
for (int w = 1; w <= 4; w++) {
|
||||
for (int d = 1; d <= 3; d++) {
|
||||
final exists = await workoutRepo.getWorkoutByWeekDay(
|
||||
cycleId: cycleRefId, localCycleId: localCycleId, week: w, day: d);
|
||||
|
||||
if (exists == null || exists.completedAt == null) {
|
||||
targetWeek = w;
|
||||
targetDay = d;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cycle complete! Finish it in stats.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
|
||||
|
||||
var workout = await workoutRepo.getWorkoutByWeekDay(
|
||||
cycleId: cycleRefId,
|
||||
localCycleId: localCycleId,
|
||||
week: targetWeek,
|
||||
day: targetDay,
|
||||
);
|
||||
|
||||
if (workout == null) {
|
||||
final exercises = _generateExercises(
|
||||
week: targetWeek,
|
||||
day: targetDay,
|
||||
trainingMaxes: trainingMaxes,
|
||||
bodyweight: user.currentBodyweight,
|
||||
);
|
||||
|
||||
final userId = user.serverId ?? user.id.toString();
|
||||
|
||||
workout = await workoutRepo.createWorkout(
|
||||
userId: userId,
|
||||
cycleId: cycleRefId,
|
||||
week: targetWeek,
|
||||
day: targetDay,
|
||||
exercisesJson: jsonEncode(exercises.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
context.go('/battle', extra: {
|
||||
'week': targetWeek,
|
||||
'day': targetDay,
|
||||
'workoutId': workout!.id,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to start workout: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userRepo = ref.watch(userRepositoryProvider);
|
||||
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: FutureBuilder(
|
||||
future: Future.wait([
|
||||
userRepo.getLocalUser(),
|
||||
cycleRepo.getCurrentCycle(),
|
||||
]),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final user = snapshot.data![0] as UserCollection?;
|
||||
final cycle = snapshot.data![1] as CycleCollection?;
|
||||
final avatarConfig = user?.avatarConfigJson != null
|
||||
? AvatarConfig.fromJson(jsonDecode(user!.avatarConfigJson!))
|
||||
: const AvatarConfig();
|
||||
|
||||
if (user == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go('/login');
|
||||
});
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final xpProgress = XPCalculator.xpProgressPercentage(
|
||||
user.xp,
|
||||
user.level,
|
||||
);
|
||||
final nextLevelXP = XPCalculator.xpForNextLevel(user.level);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
AssetPaths.bgStreetParkDay, // Das düstere Gym
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
// Dunkler Overlay, damit die UI-Elemente gut lesbar sind
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.6), // Oben etwas heller
|
||||
Colors.black.withOpacity(
|
||||
0.85), // Unten fast schwarz für Buttons
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Container(
|
||||
// decoration: BoxDecoration(
|
||||
// gradient: LinearGradient(
|
||||
// begin: Alignment.topCenter,
|
||||
// end: Alignment.bottomCenter,
|
||||
// colors: [
|
||||
// const Color(0xFF1A237E),
|
||||
// AppTheme.backgroundColor,
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
onPressed: () => context.go('/profile'),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isSyncing
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Icon(
|
||||
user.isDirty
|
||||
? Icons.cloud_upload_outlined
|
||||
: Icons.cloud_done_outlined,
|
||||
color: user.isDirty
|
||||
? AppTheme.secondaryColor
|
||||
: AppTheme.successColor,
|
||||
),
|
||||
onPressed: _runSync,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 1),
|
||||
AvatarRenderer(
|
||||
config: avatarConfig,
|
||||
size: 160, // Etwas größer für den Hub
|
||||
),
|
||||
// Container(
|
||||
// width: 120,
|
||||
// height: 120,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.primaryColor.withOpacity(0.2),
|
||||
// shape: BoxShape.circle,
|
||||
// border:
|
||||
// Border.all(color: AppTheme.primaryColor, width: 3),
|
||||
// ),
|
||||
// child: const Icon(
|
||||
// Icons.fitness_center,
|
||||
// size: 64,
|
||||
// color: AppTheme.primaryColor,
|
||||
// ),
|
||||
// ),
|
||||
const SizedBox(height: 24),
|
||||
LevelDisplay(level: user.level),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: XPBarWidget(
|
||||
currentXP: user.xp,
|
||||
level: user.level,
|
||||
progress: xpProgress,
|
||||
nextLevelXP: nextLevelXP,
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 2),
|
||||
if (cycle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: StartRaidButton(
|
||||
onPressed: () => _startNextWorkout(cycle, user),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'No active cycle',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.push('/onboarding/strength-test');
|
||||
},
|
||||
child: const Text('Create New Cycle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (cycle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_StatBox(
|
||||
label: 'Cycle', value: '#${cycle.cycleNumber}'),
|
||||
_StatBox(label: 'Active', value: 'Yes'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 1),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_NavButton(
|
||||
icon: Icons.history,
|
||||
label: 'History',
|
||||
onTap: () => context.go('/history'),
|
||||
),
|
||||
_NavButton(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Inventory',
|
||||
onTap: () => context.go('/inventory'),
|
||||
),
|
||||
_NavButton(
|
||||
icon: Icons.bar_chart,
|
||||
label: 'Stats',
|
||||
onTap: () {
|
||||
context.go('/stats');
|
||||
},
|
||||
),
|
||||
_NavButton(
|
||||
icon: Icons.auto_stories, // Buch Icon
|
||||
label: 'Codex',
|
||||
onTap: () => context.go('/codex'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatBox extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _StatBox({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryColor),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class LevelDisplay extends StatelessWidget {
|
||||
final int level;
|
||||
|
||||
const LevelDisplay({
|
||||
super.key,
|
||||
required this.level,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.secondaryColor,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.military_tech,
|
||||
color: Colors.black,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'LEVEL',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
level.toString(),
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
color: Colors.black,
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class StartRaidButton extends StatefulWidget {
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const StartRaidButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StartRaidButton> createState() => _StartRaidButtonState();
|
||||
}
|
||||
|
||||
class _StartRaidButtonState extends State<StartRaidButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _glowAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_glowAnimation = Tween<double>(begin: 0.3, end: 0.6).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(_glowAnimation.value),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: widget.onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 48,
|
||||
vertical: 20,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.flash_on,
|
||||
size: 32,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'START RAID',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontSize: 20,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class XPBarWidget extends StatelessWidget {
|
||||
final int currentXP;
|
||||
final int level;
|
||||
final double progress;
|
||||
final int nextLevelXP;
|
||||
|
||||
const XPBarWidget({
|
||||
super.key,
|
||||
required this.currentXP,
|
||||
required this.level,
|
||||
required this.progress,
|
||||
required this.nextLevelXP,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// XP Text
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'XP',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'$currentXP / $nextLevelXP',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Progress Bar
|
||||
Stack(
|
||||
children: [
|
||||
// Background
|
||||
Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.xpBarBackground,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Fill
|
||||
FractionallySizedBox(
|
||||
widthFactor: progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Percentage Text
|
||||
Container(
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${(progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
const Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
// class AvatarConfig {
|
||||
// final String gender;
|
||||
// final String skinTone;
|
||||
// final String hairStyle;
|
||||
// final String clothing;
|
||||
|
||||
// const AvatarConfig({
|
||||
// this.gender = 'male',
|
||||
// this.skinTone = 'medium',
|
||||
// this.hairStyle = 'short_01',
|
||||
// this.clothing = 'basic_tee',
|
||||
// });
|
||||
|
||||
// factory AvatarConfig.fromJson(Map<String, dynamic> json) {
|
||||
// return AvatarConfig(
|
||||
// gender: json['gender'] ?? 'male',
|
||||
// skinTone: json['skin_tone'] ?? 'medium',
|
||||
// hairStyle: json['hair_style'] ?? 'short_01',
|
||||
// clothing: json['clothing'] ?? 'basic_tee',
|
||||
// );
|
||||
// }
|
||||
|
||||
// Map<String, dynamic> toJson() {
|
||||
// return {
|
||||
// 'gender': gender,
|
||||
// 'skin_tone': skinTone,
|
||||
// 'hair_style': hairStyle,
|
||||
// 'clothing': clothing,
|
||||
// };
|
||||
// }
|
||||
|
||||
// // Helper getters
|
||||
// String get baseAsset => gender == 'female'
|
||||
// ? AssetPaths.avatarFemaleBase
|
||||
// : AssetPaths.avatarMaleBase;
|
||||
|
||||
// Color get skinColor => AvatarConstants.skinTones[skinTone] ?? const Color(0xFFD1A384);
|
||||
|
||||
// String? get hairAsset => AvatarConstants.hairStyles[hairStyle];
|
||||
|
||||
// String? get clothingAsset => AvatarConstants.clothing[clothing];
|
||||
// }
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
class AvatarConfig {
|
||||
final String gender; // 'male' or 'female'
|
||||
final int variant; // 1 to 8
|
||||
|
||||
const AvatarConfig({
|
||||
this.gender = 'male',
|
||||
this.variant = 1,
|
||||
});
|
||||
|
||||
factory AvatarConfig.fromJson(Map<String, dynamic> json) {
|
||||
return AvatarConfig(
|
||||
gender: json['gender'] ?? 'male',
|
||||
variant: json['variant'] ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'gender': gender,
|
||||
'variant': variant,
|
||||
};
|
||||
}
|
||||
|
||||
String get assetPath => AssetPaths.getAvatarPath(gender, variant);
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
class CodexScreen extends StatelessWidget {
|
||||
const CodexScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Creature Codex'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/hub'),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
_LoreCard(
|
||||
name: 'Iron Golem',
|
||||
title: 'The Weight of the Earth',
|
||||
description:
|
||||
'Forged from the tectonic plates of the Deep Earth, the Iron Golem exists only to crush the weak. '
|
||||
'It embodies the unrelenting force of gravity acting on a heavy load.\n\n'
|
||||
'It respects only one thing: The raw power of the LEGS that can stand up against its crushing weight.',
|
||||
assetPath: AssetPaths.enemyIronGolem,
|
||||
exercise: 'Squat Nemesis',
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
_LoreCard(
|
||||
name: 'Gravity Demon',
|
||||
title: 'The Abyssal Pull',
|
||||
description:
|
||||
'A spirit of pure downward force that clings to the back of adventurers. '
|
||||
'It whispers lies of weakness into your ear while dragging you towards the abyss.\n\n'
|
||||
'Only those with a back of steel and the will to pull themselves up can escape its grasp.',
|
||||
assetPath: AssetPaths.enemyGravityDemon,
|
||||
exercise: 'Pull-up Nemesis',
|
||||
color: Colors.purpleAccent,
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
_LoreCard(
|
||||
name: 'Pressure Phantom',
|
||||
title: 'The Invisible Crusher',
|
||||
description:
|
||||
'An ethereal entity that compresses the very air around you. '
|
||||
'It seeks to collapse the chest and shoulders of any who dare to push against it.\n\n'
|
||||
'Defeat it by pushing through the pain with explosive dipping power.',
|
||||
assetPath: AssetPaths.enemyPressurePhantom,
|
||||
exercise: 'Dip Nemesis',
|
||||
color: Colors.cyanAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoreCard extends StatelessWidget {
|
||||
final String name;
|
||||
final String title;
|
||||
final String description;
|
||||
final String assetPath;
|
||||
final String exercise;
|
||||
final Color color;
|
||||
|
||||
const _LoreCard({
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.assetPath,
|
||||
required this.exercise,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color.withOpacity(0.5), width: 1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppTheme.surfaceColor,
|
||||
color.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header Image
|
||||
Container(
|
||||
height: 200,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
image: DecorationImage(
|
||||
image: AssetImage(AssetPaths.bgUndergroundGym), // Hintergrund für Atmosphäre
|
||||
fit: BoxFit.cover,
|
||||
opacity: 0.3,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
assetPath,
|
||||
fit: BoxFit.contain,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
colorBlendMode: BlendMode.modulate,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
name.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: Text(exercise, style: const TextStyle(fontSize: 10, color: Colors.white)),
|
||||
backgroundColor: Colors.black54,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
height: 1.5,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import '../../../../core/theme/app_theme.dart';
|
||||
// import '../../../../core/constants/asset_paths.dart';
|
||||
// import '../../domain/entities/avatar_config.dart';
|
||||
// import 'avatar_renderer.dart';
|
||||
|
||||
// class AvatarEditor extends StatefulWidget {
|
||||
// final AvatarConfig initialConfig;
|
||||
// final ValueChanged<AvatarConfig> onChanged;
|
||||
|
||||
// const AvatarEditor({
|
||||
// super.key,
|
||||
// required this.initialConfig,
|
||||
// required this.onChanged,
|
||||
// });
|
||||
|
||||
// @override
|
||||
// State<AvatarEditor> createState() => _AvatarEditorState();
|
||||
// }
|
||||
|
||||
// class _AvatarEditorState extends State<AvatarEditor> {
|
||||
// late AvatarConfig _config;
|
||||
// String _selectedTab = 'Body'; // Body, Hair, Style
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// _config = widget.initialConfig;
|
||||
// }
|
||||
|
||||
// void _updateConfig(AvatarConfig newConfig) {
|
||||
// setState(() => _config = newConfig);
|
||||
// widget.onChanged(newConfig);
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Column(
|
||||
// children: [
|
||||
// // 1. Live Preview
|
||||
// Container(
|
||||
// height: 220,
|
||||
// alignment: Alignment.center,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.surfaceColor,
|
||||
// border: const Border(bottom: BorderSide(color: Colors.white10)),
|
||||
// ),
|
||||
// child: AvatarRenderer(config: _config, size: 180),
|
||||
// ),
|
||||
|
||||
// // 2. Tabs
|
||||
// Row(
|
||||
// children: [
|
||||
// _buildTab('Body'),
|
||||
// _buildTab('Hair'),
|
||||
// _buildTab('Style'),
|
||||
// ],
|
||||
// ),
|
||||
|
||||
// // 3. Options Grid
|
||||
// Expanded(
|
||||
// child: Container(
|
||||
// color: AppTheme.backgroundColor,
|
||||
// child: _buildOptionsGrid(),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildTab(String label) {
|
||||
// final isSelected = _selectedTab == label;
|
||||
// return Expanded(
|
||||
// child: GestureDetector(
|
||||
// onTap: () => setState(() => _selectedTab = label),
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
// decoration: BoxDecoration(
|
||||
// border: Border(
|
||||
// bottom: BorderSide(
|
||||
// color: isSelected ? AppTheme.primaryColor : Colors.transparent,
|
||||
// width: 2,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// child: Text(
|
||||
// label,
|
||||
// textAlign: TextAlign.center,
|
||||
// style: TextStyle(
|
||||
// color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
||||
// fontWeight: FontWeight.bold,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildOptionsGrid() {
|
||||
// switch (_selectedTab) {
|
||||
// case 'Body':
|
||||
// return ListView(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// children: [
|
||||
// const Text('Gender', style: TextStyle(color: Colors.grey)),
|
||||
// const SizedBox(height: 8),
|
||||
// Row(
|
||||
// children: [
|
||||
// _buildChip('Male', _config.gender == 'male', () {
|
||||
// _updateConfig(AvatarConfig(
|
||||
// gender: 'male', skinTone: _config.skinTone,
|
||||
// hairStyle: _config.hairStyle, clothing: _config.clothing));
|
||||
// }),
|
||||
// const SizedBox(width: 8),
|
||||
// _buildChip('Female', _config.gender == 'female', () {
|
||||
// _updateConfig(AvatarConfig(
|
||||
// gender: 'female', skinTone: _config.skinTone,
|
||||
// hairStyle: _config.hairStyle, clothing: _config.clothing));
|
||||
// }),
|
||||
// ],
|
||||
// ),
|
||||
// const SizedBox(height: 24),
|
||||
// const Text('Skin Tone', style: TextStyle(color: Colors.grey)),
|
||||
// const SizedBox(height: 12),
|
||||
// Wrap(
|
||||
// spacing: 12,
|
||||
// runSpacing: 12,
|
||||
// children: AvatarConstants.skinTones.entries.map((e) {
|
||||
// return GestureDetector(
|
||||
// onTap: () {
|
||||
// _updateConfig(AvatarConfig(
|
||||
// gender: _config.gender, skinTone: e.key,
|
||||
// hairStyle: _config.hairStyle, clothing: _config.clothing));
|
||||
// },
|
||||
// child: Container(
|
||||
// width: 48,
|
||||
// height: 48,
|
||||
// decoration: BoxDecoration(
|
||||
// color: e.value,
|
||||
// shape: BoxShape.circle,
|
||||
// border: Border.all(
|
||||
// color: _config.skinTone == e.key ? AppTheme.primaryColor : Colors.transparent,
|
||||
// width: 3,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }).toList(),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// case 'Hair':
|
||||
// return _buildGrid(AvatarConstants.hairStyles.keys.toList(), (key) {
|
||||
// _updateConfig(AvatarConfig(
|
||||
// gender: _config.gender, skinTone: _config.skinTone,
|
||||
// hairStyle: key, clothing: _config.clothing));
|
||||
// }, _config.hairStyle);
|
||||
// case 'Style':
|
||||
// return _buildGrid(AvatarConstants.clothing.keys.toList(), (key) {
|
||||
// _updateConfig(AvatarConfig(
|
||||
// gender: _config.gender, skinTone: _config.skinTone,
|
||||
// hairStyle: _config.hairStyle, clothing: key));
|
||||
// }, _config.clothing);
|
||||
// default:
|
||||
// return const SizedBox();
|
||||
// }
|
||||
// }
|
||||
|
||||
// Widget _buildGrid(List<String> items, Function(String) onSelect, String current) {
|
||||
// return GridView.builder(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
// crossAxisCount: 3, crossAxisSpacing: 12, mainAxisSpacing: 12, childAspectRatio: 1.0),
|
||||
// itemCount: items.length,
|
||||
// itemBuilder: (context, index) {
|
||||
// final key = items[index];
|
||||
// final isSelected = current == key;
|
||||
// return GestureDetector(
|
||||
// onTap: () => onSelect(key),
|
||||
// child: Container(
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.surfaceColor,
|
||||
// borderRadius: BorderRadius.circular(12),
|
||||
// border: Border.all(
|
||||
// color: isSelected ? AppTheme.primaryColor : Colors.transparent,
|
||||
// width: 2,
|
||||
// ),
|
||||
// ),
|
||||
// child: Center(
|
||||
// // Für MVP zeigen wir den Text-Key, später könnte hier ein Icon hin
|
||||
// child: Text(
|
||||
// key.replaceAll('_', ' ').toUpperCase(),
|
||||
// textAlign: TextAlign.center,
|
||||
// style: TextStyle(
|
||||
// color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
||||
// fontSize: 10,
|
||||
// fontWeight: FontWeight.bold
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildChip(String label, bool isSelected, VoidCallback onTap) {
|
||||
// return ActionChip(
|
||||
// label: Text(label),
|
||||
// backgroundColor: isSelected ? AppTheme.primaryColor.withOpacity(0.2) : null,
|
||||
// side: BorderSide(color: isSelected ? AppTheme.primaryColor : Colors.grey),
|
||||
// labelStyle: TextStyle(
|
||||
// color: isSelected ? AppTheme.primaryColor : Colors.white,
|
||||
// fontWeight: FontWeight.bold,
|
||||
// ),
|
||||
// onPressed: onTap,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../domain/entities/avatar_config.dart';
|
||||
import 'avatar_renderer.dart';
|
||||
|
||||
class AvatarEditor extends StatefulWidget {
|
||||
final AvatarConfig initialConfig;
|
||||
final ValueChanged<AvatarConfig> onChanged;
|
||||
|
||||
const AvatarEditor({
|
||||
super.key,
|
||||
required this.initialConfig,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AvatarEditor> createState() => _AvatarEditorState();
|
||||
}
|
||||
|
||||
class _AvatarEditorState extends State<AvatarEditor> {
|
||||
late String _gender;
|
||||
late int _variant;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_gender = widget.initialConfig.gender;
|
||||
_variant = widget.initialConfig.variant;
|
||||
}
|
||||
|
||||
void _update(String gender, int variant) {
|
||||
setState(() {
|
||||
_gender = gender;
|
||||
_variant = variant;
|
||||
});
|
||||
widget.onChanged(AvatarConfig(gender: _gender, variant: _variant));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Preview
|
||||
Container(
|
||||
height: 200,
|
||||
alignment: Alignment.center,
|
||||
color: AppTheme.surfaceColor,
|
||||
child: AvatarRenderer(
|
||||
config: AvatarConfig(gender: _gender, variant: _variant),
|
||||
size: 160,
|
||||
),
|
||||
),
|
||||
|
||||
// Gender Switch
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'male', label: Text('Male')),
|
||||
ButtonSegment(value: 'female', label: Text('Female')),
|
||||
],
|
||||
selected: {_gender},
|
||||
onSelectionChanged: (Set<String> newSelection) {
|
||||
_update(
|
||||
newSelection.first, 1); // Reset to variant 1 on gender switch
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? AppTheme.primaryColor
|
||||
: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Variants Grid
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: 8, // Wir haben 8 Varianten pro Sheet
|
||||
itemBuilder: (context, index) {
|
||||
final variantNum = index + 1;
|
||||
final isSelected = _variant == variantNum;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _update(_gender, variantNum),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppTheme.primaryColor
|
||||
: Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.white10,
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Image.asset(
|
||||
'assets/images/avatars/$_gender/$variantNum.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import '../../domain/entities/avatar_config.dart';
|
||||
|
||||
// class AvatarRenderer extends StatelessWidget {
|
||||
// final AvatarConfig config;
|
||||
// final double size;
|
||||
|
||||
// const AvatarRenderer({
|
||||
// super.key,
|
||||
// required this.config,
|
||||
// this.size = 120.0,
|
||||
// });
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return SizedBox(
|
||||
// width: size,
|
||||
// height: size,
|
||||
// child: Stack(
|
||||
// alignment: Alignment.center,
|
||||
// children: [
|
||||
// // 1. Layer: Background Circle (Optional, for glow)
|
||||
// Container(
|
||||
// decoration: BoxDecoration(
|
||||
// shape: BoxShape.circle,
|
||||
// boxShadow: [
|
||||
// BoxShadow(
|
||||
// color: Colors.black.withOpacity(0.3),
|
||||
// blurRadius: 10,
|
||||
// spreadRadius: 2,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// // 2. Layer: Body Base (Tinted with Skin Tone)
|
||||
// _buildLayer(
|
||||
// assetPath: config.baseAsset,
|
||||
// color: config.skinColor,
|
||||
// blendMode: BlendMode.modulate, // Färbt das weiße Pixel-Art ein
|
||||
// ),
|
||||
|
||||
// // 3. Layer: Eyes/Face (Könnte separat sein, hier Teil der Base angenommen oder weggelassen)
|
||||
|
||||
// // 4. Layer: Clothing
|
||||
// if (config.clothingAsset != null)
|
||||
// _buildLayer(assetPath: config.clothingAsset!),
|
||||
|
||||
// // 5. Layer: Hair
|
||||
// if (config.hairAsset != null)
|
||||
// _buildLayer(assetPath: config.hairAsset!),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildLayer({
|
||||
// required String assetPath,
|
||||
// Color? color,
|
||||
// BlendMode? blendMode,
|
||||
// }) {
|
||||
// return Image.asset(
|
||||
// assetPath,
|
||||
// width: size,
|
||||
// height: size,
|
||||
// fit: BoxFit.contain,
|
||||
// color: color,
|
||||
// colorBlendMode: blendMode,
|
||||
// // Fallback, falls Assets fehlen (damit die App nicht abstürzt)
|
||||
// errorBuilder: (context, error, stackTrace) {
|
||||
// return Container(
|
||||
// width: size,
|
||||
// height: size,
|
||||
// decoration: BoxDecoration(
|
||||
// color: Colors.grey.withOpacity(0.2),
|
||||
// shape: BoxShape.circle,
|
||||
// border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
// ),
|
||||
// child: const Center(
|
||||
// child: Icon(Icons.broken_image, size: 20, color: Colors.white24),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/avatar_config.dart';
|
||||
|
||||
class AvatarRenderer extends StatelessWidget {
|
||||
final AvatarConfig config;
|
||||
final double size;
|
||||
|
||||
const AvatarRenderer({
|
||||
super.key,
|
||||
required this.config,
|
||||
this.size = 120.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
config.assetPath,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (c, o, s) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.person, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
// import 'package:go_router/go_router.dart';
|
||||
// import 'package:intl/intl.dart';
|
||||
|
||||
// import '../../../../core/theme/app_theme.dart';
|
||||
// import '../../../../shared/data/repositories/workout_repository.dart';
|
||||
// import '../../../../shared/data/local/collections/workout_collection.dart';
|
||||
|
||||
// class HistoryScreen extends ConsumerStatefulWidget {
|
||||
// const HistoryScreen({super.key});
|
||||
|
||||
// @override
|
||||
// ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
|
||||
// }
|
||||
|
||||
// class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// final workoutRepo = ref.watch(workoutRepositoryProvider);
|
||||
|
||||
// return Scaffold(
|
||||
// appBar: AppBar(
|
||||
// title: const Text('Workout History'),
|
||||
// leading: IconButton(
|
||||
// icon: const Icon(Icons.arrow_back),
|
||||
// onPressed: () => context.go('/hub'),
|
||||
// ),
|
||||
// ),
|
||||
// body: FutureBuilder<List<WorkoutCollection>>(
|
||||
// future: workoutRepo.getCompletedWorkouts(),
|
||||
// builder: (context, snapshot) {
|
||||
// if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
// return const Center(child: CircularProgressIndicator());
|
||||
// }
|
||||
|
||||
// if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
// return Center(
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// Icon(
|
||||
// Icons.history,
|
||||
// size: 80,
|
||||
// color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
// ),
|
||||
// const SizedBox(height: 16),
|
||||
// Text(
|
||||
// 'No workout history yet',
|
||||
// style: Theme.of(context).textTheme.headlineMedium,
|
||||
// ),
|
||||
// const SizedBox(height: 8),
|
||||
// Text(
|
||||
// 'Complete your first workout to see it here',
|
||||
// style: Theme.of(context).textTheme.bodyMedium,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Sort by date descending
|
||||
// final workouts = snapshot.data!
|
||||
// ..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
|
||||
|
||||
// return ListView.builder(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// itemCount: workouts.length,
|
||||
// itemBuilder: (context, index) {
|
||||
// final workout = workouts[index];
|
||||
// final dateStr = DateFormat.yMMMd().format(workout.completedAt!);
|
||||
// final timeStr = DateFormat.jm().format(workout.completedAt!);
|
||||
|
||||
// return Card(
|
||||
// margin: const EdgeInsets.only(bottom: 16),
|
||||
// child: ExpansionTile(
|
||||
// leading: Container(
|
||||
// width: 48,
|
||||
// height: 48,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.primaryColor.withOpacity(0.2),
|
||||
// borderRadius: BorderRadius.circular(8),
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// 'W${workout.week}\nD${workout.day}',
|
||||
// textAlign: TextAlign.center,
|
||||
// style: const TextStyle(
|
||||
// fontWeight: FontWeight.bold,
|
||||
// fontSize: 12,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// title: Text(
|
||||
// dateStr,
|
||||
// style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
// color: AppTheme.primaryColor,
|
||||
// ),
|
||||
// ),
|
||||
// subtitle: Text('$timeStr • ${workout.xpEarned} XP'),
|
||||
// children: [
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// children: [
|
||||
// if (workout.notes.isNotEmpty) ...[
|
||||
// Text(
|
||||
// 'Notes:',
|
||||
// style: Theme.of(context).textTheme.labelLarge,
|
||||
// ),
|
||||
// Text(workout.notes),
|
||||
// const SizedBox(height: 16),
|
||||
// ],
|
||||
// // We could parse exercisesJson here to show details
|
||||
// // For MVP, just showing basic completion info
|
||||
// Text(
|
||||
// 'Workout Completed',
|
||||
// style: TextStyle(color: AppTheme.successColor),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/workout_repository.dart';
|
||||
import '../../../../shared/data/local/collections/workout_collection.dart';
|
||||
import '../../../../shared/domain/entities/exercise.dart';
|
||||
import '../../../../shared/domain/entities/workout_set.dart';
|
||||
|
||||
class HistoryScreen extends ConsumerStatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||
Future<List<WorkoutCollection>> _loadHistory() async {
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final workoutRepo = ref.read(workoutRepositoryProvider);
|
||||
|
||||
final user = await userRepo.getLocalUser();
|
||||
if (user == null) return [];
|
||||
|
||||
final userId = user.serverId ?? user.id.toString();
|
||||
return workoutRepo.getCompletedWorkouts(userId); // ID übergeben
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final workoutRepo = ref.watch(workoutRepositoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Quest Log'), // "Quest Log" passt besser zum RPG Theme als "History"
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/hub'),
|
||||
),
|
||||
),
|
||||
body: FutureBuilder<List<WorkoutCollection>>(
|
||||
// future: workoutRepo.getCompletedWorkouts(),
|
||||
future: _loadHistory(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_edu,
|
||||
size: 80,
|
||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No completed quests yet',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Complete a workout to fill your journal',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
final workouts = snapshot.data!
|
||||
..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: workouts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final workout = workouts[index];
|
||||
return _WorkoutHistoryCard(workout: workout);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkoutHistoryCard extends StatelessWidget {
|
||||
final WorkoutCollection workout;
|
||||
|
||||
const _WorkoutHistoryCard({required this.workout});
|
||||
|
||||
List<Exercise> _parseExercises() {
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(workout.exercisesJson);
|
||||
return jsonList.map((json) => Exercise.fromJson(json)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing workout history: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateStr = DateFormat.yMMMd().format(workout.completedAt!);
|
||||
final timeStr = DateFormat.jm().format(workout.completedAt!);
|
||||
final exercises = _parseExercises();
|
||||
|
||||
// Zusammenfassung der trainierten Muskelgruppen/Übungen für den Titel
|
||||
final summary = exercises
|
||||
.map((e) =>
|
||||
e.exerciseName.replaceAll('Weighted ', '').replaceAll('Back ', ''))
|
||||
.toSet()
|
||||
.join(' & ');
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.all(16),
|
||||
leading: _buildDateBadge(context, workout),
|
||||
title: Text(
|
||||
summary.isEmpty ? 'Unknown Workout' : summary,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 14, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr • $timeStr',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
Text('+${workout.xpEarned} XP',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryColor,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
if (workout.notes.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Text(
|
||||
'📝 "${workout.notes}"',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
...exercises
|
||||
.map((exercise) => _ExerciseDetailRow(exercise: exercise))
|
||||
.toList(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateBadge(BuildContext context, WorkoutCollection workout) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'W${workout.week}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'D${workout.day}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExerciseDetailRow extends StatelessWidget {
|
||||
final Exercise exercise;
|
||||
|
||||
const _ExerciseDetailRow({required this.exercise});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
Text(
|
||||
exercise.exerciseName,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Table(
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(1), // Set
|
||||
1: FlexColumnWidth(2), // Weight
|
||||
2: FlexColumnWidth(2), // Reps
|
||||
3: FlexColumnWidth(1), // Type (AMRAP/FSL)
|
||||
},
|
||||
children: exercise.sets.where((s) => s.completed).map((set) {
|
||||
return TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
'#${set.setNumber}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${set.targetWeightTotal} kg',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${set.repsActual}',
|
||||
style: TextStyle(
|
||||
color: set.isAmrap
|
||||
? AppTheme.secondaryColor
|
||||
: (set.repsActual >= set.repsTarget
|
||||
? AppTheme.successColor
|
||||
: AppTheme.errorColor),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' / ${set.repsTarget}',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (set.isAmrap)
|
||||
const Icon(Icons.local_fire_department,
|
||||
size: 16, color: AppTheme.secondaryColor)
|
||||
else
|
||||
const SizedBox(),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../widgets/plate_counter.dart';
|
||||
|
||||
class InventoryScreen extends ConsumerStatefulWidget {
|
||||
const InventoryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventoryScreen> createState() => _InventoryScreenState();
|
||||
}
|
||||
|
||||
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||
double _barWeight = 20.0;
|
||||
Map<double, int> _plateInventory = {};
|
||||
Map<String, bool> _bandInventory = {};
|
||||
bool _isLoading = true;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentInventory();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentInventory() async {
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final inventory = userRepo.getInventorySettings();
|
||||
|
||||
final barWeight = (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
|
||||
|
||||
final platesList = (inventory['plates'] as List?)
|
||||
?.map((e) => (e as num).toDouble())
|
||||
.toList() ??
|
||||
[];
|
||||
final plateMap = <double, int>{
|
||||
25.0: 0,
|
||||
20.0: 0,
|
||||
15.0: 0,
|
||||
10.0: 0,
|
||||
5.0: 0,
|
||||
2.5: 0,
|
||||
1.25: 0
|
||||
};
|
||||
|
||||
for (var p in platesList) {
|
||||
if (plateMap.containsKey(p)) {
|
||||
plateMap[p] = (plateMap[p] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
final bandsList = (inventory['bands'] as List?) ?? [];
|
||||
final bandMap = <String, bool>{
|
||||
'Blue': false,
|
||||
'Green': false,
|
||||
'Orange': false,
|
||||
'Red': false
|
||||
};
|
||||
|
||||
for (var b in bandsList) {
|
||||
final color = b['color'] as String;
|
||||
if (bandMap.containsKey(color)) {
|
||||
bandMap[color] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_barWeight = barWeight;
|
||||
_plateInventory = plateMap;
|
||||
_bandInventory = bandMap;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyPreset(String preset) {
|
||||
setState(() {
|
||||
_hasChanges = true;
|
||||
switch (preset) {
|
||||
case 'home':
|
||||
_plateInventory = {
|
||||
25.0: 0,
|
||||
20.0: 2,
|
||||
15.0: 0,
|
||||
10.0: 2,
|
||||
5.0: 2,
|
||||
2.5: 2,
|
||||
1.25: 2
|
||||
};
|
||||
break;
|
||||
case 'commercial':
|
||||
_plateInventory = {
|
||||
25.0: 4,
|
||||
20.0: 4,
|
||||
15.0: 2,
|
||||
10.0: 4,
|
||||
5.0: 4,
|
||||
2.5: 4,
|
||||
1.25: 2
|
||||
};
|
||||
break;
|
||||
case 'minimal':
|
||||
_plateInventory = {
|
||||
25.0: 0,
|
||||
20.0: 2,
|
||||
15.0: 0,
|
||||
10.0: 0,
|
||||
5.0: 2,
|
||||
2.5: 0,
|
||||
1.25: 0
|
||||
};
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
|
||||
final platesList = <double>[];
|
||||
_plateInventory.forEach((weight, count) {
|
||||
for (int i = 0; i < count; i++) platesList.add(weight);
|
||||
});
|
||||
|
||||
final bandsList = <Map<String, dynamic>>[];
|
||||
_bandInventory.forEach((color, isSelected) {
|
||||
if (isSelected) {
|
||||
bandsList.add({
|
||||
'color': color,
|
||||
'resistance_kg': AppConstants.defaultBands[color] ?? 0.0,
|
||||
'count': 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final newSettings = {
|
||||
'bar_weight': _barWeight,
|
||||
'plates': platesList,
|
||||
'bands': bandsList,
|
||||
};
|
||||
|
||||
await userRepo.updateInventory(newSettings);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inventory updated successfully')),
|
||||
);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error saving: $e'),
|
||||
backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _getBandColor(String name) {
|
||||
switch (name) {
|
||||
case 'Blue':
|
||||
return Colors.blue;
|
||||
case 'Green':
|
||||
return Colors.green;
|
||||
case 'Orange':
|
||||
return Colors.orange;
|
||||
case 'Red':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading && !_hasChanges) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Manage Equipment'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/hub'),
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('SAVE',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold)),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Barbell Weight',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.fitness_center,
|
||||
size: 32, color: AppTheme.primaryColor),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _barWeight,
|
||||
min: 10,
|
||||
max: 25,
|
||||
divisions: 6,
|
||||
label: '$_barWeight kg',
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (val) => setState(() {
|
||||
_barWeight = val;
|
||||
_hasChanges = true;
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text('${_barWeight.toStringAsFixed(1)} kg',
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Quick Presets',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ActionChip(
|
||||
label: const Text('Home Gym'),
|
||||
onPressed: () => _applyPreset('home')),
|
||||
const SizedBox(width: 8),
|
||||
ActionChip(
|
||||
label: const Text('Commercial'),
|
||||
onPressed: () => _applyPreset('commercial')),
|
||||
const SizedBox(width: 8),
|
||||
ActionChip(
|
||||
label: const Text('Minimal'),
|
||||
onPressed: () => _applyPreset('minimal')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Plates Available',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
..._plateInventory.entries.map((entry) {
|
||||
return PlateCounter(
|
||||
weight: entry.key,
|
||||
count: entry.value,
|
||||
onChanged: (newCount) {
|
||||
setState(() {
|
||||
_plateInventory[entry.key] = newCount;
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
Text('Resistance Bands (Assistance)',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _bandInventory.entries.map((entry) {
|
||||
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
|
||||
return FilterChip(
|
||||
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
|
||||
selected: entry.value,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_bandInventory[entry.key] = selected;
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
selectedColor: _getBandColor(entry.key).withOpacity(0.3),
|
||||
checkmarkColor: _getBandColor(entry.key),
|
||||
side: BorderSide(color: _getBandColor(entry.key)),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
if (_hasChanges)
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _saveChanges,
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.black)
|
||||
: const Text('SAVE CHANGES'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
class PlateCounter extends StatelessWidget {
|
||||
final double weight;
|
||||
final int count;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
const PlateCounter({
|
||||
super.key,
|
||||
required this.weight,
|
||||
required this.count,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
Color _getPlateColor() {
|
||||
final colorValue = PlateColors.colors[weight];
|
||||
return colorValue != null ? Color(colorValue) : Colors.grey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Plate Visual
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _getPlateColor(),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white24,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
weight == weight.toInt()
|
||||
? '${weight.toInt()}'
|
||||
: weight.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Weight Label
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${weight.toStringAsFixed(weight == weight.toInt() ? 0 : 2)} kg',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
|
||||
// Counter
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: count > 0 ? () => onChanged(count - 1) : null,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1), // Hintergrund
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border:
|
||||
Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white, // Explizit Weiß
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// SizedBox(
|
||||
// width: 32,
|
||||
// child: Text(
|
||||
// count.toString(),
|
||||
// style: Theme.of(context).textTheme.titleLarge,
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// ),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: count < 20
|
||||
? () => onChanged(count + 1)
|
||||
: null, // Limit erhöht auf 20
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/cycle_repository.dart';
|
||||
import '../../../gamification/domain/entities/avatar_config.dart';
|
||||
import '../../../gamification/presentation/widgets/avatar_editor.dart';
|
||||
import 'bodyweight_input_screen.dart'; // Für den Provider
|
||||
|
||||
class AvatarSetupScreen extends ConsumerStatefulWidget {
|
||||
const AvatarSetupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AvatarSetupScreen> createState() => _AvatarSetupScreenState();
|
||||
}
|
||||
|
||||
class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
|
||||
AvatarConfig _config = const AvatarConfig();
|
||||
bool _isLoading = false;
|
||||
|
||||
// Future<void> _handleFinish() async {
|
||||
// setState(() => _isLoading = true);
|
||||
|
||||
// try {
|
||||
// final onboardingData = ref.read(onboardingDataProvider);
|
||||
// final userRepo = ref.read(userRepositoryProvider);
|
||||
|
||||
// // Inventory Settings aus dem Provider holen (muss dort gespeichert worden sein)
|
||||
// final inventorySettings = onboardingData['inventory_settings'] as Map<String, dynamic>;
|
||||
|
||||
// // Registrierung durchführen
|
||||
// final user = await userRepo.register(
|
||||
// email: onboardingData['email'] ?? '',
|
||||
// password: onboardingData['password'] ?? '',
|
||||
// bodyweight: onboardingData['bodyweight'] ?? 80.0,
|
||||
// inventorySettings: inventorySettings,
|
||||
// );
|
||||
|
||||
// // Avatar speichern (separates Update, da register meist nur Basisdaten nimmt,
|
||||
// // oder wir packen es direkt in register rein, wenn die API es erlaubt.
|
||||
// // Hier machen wir es sicherheitshalber als Update, falls register streng ist).
|
||||
// // Update: UserRepo.register unterstützt avatarConfig laut Code!
|
||||
// // Aber wir haben UserRepo.register schon aufgerufen. Da wir den User jetzt lokal haben,
|
||||
// // können wir das avatarConfigJson updaten und speichern.
|
||||
|
||||
// // Update local user with avatar config
|
||||
// user.avatarConfigJson = jsonEncode(_config.toJson());
|
||||
// user.isDirty = true;
|
||||
// await userRepo.saveLocalUser(user);
|
||||
// // Optional: Sofort syncen, oder einfach auf Background Sync warten.
|
||||
|
||||
// // Cycle erstellen
|
||||
// final trainingMaxes = onboardingData['training_maxes'] as Map<String, dynamic>?;
|
||||
// if (trainingMaxes != null) {
|
||||
// final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
// final tmMap = <String, double>{
|
||||
// 'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
|
||||
// 'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
|
||||
// 'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
|
||||
// };
|
||||
// await cycleRepo.createCycle(tmMap);
|
||||
// }
|
||||
|
||||
// if (mounted) {
|
||||
// ref.read(onboardingDataProvider.notifier).state = {}; // Cleanup
|
||||
// context.go('/hub');
|
||||
// }
|
||||
// } catch (e) {
|
||||
// if (mounted) {
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(content: Text('Setup failed: $e'), backgroundColor: AppTheme.errorColor),
|
||||
// );
|
||||
// }
|
||||
// } finally {
|
||||
// if (mounted) setState(() => _isLoading = false);
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<void> _handleFinish() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final onboardingData = ref.read(onboardingDataProvider);
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final inventorySettings =
|
||||
onboardingData['inventory_settings'] as Map<String, dynamic>;
|
||||
|
||||
// PRÜFUNG: Sind wir schon eingeloggt? (Reset Fall)
|
||||
var user = await userRepo.getLocalUser();
|
||||
|
||||
if (user == null) {
|
||||
// FALL A: Neuer User -> Registrieren
|
||||
user = await userRepo.register(
|
||||
email: onboardingData['email'] ?? '',
|
||||
password: onboardingData['password'] ?? '',
|
||||
bodyweight: onboardingData['bodyweight'] ?? 80.0,
|
||||
inventorySettings: inventorySettings,
|
||||
);
|
||||
} else {
|
||||
// FALL B: Existierender User (Reset) -> Nur Updaten
|
||||
user.currentBodyweight =
|
||||
onboardingData['bodyweight'] ?? user.currentBodyweight;
|
||||
user.inventorySettingsJson = jsonEncode(inventorySettings);
|
||||
user.isDirty = true;
|
||||
await userRepo.saveLocalUser(user);
|
||||
|
||||
// Server Update triggern (via Repo Methoden die API rufen)
|
||||
try {
|
||||
await userRepo.updateBodyweight(user.currentBodyweight);
|
||||
await userRepo.updateInventory(inventorySettings);
|
||||
} catch (e) {
|
||||
// Sync macht das später
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar speichern (für beide Fälle gleich)
|
||||
user!.avatarConfigJson = jsonEncode(_config.toJson());
|
||||
user.isDirty = true;
|
||||
await userRepo.saveLocalUser(user);
|
||||
|
||||
// Cycle erstellen (für beide Fälle gleich)
|
||||
final trainingMaxes =
|
||||
onboardingData['training_maxes'] as Map<String, dynamic>?;
|
||||
if (trainingMaxes != null) {
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
final tmMap = <String, double>{
|
||||
'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
|
||||
'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
|
||||
'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
|
||||
};
|
||||
await cycleRepo.createCycle(tmMap);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ref.read(onboardingDataProvider.notifier).state = {};
|
||||
context.go('/hub');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Setup failed: $e'),
|
||||
backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// title: const Text('Create Character'),
|
||||
title: const Text('Choose Your Hero'), // Statt "Create Character"
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : _handleFinish,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('FINISH',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor)),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: AppTheme.surfaceColor,
|
||||
width: double.infinity,
|
||||
child: const Text(
|
||||
'This is how the legends will remember you.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AvatarEditor(
|
||||
initialConfig: _config,
|
||||
onChanged: (newConfig) => _config = newConfig,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// body: AvatarEditor(
|
||||
// initialConfig: _config,
|
||||
// onChanged: (newConfig) => _config = newConfig,
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
// Provider to store onboarding data
|
||||
final onboardingDataProvider =
|
||||
StateProvider<Map<String, dynamic>>((ref) => {});
|
||||
|
||||
class BodyweightInputScreen extends ConsumerStatefulWidget {
|
||||
const BodyweightInputScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BodyweightInputScreen> createState() =>
|
||||
_BodyweightInputScreenState();
|
||||
}
|
||||
|
||||
class _BodyweightInputScreenState
|
||||
extends ConsumerState<BodyweightInputScreen> {
|
||||
double _bodyweight = 80.0;
|
||||
bool _useKg = true;
|
||||
|
||||
void _handleContinue() {
|
||||
// Store bodyweight
|
||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
||||
...state,
|
||||
'bodyweight': _bodyweight,
|
||||
});
|
||||
|
||||
context.go('/onboarding/strength-test');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Setup Profile'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/onboarding/welcome'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Indicator
|
||||
LinearProgressIndicator(
|
||||
value: 0.25,
|
||||
backgroundColor: AppTheme.xpBarBackground,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
'What\'s your current bodyweight?',
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'We need this to calculate your weighted calisthenics exercises',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Unit Toggle
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(value: true, label: Text('KG')),
|
||||
ButtonSegment(value: false, label: Text('LBS')),
|
||||
],
|
||||
selected: {_useKg},
|
||||
onSelectionChanged: (Set<bool> newSelection) {
|
||||
setState(() {
|
||||
_useKg = newSelection.first;
|
||||
if (_useKg) {
|
||||
_bodyweight = _bodyweight / 2.20462;
|
||||
} else {
|
||||
_bodyweight = _bodyweight * 2.20462;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bodyweight Display
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_bodyweight.toStringAsFixed(1),
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
fontSize: 72,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_useKg ? 'kg' : 'lbs',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Slider
|
||||
Slider(
|
||||
value: _bodyweight,
|
||||
min: _useKg
|
||||
? AppConstants.minBodyweight
|
||||
: AppConstants.minBodyweight * 2.20462,
|
||||
max: _useKg
|
||||
? AppConstants.maxBodyweight
|
||||
: AppConstants.maxBodyweight * 2.20462,
|
||||
divisions: _useKg ? 160 : 352,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() => _bodyweight = value);
|
||||
},
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Continue Button
|
||||
ElevatedButton(
|
||||
onPressed: _handleContinue,
|
||||
child: const Text('CONTINUE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/cycle_repository.dart';
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
import '../../../inventory/presentation/widgets/plate_counter.dart';
|
||||
import 'bodyweight_input_screen.dart';
|
||||
|
||||
class InventorySetupScreen extends ConsumerStatefulWidget {
|
||||
const InventorySetupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventorySetupScreen> createState() =>
|
||||
_InventorySetupScreenState();
|
||||
}
|
||||
|
||||
class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
|
||||
double _barWeight = AppConstants.defaultBarWeight;
|
||||
Map<double, int> _plateInventory = {
|
||||
25.0: 2,
|
||||
20.0: 2,
|
||||
15.0: 0,
|
||||
10.0: 2,
|
||||
5.0: 2,
|
||||
2.5: 2,
|
||||
1.25: 2,
|
||||
};
|
||||
|
||||
// Band selection state - Default configuration based on standard colors
|
||||
final Map<String, bool> _bandInventory = {
|
||||
'Blue': true,
|
||||
'Green': true,
|
||||
'Orange': false,
|
||||
'Red': false,
|
||||
};
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
void _applyPreset(String preset) {
|
||||
setState(() {
|
||||
switch (preset) {
|
||||
case 'home':
|
||||
_plateInventory = {
|
||||
25.0: 0,
|
||||
20.0: 2,
|
||||
15.0: 0,
|
||||
10.0: 2,
|
||||
5.0: 2,
|
||||
2.5: 2,
|
||||
1.25: 2,
|
||||
};
|
||||
break;
|
||||
case 'commercial':
|
||||
_plateInventory = {
|
||||
25.0: 4,
|
||||
20.0: 4,
|
||||
15.0: 2,
|
||||
10.0: 4,
|
||||
5.0: 4,
|
||||
2.5: 4,
|
||||
1.25: 2,
|
||||
};
|
||||
break;
|
||||
case 'minimal':
|
||||
_plateInventory = {
|
||||
25.0: 0,
|
||||
20.0: 2,
|
||||
15.0: 0,
|
||||
10.0: 0,
|
||||
5.0: 2,
|
||||
2.5: 0,
|
||||
1.25: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleNext() {
|
||||
// Listen bauen (wie vorher)
|
||||
final platesList = <double>[];
|
||||
_plateInventory.forEach((weight, count) {
|
||||
for (int i = 0; i < count; i++) platesList.add(weight);
|
||||
});
|
||||
|
||||
final bandsList = <Map<String, dynamic>>[];
|
||||
_bandInventory.forEach((color, isSelected) {
|
||||
if (isSelected) {
|
||||
bandsList.add({
|
||||
'color': color,
|
||||
'resistance_kg': AppConstants.defaultBands[color] ?? 0.0,
|
||||
'count': 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final inventorySettings = {
|
||||
'bar_weight': _barWeight,
|
||||
'plates': platesList,
|
||||
'bands': bandsList,
|
||||
};
|
||||
|
||||
// Im Provider speichern für den nächsten Screen
|
||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
||||
...state,
|
||||
'inventory_settings': inventorySettings,
|
||||
});
|
||||
|
||||
context.push('/onboarding/avatar'); // Neue Route!
|
||||
}
|
||||
|
||||
Future<void> _handleFinish() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final onboardingData = ref.read(onboardingDataProvider);
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
|
||||
// Build plates list for DB
|
||||
final platesList = <double>[];
|
||||
_plateInventory.forEach((weight, count) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
platesList.add(weight);
|
||||
}
|
||||
});
|
||||
|
||||
// Build bands list for DB
|
||||
final bandsList = <Map<String, dynamic>>[];
|
||||
_bandInventory.forEach((color, isSelected) {
|
||||
if (isSelected) {
|
||||
bandsList.add({
|
||||
'color': color,
|
||||
'resistance_kg': AppConstants.defaultBands[color] ?? 0.0,
|
||||
'count': 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final inventorySettings = {
|
||||
'bar_weight': _barWeight,
|
||||
'plates': platesList,
|
||||
'bands': bandsList,
|
||||
};
|
||||
|
||||
// Register user with all data
|
||||
final user = await userRepo.register(
|
||||
email: onboardingData['email'] ?? '',
|
||||
password: onboardingData['password'] ?? '',
|
||||
bodyweight: onboardingData['bodyweight'] ?? 80.0,
|
||||
inventorySettings: inventorySettings,
|
||||
);
|
||||
|
||||
debugPrint('✅ User registered: ${user.serverId}');
|
||||
|
||||
// Create first cycle
|
||||
final trainingMaxes =
|
||||
onboardingData['training_maxes'] as Map<String, dynamic>?;
|
||||
|
||||
if (trainingMaxes != null) {
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
final tmMap = <String, double>{
|
||||
'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0,
|
||||
'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0,
|
||||
'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0,
|
||||
};
|
||||
|
||||
debugPrint('📊 Creating cycle with TMs: $tmMap');
|
||||
await cycleRepo.createCycle(tmMap);
|
||||
debugPrint('✅ Cycle created successfully');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Clear onboarding data
|
||||
ref.read(onboardingDataProvider.notifier).state = {};
|
||||
|
||||
// Navigate to hub
|
||||
context.go('/hub');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Setup failed: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
|
||||
if (mounted) {
|
||||
String message = 'Setup failed: ${e.toString()}';
|
||||
// Catch unique constraint error (PocketBase returns 400 usually)
|
||||
if (e.toString().toLowerCase().contains('unique') ||
|
||||
e.toString().toLowerCase().contains('email')) {
|
||||
message = 'Email already exists. Please login or use another email.';
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _getBandColor(String name) {
|
||||
switch (name) {
|
||||
case 'Blue':
|
||||
return Colors.blue;
|
||||
case 'Green':
|
||||
return Colors.green;
|
||||
case 'Orange':
|
||||
return Colors.orange;
|
||||
case 'Red':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Equipment Setup'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/onboarding/strength-test'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Indicator
|
||||
LinearProgressIndicator(
|
||||
value: 0.75,
|
||||
backgroundColor: AppTheme.xpBarBackground,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
'Equipment Inventory',
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tell us what equipment you have available',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bar Weight Selector
|
||||
Text(
|
||||
'Barbell Weight',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Slider(
|
||||
value: _barWeight,
|
||||
min: 10,
|
||||
max: 25,
|
||||
divisions: 3,
|
||||
label: '${_barWeight.toStringAsFixed(0)} kg',
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() => _barWeight = value);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'${_barWeight.toStringAsFixed(0)} kg',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Presets
|
||||
Text(
|
||||
'Quick Presets',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _applyPreset('home'),
|
||||
child: const Text('Home Gym'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _applyPreset('commercial'),
|
||||
child: const Text('Commercial'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _applyPreset('minimal'),
|
||||
child: const Text('Minimal'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Plate Selection
|
||||
Text(
|
||||
'Available Plates',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._plateInventory.entries.map((entry) {
|
||||
return PlateCounter(
|
||||
weight: entry.key,
|
||||
count: entry.value,
|
||||
onChanged: (newCount) {
|
||||
setState(() {
|
||||
_plateInventory[entry.key] = newCount;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Text(
|
||||
'Resistance Bands (Assistance)',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select bands you have for pullup/dip assistance',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _bandInventory.entries.map((entry) {
|
||||
final resistance = AppConstants.defaultBands[entry.key] ?? 0;
|
||||
return FilterChip(
|
||||
label: Text('${entry.key} (~${resistance.toInt()}kg)'),
|
||||
selected: entry.value,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_bandInventory[entry.key] = selected;
|
||||
});
|
||||
},
|
||||
selectedColor: _getBandColor(entry.key).withOpacity(0.3),
|
||||
checkmarkColor: _getBandColor(entry.key),
|
||||
labelStyle: TextStyle(
|
||||
color: entry.value ? Colors.white : Colors.grey,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: _getBandColor(entry.key),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _handleNext,
|
||||
child: const Text('NEXT STEP'),
|
||||
),
|
||||
// // Finish Button
|
||||
// ElevatedButton(
|
||||
// onPressed: _isLoading ? null : _handleFinish,
|
||||
// child: _isLoading
|
||||
// ? const SizedBox(
|
||||
// height: 20,
|
||||
// width: 20,
|
||||
// child: CircularProgressIndicator(
|
||||
// strokeWidth: 2,
|
||||
// color: Colors.black,
|
||||
// ),
|
||||
// )
|
||||
// : const Text('FINISH SETUP'),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// class _PlateCounter extends StatelessWidget {
|
||||
// final double weight;
|
||||
// final int count;
|
||||
// final ValueChanged<int> onChanged;
|
||||
|
||||
// const _PlateCounter({
|
||||
// required this.weight,
|
||||
// required this.count,
|
||||
// required this.onChanged,
|
||||
// });
|
||||
|
||||
// Color _getPlateColor() {
|
||||
// final colorValue = PlateColors.colors[weight];
|
||||
// return colorValue != null ? Color(colorValue) : Colors.grey;
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Card(
|
||||
// margin: const EdgeInsets.only(bottom: 8),
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(12),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// // Plate Visual
|
||||
// Container(
|
||||
// width: 48,
|
||||
// height: 48,
|
||||
// decoration: BoxDecoration(
|
||||
// color: _getPlateColor(),
|
||||
// shape: BoxShape.circle,
|
||||
// border: Border.all(
|
||||
// color: Colors.white24,
|
||||
// width: 2,
|
||||
// ),
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// weight == weight.toInt()
|
||||
// ? '${weight.toInt()}'
|
||||
// : weight.toStringAsFixed(2),
|
||||
// style: const TextStyle(
|
||||
// color: Colors.white,
|
||||
// fontWeight: FontWeight.bold,
|
||||
// fontSize: 12,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(width: 16),
|
||||
|
||||
// // Weight Label
|
||||
// Expanded(
|
||||
// child: Text(
|
||||
// '${weight.toStringAsFixed(weight == weight.toInt() ? 0 : 2)} kg',
|
||||
// style: Theme.of(context).textTheme.bodyLarge,
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// // Counter
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.remove_circle_outline),
|
||||
// onPressed: count > 0 ? () => onChanged(count - 1) : null,
|
||||
// color: AppTheme.primaryColor,
|
||||
// ),
|
||||
// SizedBox(
|
||||
// width: 32,
|
||||
// child: Text(
|
||||
// count.toString(),
|
||||
// style: Theme.of(context).textTheme.titleLarge,
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// ),
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.add_circle_outline),
|
||||
// onPressed: count < 10 ? () => onChanged(count + 1) : null,
|
||||
// color: AppTheme.primaryColor,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||
import 'bodyweight_input_screen.dart';
|
||||
|
||||
class StrengthTestScreen extends ConsumerStatefulWidget {
|
||||
const StrengthTestScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StrengthTestScreen> createState() => _StrengthTestScreenState();
|
||||
}
|
||||
|
||||
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers for each exercise
|
||||
final _squatWeightController = TextEditingController(text: '100');
|
||||
final _squatRepsController = TextEditingController(text: '5');
|
||||
final _pullupWeightController = TextEditingController(text: '0');
|
||||
final _pullupRepsController = TextEditingController(text: '8');
|
||||
final _dipWeightController = TextEditingController(text: '0');
|
||||
final _dipRepsController = TextEditingController(text: '10');
|
||||
|
||||
Map<String, double> _calculated1RMs = {};
|
||||
Map<String, double> _calculatedTMs = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateAll();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_squatWeightController.dispose();
|
||||
_squatRepsController.dispose();
|
||||
_pullupWeightController.dispose();
|
||||
_pullupRepsController.dispose();
|
||||
_dipWeightController.dispose();
|
||||
_dipRepsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _calculateAll() {
|
||||
final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
|
||||
|
||||
// Squat
|
||||
final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
|
||||
final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
|
||||
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
|
||||
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
|
||||
|
||||
// Pullup (bodyweight + additional weight)
|
||||
final pullupAdditional = double.tryParse(_pullupWeightController.text) ?? 0;
|
||||
final pullupReps = int.tryParse(_pullupRepsController.text) ?? 1;
|
||||
final pullupTotal = bodyweight + pullupAdditional;
|
||||
final pullup1RM = WendlerCalculator.calculate1RM(pullupTotal, pullupReps);
|
||||
final pullupTM = WendlerCalculator.calculateTrainingMax(pullup1RM);
|
||||
|
||||
// Dip (bodyweight + additional weight)
|
||||
final dipAdditional = double.tryParse(_dipWeightController.text) ?? 0;
|
||||
final dipReps = int.tryParse(_dipRepsController.text) ?? 1;
|
||||
final dipTotal = bodyweight + dipAdditional;
|
||||
final dip1RM = WendlerCalculator.calculate1RM(dipTotal, dipReps);
|
||||
final dipTM = WendlerCalculator.calculateTrainingMax(dip1RM);
|
||||
|
||||
setState(() {
|
||||
_calculated1RMs = {
|
||||
'squat': squat1RM,
|
||||
'pullup': pullup1RM,
|
||||
'dip': dip1RM,
|
||||
};
|
||||
_calculatedTMs = {
|
||||
'squat': squatTM,
|
||||
'pullup': pullupTM,
|
||||
'dip': dipTM,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void _handleContinue() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
// Store training maxes
|
||||
ref.read(onboardingDataProvider.notifier).update((state) => {
|
||||
...state,
|
||||
'training_maxes': _calculatedTMs,
|
||||
});
|
||||
|
||||
context.go('/onboarding/inventory');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bodyweight = ref.watch(onboardingDataProvider)['bodyweight'] ?? 80.0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Strength Test'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/onboarding/bodyweight'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Indicator
|
||||
LinearProgressIndicator(
|
||||
value: 0.5,
|
||||
backgroundColor: AppTheme.xpBarBackground,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
'Combat Calibration', // Statt "Strength Assessment"
|
||||
style: Theme.of(context).textTheme.displayMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'We need to assess your current power level to assign the correct monsters.', // Flavor
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
// Text(
|
||||
// 'Strength Assessment',
|
||||
// style: Theme.of(context).textTheme.displayMedium,
|
||||
// ),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter your recent best performance for each exercise',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Squat
|
||||
_ExerciseCard(
|
||||
exerciseName: 'Back Squat',
|
||||
icon: Icons.accessibility_new,
|
||||
weightController: _squatWeightController,
|
||||
repsController: _squatRepsController,
|
||||
isBodyweight: false,
|
||||
calculated1RM: _calculated1RMs['squat'] ?? 0,
|
||||
calculatedTM: _calculatedTMs['squat'] ?? 0,
|
||||
onChanged: _calculateAll,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pullup
|
||||
_ExerciseCard(
|
||||
exerciseName: 'Weighted Pull-up',
|
||||
icon: Icons.north,
|
||||
weightController: _pullupWeightController,
|
||||
repsController: _pullupRepsController,
|
||||
isBodyweight: true,
|
||||
bodyweight: bodyweight,
|
||||
calculated1RM: _calculated1RMs['pullup'] ?? 0,
|
||||
calculatedTM: _calculatedTMs['pullup'] ?? 0,
|
||||
onChanged: _calculateAll,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Dip
|
||||
_ExerciseCard(
|
||||
exerciseName: 'Weighted Dip',
|
||||
icon: Icons.south,
|
||||
weightController: _dipWeightController,
|
||||
repsController: _dipRepsController,
|
||||
isBodyweight: true,
|
||||
bodyweight: bodyweight,
|
||||
calculated1RM: _calculated1RMs['dip'] ?? 0,
|
||||
calculatedTM: _calculatedTMs['dip'] ?? 0,
|
||||
onChanged: _calculateAll,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Info Box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
// child: Text(
|
||||
// 'Training Max (TM) = 90% of your estimated 1RM. This is what we\'ll use for programming.',
|
||||
// style: Theme.of(context).textTheme.bodySmall,
|
||||
// ),
|
||||
child: Text(
|
||||
'Your "Training Max" (TM) is your base combat power. We calculate it as 90% of your max potential to ensure long-term survival.', // Flavor
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Continue Button
|
||||
ElevatedButton(
|
||||
onPressed: _handleContinue,
|
||||
child: const Text('CONTINUE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExerciseCard extends StatelessWidget {
|
||||
final String exerciseName;
|
||||
final IconData icon;
|
||||
final TextEditingController weightController;
|
||||
final TextEditingController repsController;
|
||||
final bool isBodyweight;
|
||||
final double bodyweight;
|
||||
final double calculated1RM;
|
||||
final double calculatedTM;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _ExerciseCard({
|
||||
required this.exerciseName,
|
||||
required this.icon,
|
||||
required this.weightController,
|
||||
required this.repsController,
|
||||
this.isBodyweight = false,
|
||||
this.bodyweight = 0,
|
||||
required this.calculated1RM,
|
||||
required this.calculatedTM,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: AppTheme.primaryColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
exerciseName,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Input Fields
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: weightController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: isBodyweight
|
||||
? 'Additional Weight (kg)'
|
||||
: 'Weight (kg)',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => onChanged(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: repsController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reps',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => onChanged(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Required';
|
||||
}
|
||||
final reps = int.tryParse(value);
|
||||
if (reps == null || reps < 1 || reps > 20) {
|
||||
return '1-20';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Calculations
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isBodyweight)
|
||||
_ResultRow(
|
||||
label: 'Total Weight',
|
||||
value:
|
||||
'${(bodyweight + (double.tryParse(weightController.text) ?? 0)).toStringAsFixed(1)} kg',
|
||||
),
|
||||
_ResultRow(
|
||||
label: 'Estimated 1RM',
|
||||
value: '${calculated1RM.toStringAsFixed(1)} kg',
|
||||
),
|
||||
_ResultRow(
|
||||
label: 'Training Max (90%)',
|
||||
value: '${calculatedTM.toStringAsFixed(1)} kg',
|
||||
highlight: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool highlight;
|
||||
|
||||
const _ResultRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.highlight = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: highlight ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: highlight ? AppTheme.primaryColor : null,
|
||||
fontWeight: highlight ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Scaffold(
|
||||
// body: SafeArea(
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(24),
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
// children: [
|
||||
// const Spacer(),
|
||||
|
||||
// // Logo
|
||||
// Container(
|
||||
// width: 120,
|
||||
// height: 120,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.primaryColor,
|
||||
// borderRadius: BorderRadius.circular(24),
|
||||
// ),
|
||||
// child: const Icon(
|
||||
// Icons.fitness_center,
|
||||
// size: 64,
|
||||
// color: Colors.black,
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(height: 32),
|
||||
|
||||
// // Title
|
||||
// Text(
|
||||
// 'WELCOME TO',
|
||||
// style: Theme.of(context).textTheme.headlineMedium,
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// Text(
|
||||
// 'SLRPG',
|
||||
// style: Theme.of(context).textTheme.displayLarge,
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// const SizedBox(height: 16),
|
||||
|
||||
// // Description
|
||||
// Text(
|
||||
// 'Transform your training into an epic RPG adventure',
|
||||
// style: Theme.of(context).textTheme.bodyLarge,
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// const SizedBox(height: 48),
|
||||
|
||||
// // Features
|
||||
// _FeatureItem(
|
||||
// icon: Icons.trending_up,
|
||||
// title: 'Progressive Overload',
|
||||
// description: 'Wendler 5/3/1 periodization',
|
||||
// ),
|
||||
// const SizedBox(height: 16),
|
||||
// _FeatureItem(
|
||||
// icon: Icons.videogame_asset,
|
||||
// title: 'Gamified Training',
|
||||
// description: 'Level up, earn XP, unlock achievements',
|
||||
// ),
|
||||
// const SizedBox(height: 16),
|
||||
// _FeatureItem(
|
||||
// icon: Icons.offline_bolt,
|
||||
// title: 'Offline First',
|
||||
// description: 'Train anywhere, sync when ready',
|
||||
// ),
|
||||
|
||||
// const Spacer(),
|
||||
|
||||
// // Continue Button
|
||||
// ElevatedButton(
|
||||
// onPressed: () => context.go('/onboarding/bodyweight'),
|
||||
// child: const Text('GET STARTED'),
|
||||
// ),
|
||||
// const SizedBox(height: 16),
|
||||
|
||||
// // Skip to Login
|
||||
// TextButton(
|
||||
// onPressed: () => context.go('/login'),
|
||||
// child: const Text('Already have an account? Login'),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// // ... imports
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 1. Hintergrund (Street Park)
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
AssetPaths.bgStreetParkDay,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
// 2. Overlay (Dunkel für Lesbarkeit)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Inhalt
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
// Logo (Optional: Kann bleiben oder weg)
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
blurRadius: 20)
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.fitness_center,
|
||||
size: 56, color: Colors.black),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// RPG Title
|
||||
Text(
|
||||
'ENTER THE ARENA', // Statt "WELCOME TO"
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white70,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
'S.L.R.P.G.',
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
const Shadow(color: Colors.black, blurRadius: 10)
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// RPG Description
|
||||
const Text(
|
||||
'The Iron Golems have awakened. The Gravity Demons are pulling the world into the abyss.\n\n'
|
||||
'Only a true Streetlifter can stop them. Are you ready to forge your body into a weapon?',
|
||||
style: TextStyle(
|
||||
fontSize: 16, height: 1.5, color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Features (Umformuliert)
|
||||
_FeatureItem(
|
||||
icon: Icons.shield, // Statt trending_up
|
||||
title: 'Build Your Armor',
|
||||
description: 'Progressive overload based on Wendler 5/3/1.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FeatureItem(
|
||||
icon: Icons.videogame_asset,
|
||||
// icon: Icons
|
||||
// .swords, // Statt videogame_asset (wenn Icon verfügbar, sonst Flash/Star)
|
||||
title: 'Slay Monsters',
|
||||
description:
|
||||
'Turn every rep into damage against epic foes.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FeatureItem(
|
||||
icon: Icons.inventory_2, // Statt offline_bolt
|
||||
title: 'Gather Loot',
|
||||
description: 'Earn XP, level up, and unlock new gear.',
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Button
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/onboarding/bodyweight'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
),
|
||||
child: const Text('BEGIN YOUR JOURNEY',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, letterSpacing: 1)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: const Text('Already a hero? Login here',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const _FeatureItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/src/features/stats/domain/entities/stats_data_point.dart
Normal file
22
lib/src/features/stats/domain/entities/stats_data_point.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class StatsDataPoint {
|
||||
final DateTime date;
|
||||
final double trainingMax;
|
||||
final double estimated1RM;
|
||||
final double totalVolume;
|
||||
|
||||
StatsDataPoint({
|
||||
required this.date,
|
||||
required this.trainingMax,
|
||||
required this.estimated1RM,
|
||||
required this.totalVolume,
|
||||
});
|
||||
|
||||
factory StatsDataPoint.fromJson(Map<String, dynamic> json) {
|
||||
return StatsDataPoint(
|
||||
date: DateTime.parse(json['date']),
|
||||
trainingMax: (json['training_max'] as num).toDouble(),
|
||||
estimated1RM: (json['estimated_1rm'] as num).toDouble(),
|
||||
totalVolume: (json['total_volume'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
590
lib/src/features/stats/presentation/screens/stats_screen.dart
Normal file
590
lib/src/features/stats/presentation/screens/stats_screen.dart
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/data/repositories/cycle_repository.dart';
|
||||
import '../../../../shared/data/local/collections/cycle_collection.dart';
|
||||
import '../../../../shared/data/remote/api_client.dart'; // Zugriff auf API
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/workout_repository.dart';
|
||||
import '../../../../shared/domain/entities/exercise.dart';
|
||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||
import '../../domain/entities/stats_data_point.dart';
|
||||
import '../widgets/progress_chart.dart';
|
||||
|
||||
class StatsScreen extends ConsumerStatefulWidget {
|
||||
const StatsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StatsScreen> createState() => _StatsScreenState();
|
||||
}
|
||||
|
||||
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||
bool _isLoading = false;
|
||||
|
||||
String _selectedExercise = 'squat'; // squat, pullup, dip
|
||||
String _selectedRange = '3m'; // 1m, 3m, 1y, all
|
||||
// Daten
|
||||
List<StatsDataPoint> _chartData = [];
|
||||
bool _isChartLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
setState(() => _isChartLoading = true);
|
||||
|
||||
try {
|
||||
final workoutRepo = ref.read(workoutRepositoryProvider);
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
|
||||
final user = await userRepo.getLocalUser();
|
||||
if (user == null) {
|
||||
setState(() => _isChartLoading = false);
|
||||
return;
|
||||
}
|
||||
final userId = user.serverId ?? user.id.toString();
|
||||
// 1. Alle abgeschlossenen Workouts laden (Lokal aus Isar)
|
||||
final allWorkouts = await workoutRepo.getCompletedWorkouts(userId);
|
||||
|
||||
final points = <StatsDataPoint>[];
|
||||
|
||||
for (var workout in allWorkouts) {
|
||||
if (workout.completedAt == null) continue;
|
||||
|
||||
// 2. Exercises parsen
|
||||
List<dynamic> exercisesJson = [];
|
||||
try {
|
||||
exercisesJson = jsonDecode(workout.exercisesJson);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double max1RM = 0.0;
|
||||
double sessionVolume = 0.0;
|
||||
bool foundExercise = false;
|
||||
double trainingMax =
|
||||
0.0; // Versuchen wir aus den Daten zu raten oder nehmen 0
|
||||
|
||||
// 3. Durch Übungen iterieren
|
||||
for (var exJson in exercisesJson) {
|
||||
final exercise = Exercise.fromJson(exJson);
|
||||
|
||||
// Nur die ausgewählte Übung betrachten
|
||||
if (exercise.exerciseId == _selectedExercise) {
|
||||
foundExercise = true;
|
||||
|
||||
for (var set in exercise.sets) {
|
||||
if (!set.completed || set.repsActual <= 0) continue;
|
||||
|
||||
// Volumen summieren
|
||||
sessionVolume += set.targetWeightTotal * set.repsActual;
|
||||
|
||||
// 1RM berechnen (Epley Formel)
|
||||
// Wir nutzen den WendlerCalculator, der die Logik schon hat
|
||||
final e1rm = WendlerCalculator.calculate1RM(
|
||||
set.targetWeightTotal, set.repsActual);
|
||||
|
||||
// Wir nehmen das beste Set des Tages als Wert für den Graphen
|
||||
if (e1rm > max1RM) {
|
||||
max1RM = e1rm;
|
||||
}
|
||||
|
||||
// Versuchen, das TM aus dem Prozentsatz rückzurechnen (optional)
|
||||
// TM = Weight / Percentage.
|
||||
if (set.targetPercentage > 0 && trainingMax == 0) {
|
||||
trainingMax =
|
||||
set.targetWeightTotal / (set.targetPercentage / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Datenpunkt erstellen, wenn Übung in diesem Workout vorkam
|
||||
if (foundExercise && max1RM > 0) {
|
||||
points.add(StatsDataPoint(
|
||||
date: workout.completedAt!,
|
||||
trainingMax:
|
||||
trainingMax, // Ist ggf. ungenau durch Rückrechnung, aber für Graph ok
|
||||
estimated1RM: max1RM,
|
||||
totalVolume: sessionVolume,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sortieren & Filtern (Zeitraum)
|
||||
points.sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
// Filter nach Datum (Range)
|
||||
final now = DateTime.now();
|
||||
final filteredPoints = points.where((p) {
|
||||
if (_selectedRange == '1m') {
|
||||
return p.date.isAfter(now.subtract(const Duration(days: 30)));
|
||||
} else if (_selectedRange == '3m') {
|
||||
return p.date.isAfter(now.subtract(const Duration(days: 90)));
|
||||
} else if (_selectedRange == '1y') {
|
||||
return p.date.isAfter(now.subtract(const Duration(days: 365)));
|
||||
}
|
||||
return true; // 'all'
|
||||
}).toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_chartData = filteredPoints;
|
||||
_isChartLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to calculate local stats: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_chartData = [];
|
||||
_isChartLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Future<void> _loadStats() async {
|
||||
// setState(() => _isChartLoading = true);
|
||||
// try {
|
||||
// final apiClient = ref
|
||||
// .read(apiClientProvider); // Braucht Provider in user_repository.dart
|
||||
|
||||
// // Hier rufen wir die echte API auf
|
||||
// // Hinweis: Wenn Offline, müssten wir hier lokal aus Isar aggregieren.
|
||||
// // Für MVP nutzen wir den API Endpoint wie im TDD spezifiziert.
|
||||
// try {
|
||||
// final response = await apiClient.getStatsHistory(
|
||||
// exercise: _selectedExercise,
|
||||
// range: _selectedRange,
|
||||
// );
|
||||
|
||||
// final pointsJson = response['data_points'] as List? ?? [];
|
||||
// final points =
|
||||
// pointsJson.map((json) => StatsDataPoint.fromJson(json)).toList();
|
||||
|
||||
// if (mounted) {
|
||||
// setState(() {
|
||||
// _chartData = points;
|
||||
// _isChartLoading = false;
|
||||
// });
|
||||
// }
|
||||
// } catch (e) {
|
||||
// // Fallback/Error Handling (z.B. Offline)
|
||||
// debugPrint('Failed to load stats: $e');
|
||||
// if (mounted) {
|
||||
// setState(() {
|
||||
// _chartData = []; // Leer anzeigen
|
||||
// _isChartLoading = false;
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// } catch (e) {
|
||||
// // ...
|
||||
// }
|
||||
// }
|
||||
|
||||
void _onFilterChanged(String exercise, String range) {
|
||||
setState(() {
|
||||
_selectedExercise = exercise;
|
||||
_selectedRange = range;
|
||||
});
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
Future<void> _handleFinishCycle(CycleCollection currentCycle) async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
|
||||
// 1. Alte TMs merken für den Vergleich
|
||||
final oldTMs =
|
||||
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
|
||||
|
||||
// 2. Zyklus abschließen (Stall Handling Logic läuft hier)
|
||||
final newCycle = await cycleRepo.finishCycle();
|
||||
final newTMs =
|
||||
jsonDecode(newCycle.trainingMaxesJson) as Map<String, dynamic>;
|
||||
|
||||
if (mounted) {
|
||||
// 3. Ergebnis anzeigen
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _CycleFinishDialog(
|
||||
oldTMs: oldTMs,
|
||||
newTMs: newTMs,
|
||||
newCycleNumber: newCycle.cycleNumber,
|
||||
),
|
||||
);
|
||||
|
||||
// UI aktualisieren
|
||||
setState(() {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error finishing cycle: $e'),
|
||||
backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cycleRepo = ref.watch(cycleRepositoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Statistics & Cycles'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/hub'),
|
||||
),
|
||||
),
|
||||
body: FutureBuilder<CycleCollection?>(
|
||||
future: cycleRepo.getCurrentCycle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final currentCycle = snapshot.data;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cycle Card (bleibt wie vorher)
|
||||
if (currentCycle != null) ...[
|
||||
_CurrentCycleCard(
|
||||
cycle: currentCycle,
|
||||
onFinish: _isLoading
|
||||
? null
|
||||
: () => _handleFinishCycle(currentCycle),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
Text(
|
||||
'Progress Analysis',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- FILTER CHIPS ---
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Squat',
|
||||
isSelected: _selectedExercise == 'squat',
|
||||
onTap: () => _onFilterChanged('squat', _selectedRange),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Pull-up',
|
||||
isSelected: _selectedExercise == 'pullup',
|
||||
onTap: () => _onFilterChanged('pullup', _selectedRange),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Dip',
|
||||
isSelected: _selectedExercise == 'dip',
|
||||
onTap: () => _onFilterChanged('dip', _selectedRange),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- CHART ---
|
||||
_isChartLoading
|
||||
? const SizedBox(
|
||||
height: 250,
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
: ProgressChart(data: _chartData),
|
||||
|
||||
// (Optional: Range Selector unten drunter '1M', '3M', '1Y'...)
|
||||
],
|
||||
),
|
||||
// return SingleChildScrollView(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
// children: [
|
||||
// if (currentCycle != null) ...[
|
||||
// _CurrentCycleCard(
|
||||
// cycle: currentCycle,
|
||||
// onFinish: _isLoading
|
||||
// ? null
|
||||
// : () => _handleFinishCycle(currentCycle),
|
||||
// ),
|
||||
// const SizedBox(height: 24),
|
||||
// ] else
|
||||
// const Card(
|
||||
// child: Padding(
|
||||
// padding: EdgeInsets.all(16),
|
||||
// child: Text('No active cycle found.'),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// // Platzhalter für zukünftige Graphen
|
||||
// Text(
|
||||
// 'Progress Charts',
|
||||
// style: Theme.of(context).textTheme.titleLarge,
|
||||
// ),
|
||||
// const SizedBox(height: 8),
|
||||
// Container(
|
||||
// height: 200,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.surfaceColor,
|
||||
// borderRadius: BorderRadius.circular(12),
|
||||
// border: Border.all(color: Colors.white10),
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// Icon(Icons.bar_chart,
|
||||
// size: 48,
|
||||
// color: AppTheme.primaryColor.withOpacity(0.5)),
|
||||
// const SizedBox(height: 8),
|
||||
// Text(
|
||||
// 'Coming Soon',
|
||||
// style: Theme.of(context).textTheme.bodyMedium,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CurrentCycleCard extends StatelessWidget {
|
||||
final CycleCollection cycle;
|
||||
final VoidCallback? onFinish;
|
||||
|
||||
const _CurrentCycleCard({required this.cycle, required this.onFinish});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tms = jsonDecode(cycle.trainingMaxesJson) as Map<String, dynamic>;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'CYCLE ${cycle.cycleNumber}',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'ACTIVE',
|
||||
style: TextStyle(
|
||||
color: AppTheme.successColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('Current Training Maxes (TM)',
|
||||
style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 16),
|
||||
_StatRow(label: 'Squat', value: '${tms['squat']} kg'),
|
||||
_StatRow(label: 'Pull-up', value: '${tms['pullup']} kg'),
|
||||
_StatRow(label: 'Dip', value: '${tms['dip']} kg'),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onFinish,
|
||||
icon: const Icon(Icons.upgrade),
|
||||
label: const Text('FINISH CYCLE & LEVEL UP'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _StatRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CycleFinishDialog extends StatelessWidget {
|
||||
final Map<String, dynamic> oldTMs;
|
||||
final Map<String, dynamic> newTMs;
|
||||
final int newCycleNumber;
|
||||
|
||||
const _CycleFinishDialog({
|
||||
required this.oldTMs,
|
||||
required this.newTMs,
|
||||
required this.newCycleNumber,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
// title: Text('Cycle $newCycleNumber Started!'),
|
||||
title: const Text('Dungeon Cleared!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...'), // Story
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Your Training Maxes have increased:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
// const Text(
|
||||
// 'Based on your performance in Week 3, your Training Maxes have been updated:'),
|
||||
const SizedBox(height: 16),
|
||||
_DiffRow(
|
||||
name: 'Squat', oldVal: oldTMs['squat'], newVal: newTMs['squat']),
|
||||
_DiffRow(
|
||||
name: 'Pull-up',
|
||||
oldVal: oldTMs['pullup'],
|
||||
newVal: newTMs['pullup']),
|
||||
_DiffRow(name: 'Dip', oldVal: oldTMs['dip'], newVal: newTMs['dip']),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('ENTER NEXT LEVEL'),
|
||||
// child: const Text('LET\'S GO!'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffRow extends StatelessWidget {
|
||||
final String name;
|
||||
final double oldVal;
|
||||
final double newVal;
|
||||
|
||||
const _DiffRow(
|
||||
{required this.name, required this.oldVal, required this.newVal});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final diff = newVal - oldVal;
|
||||
final isPositive = diff > 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
Text('${oldVal.toStringAsFixed(1)} → ',
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
Text(
|
||||
newVal.toStringAsFixed(1),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isPositive)
|
||||
Text('+${diff.toStringAsFixed(1)}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.successColor, fontWeight: FontWeight.bold))
|
||||
else
|
||||
const Text('STALLED',
|
||||
style: TextStyle(color: AppTheme.secondaryColor, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterChip(
|
||||
{required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
selectedColor: AppTheme.primaryColor.withOpacity(0.2),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
side: BorderSide(
|
||||
color:
|
||||
isSelected ? AppTheme.primaryColor : Colors.grey.withOpacity(0.3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
357
lib/src/features/stats/presentation/widgets/progress_chart.dart
Normal file
357
lib/src/features/stats/presentation/widgets/progress_chart.dart
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
// import 'package:fl_chart/fl_chart.dart';
|
||||
// import 'package:flutter/material.dart';
|
||||
// import 'package:intl/intl.dart';
|
||||
// import '../../../../core/theme/app_theme.dart';
|
||||
// import '../../domain/entities/stats_data_point.dart';
|
||||
|
||||
// class ProgressChart extends StatelessWidget {
|
||||
// final List<StatsDataPoint> data;
|
||||
// final bool isEmpty;
|
||||
|
||||
// const ProgressChart({
|
||||
// super.key,
|
||||
// required this.data,
|
||||
// }) : isEmpty = data.isEmpty;
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// if (isEmpty) {
|
||||
// return Container(
|
||||
// height: 250,
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.surfaceColor,
|
||||
// borderRadius: BorderRadius.circular(16),
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// 'No data available yet',
|
||||
// style: TextStyle(color: AppTheme.textSecondary),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Daten sortieren
|
||||
// final points = List<StatsDataPoint>.from(data)
|
||||
// ..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
// // Min/Max für Y-Achse berechnen (mit etwas Puffer)
|
||||
// double maxY = points.map((e) => e.estimated1RM).reduce((a, b) => a > b ? a : b);
|
||||
// double minY = points.map((e) => e.estimated1RM).reduce((a, b) => a < b ? a : b);
|
||||
|
||||
// // Puffer hinzufügen (z.B. +/- 5kg), damit die Linie nicht am Rand klebt
|
||||
// maxY += 5;
|
||||
// minY = (minY - 5).clamp(0, double.infinity);
|
||||
|
||||
// return Container(
|
||||
// height: 250,
|
||||
// padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
|
||||
// decoration: BoxDecoration(
|
||||
// color: AppTheme.surfaceColor,
|
||||
// borderRadius: BorderRadius.circular(16),
|
||||
// border: Border.all(color: AppTheme.primaryColor.withOpacity(0.1)),
|
||||
// ),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
// children: [
|
||||
// Text(
|
||||
// 'Estimated 1RM Progress',
|
||||
// style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
// color: AppTheme.textSecondary,
|
||||
// ),
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// const SizedBox(height: 24),
|
||||
// Expanded(
|
||||
// child: LineChart(
|
||||
// LineChartData(
|
||||
// gridData: FlGridData(
|
||||
// show: true,
|
||||
// drawVerticalLine: false,
|
||||
// horizontalInterval: 5,
|
||||
// getDrawingHorizontalLine: (value) => FlLine(
|
||||
// color: Colors.white10,
|
||||
// strokeWidth: 1,
|
||||
// ),
|
||||
// ),
|
||||
// titlesData: FlTitlesData(
|
||||
// show: true,
|
||||
// topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
// rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
// bottomTitles: AxisTitles(
|
||||
// sideTitles: SideTitles(
|
||||
// showTitles: true,
|
||||
// reservedSize: 30,
|
||||
// interval: (points.length / 3).ceil().toDouble(), // Zeige nicht jedes Datum
|
||||
// getTitlesWidget: (value, meta) {
|
||||
// final index = value.toInt();
|
||||
// if (index >= 0 && index < points.length) {
|
||||
// return Padding(
|
||||
// padding: const EdgeInsets.only(top: 8.0),
|
||||
// child: Text(
|
||||
// DateFormat.Md().format(points[index].date),
|
||||
// style: const TextStyle(
|
||||
// color: Colors.grey,
|
||||
// fontSize: 10,
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// return const Text('');
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// leftTitles: AxisTitles(
|
||||
// sideTitles: SideTitles(
|
||||
// showTitles: true,
|
||||
// reservedSize: 40,
|
||||
// interval: 5, // Alle 5kg eine Beschriftung
|
||||
// getTitlesWidget: (value, meta) {
|
||||
// return Text(
|
||||
// value.toInt().toString(),
|
||||
// style: const TextStyle(
|
||||
// color: Colors.grey,
|
||||
// fontSize: 10,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// borderData: FlBorderData(show: false),
|
||||
// minX: 0,
|
||||
// maxX: (points.length - 1).toDouble(),
|
||||
// minY: minY,
|
||||
// maxY: maxY,
|
||||
// lineBarsData: [
|
||||
// LineChartBarData(
|
||||
// spots: points.asMap().entries.map((e) {
|
||||
// return FlSpot(e.key.toDouble(), e.value.estimated1RM);
|
||||
// }).toList(),
|
||||
// isCurved: true, // Kurve glätten
|
||||
// color: AppTheme.primaryColor,
|
||||
// barWidth: 3,
|
||||
// isStrokeCapRound: true,
|
||||
// dotData: FlDotData(
|
||||
// show: true,
|
||||
// getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
|
||||
// radius: 4,
|
||||
// color: AppTheme.backgroundColor,
|
||||
// strokeWidth: 2,
|
||||
// strokeColor: AppTheme.primaryColor,
|
||||
// ),
|
||||
// ),
|
||||
// belowBarData: BarAreaData(
|
||||
// show: true,
|
||||
// color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// lineTouchData: LineTouchData(
|
||||
// touchTooltipData: LineTouchTooltipData(
|
||||
// getTooltipColor: (touchedSpot) => AppTheme.surfaceColor,
|
||||
// getTooltipItems: (touchedSpots) {
|
||||
// return touchedSpots.map((spot) {
|
||||
// final date = points[spot.x.toInt()].date;
|
||||
// return LineTooltipItem(
|
||||
// '${spot.y.toStringAsFixed(1)} kg\n${DateFormat.yMMMd().format(date)}',
|
||||
// const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
// );
|
||||
// }).toList();
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../domain/entities/stats_data_point.dart';
|
||||
|
||||
class ProgressChart extends StatelessWidget {
|
||||
final List<StatsDataPoint> data;
|
||||
|
||||
// FIX 1: 'const' Konstruktor erlaubt, da wir keine Berechnung mehr hier machen
|
||||
const ProgressChart({
|
||||
super.key,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
// FIX 1: isEmpty als Getter (wird bei Zugriff berechnet)
|
||||
bool get isEmpty => data.isEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isEmpty) {
|
||||
return Container(
|
||||
height: 250,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No data available yet',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
// Kleine Anpassung für Typ-Sicherheit
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Daten sortieren
|
||||
final points = List<StatsDataPoint>.from(data)
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
// Min/Max für Y-Achse berechnen (mit etwas Puffer)
|
||||
double maxY =
|
||||
points.map((e) => e.estimated1RM).reduce((a, b) => a > b ? a : b);
|
||||
double minY =
|
||||
points.map((e) => e.estimated1RM).reduce((a, b) => a < b ? a : b);
|
||||
|
||||
// Puffer hinzufügen (z.B. +/- 5kg), damit die Linie nicht am Rand klebt
|
||||
maxY += 5;
|
||||
minY = (minY - 5).clamp(0, double.infinity);
|
||||
|
||||
return Container(
|
||||
height: 250,
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Estimated 1RM Progress',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 5,
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: Colors.white10,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: (points.length / 3)
|
||||
.ceil()
|
||||
.toDouble(), // Zeige nicht jedes Datum
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < points.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
DateFormat.Md().format(points[index].date),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 5, // Alle 5kg eine Beschriftung
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
value.toInt().toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: (points.length - 1).toDouble(),
|
||||
minY: minY,
|
||||
maxY: maxY,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: points.asMap().entries.map((e) {
|
||||
return FlSpot(e.key.toDouble(), e.value.estimated1RM);
|
||||
}).toList(),
|
||||
isCurved: true, // Kurve glätten
|
||||
color: AppTheme.primaryColor,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) =>
|
||||
FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: AppTheme.backgroundColor,
|
||||
strokeWidth: 2,
|
||||
strokeColor: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
// FIX 2: Alte API nutzen (tooltipBgColor statt getTooltipColor)
|
||||
tooltipBgColor: AppTheme.surfaceColor,
|
||||
getTooltipItems: (touchedSpots) {
|
||||
return touchedSpots.map((spot) {
|
||||
final date = points[spot.x.toInt()].date;
|
||||
return LineTooltipItem(
|
||||
'${spot.y.toStringAsFixed(1)} kg\n${DateFormat.yMMMd().format(date)}',
|
||||
const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,994 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../shared/domain/entities/exercise.dart';
|
||||
import '../../../../shared/domain/entities/workout_set.dart';
|
||||
import '../../../../shared/domain/logic/wendler_calculator.dart';
|
||||
import '../../../../shared/domain/logic/plate_calculator.dart';
|
||||
import '../../../../shared/domain/logic/xp_calculator.dart';
|
||||
import '../../../../shared/data/repositories/user_repository.dart';
|
||||
import '../../../../shared/data/repositories/cycle_repository.dart';
|
||||
import '../../../../shared/data/repositories/workout_repository.dart';
|
||||
import '../../../../shared/data/remote/sync_service.dart';
|
||||
import '../widgets/plate_visualizer.dart';
|
||||
import '../widgets/timer_widget.dart';
|
||||
import '../widgets/enemy_hp_bar.dart';
|
||||
|
||||
class BattleScreen extends ConsumerStatefulWidget {
|
||||
final int week;
|
||||
final int day;
|
||||
final int? workoutId;
|
||||
|
||||
const BattleScreen({
|
||||
super.key,
|
||||
required this.week,
|
||||
required this.day,
|
||||
this.workoutId,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<BattleScreen> createState() => _BattleScreenState();
|
||||
}
|
||||
|
||||
class _BattleScreenState extends ConsumerState<BattleScreen> {
|
||||
List<Exercise> _exercises = [];
|
||||
int _currentExerciseIndex = 0;
|
||||
int _currentSetIndex = 0;
|
||||
int _repsCompleted = 0;
|
||||
bool _isLoading = true;
|
||||
Timer? _restTimer;
|
||||
int _restSeconds = 0;
|
||||
bool _isResting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWorkout();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getEnemyAsset(String exerciseId) {
|
||||
// Mapping basierend auf Übungs-ID
|
||||
switch (exerciseId) {
|
||||
case 'squat':
|
||||
return AssetPaths.enemyIronGolem;
|
||||
case 'pullup':
|
||||
return AssetPaths.enemyGravityDemon;
|
||||
case 'dip':
|
||||
return AssetPaths.enemyPressurePhantom;
|
||||
default:
|
||||
return AssetPaths.enemyIronGolem; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _getExerciseConfig(int day) {
|
||||
switch (day) {
|
||||
case 1:
|
||||
return [
|
||||
{
|
||||
'id': 'squat',
|
||||
'name': 'Back Squat',
|
||||
'type': ExerciseType.squat,
|
||||
'isMain': true
|
||||
},
|
||||
{
|
||||
'id': 'pullup',
|
||||
'name': 'Weighted Pull-up',
|
||||
'type': ExerciseType.pullup,
|
||||
'isMain': false
|
||||
},
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
{
|
||||
'id': 'dip',
|
||||
'name': 'Weighted Dip',
|
||||
'type': ExerciseType.dip,
|
||||
'isMain': true
|
||||
},
|
||||
{
|
||||
'id': 'squat',
|
||||
'name': 'Back Squat',
|
||||
'type': ExerciseType.squat,
|
||||
'isMain': false
|
||||
},
|
||||
];
|
||||
case 3:
|
||||
return [
|
||||
{
|
||||
'id': 'pullup',
|
||||
'name': 'Weighted Pull-up',
|
||||
'type': ExerciseType.pullup,
|
||||
'isMain': true
|
||||
},
|
||||
{
|
||||
'id': 'dip',
|
||||
'name': 'Weighted Dip',
|
||||
'type': ExerciseType.dip,
|
||||
'isMain': false
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadWorkout() async {
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
|
||||
final user = await userRepo.getLocalUser();
|
||||
final cycle = await cycleRepo.getCurrentCycle();
|
||||
|
||||
if (user == null || cycle == null) {
|
||||
if (mounted) context.go('/hub');
|
||||
return;
|
||||
}
|
||||
|
||||
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
|
||||
final exercises = <Exercise>[];
|
||||
|
||||
final exerciseConfigs = _getExerciseConfig(widget.day);
|
||||
|
||||
for (final config in exerciseConfigs) {
|
||||
final id = config['id'] as String;
|
||||
final name = config['name'] as String;
|
||||
final type = config['type'] as ExerciseType;
|
||||
final isMain = config['isMain'] as bool;
|
||||
|
||||
final tm = trainingMaxes[id] ?? 0.0;
|
||||
List<WorkoutSet> sets = [];
|
||||
|
||||
if (isMain) {
|
||||
sets = WendlerCalculator.generateSets(
|
||||
week: widget.week,
|
||||
trainingMax: tm,
|
||||
exerciseType: type,
|
||||
currentBodyweight: user.currentBodyweight,
|
||||
);
|
||||
} else {
|
||||
if (widget.week != 4) {
|
||||
sets = WendlerCalculator.generateFSLSets(
|
||||
trainingMax: tm,
|
||||
exerciseType: type,
|
||||
currentBodyweight: user.currentBodyweight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.isNotEmpty) {
|
||||
exercises.add(Exercise(
|
||||
exerciseId: id,
|
||||
exerciseName: isMain ? name : '$name (FSL)',
|
||||
bodyweightAtSession: user.currentBodyweight,
|
||||
sets: sets,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_exercises = exercises;
|
||||
_isLoading = false;
|
||||
|
||||
if (exercises.isNotEmpty && exercises.first.sets.isNotEmpty) {
|
||||
_repsCompleted = exercises.first.sets.first.repsTarget;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _completeSet() {
|
||||
final currentExercise = _exercises[_currentExerciseIndex];
|
||||
final currentSet = currentExercise.sets[_currentSetIndex];
|
||||
|
||||
final updatedSet = currentSet.copyWith(
|
||||
repsActual: _repsCompleted,
|
||||
completed: true,
|
||||
);
|
||||
|
||||
final updatedSets = List<WorkoutSet>.from(currentExercise.sets);
|
||||
updatedSets[_currentSetIndex] = updatedSet;
|
||||
|
||||
final updatedExercise = currentExercise.copyWith(sets: updatedSets);
|
||||
final updatedExercises = List<Exercise>.from(_exercises);
|
||||
updatedExercises[_currentExerciseIndex] = updatedExercise;
|
||||
|
||||
int nextRepsTarget = 0;
|
||||
|
||||
if (_currentSetIndex < currentExercise.sets.length - 1) {
|
||||
nextRepsTarget = currentExercise.sets[_currentSetIndex + 1].repsTarget;
|
||||
|
||||
setState(() {
|
||||
_exercises = updatedExercises;
|
||||
_currentSetIndex++;
|
||||
_repsCompleted = nextRepsTarget;
|
||||
});
|
||||
_startRestTimer(90);
|
||||
} else if (_currentExerciseIndex < _exercises.length - 1) {
|
||||
final nextExercise = _exercises[_currentExerciseIndex + 1];
|
||||
if (nextExercise.sets.isNotEmpty) {
|
||||
nextRepsTarget = nextExercise.sets.first.repsTarget;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_exercises = updatedExercises;
|
||||
_currentExerciseIndex++;
|
||||
_currentSetIndex = 0;
|
||||
_repsCompleted = nextRepsTarget;
|
||||
});
|
||||
_startRestTimer(180);
|
||||
} else {
|
||||
setState(() {
|
||||
_exercises = updatedExercises;
|
||||
});
|
||||
_completeWorkout();
|
||||
}
|
||||
}
|
||||
|
||||
void _startRestTimer(int seconds) {
|
||||
setState(() {
|
||||
_isResting = true;
|
||||
_restSeconds = seconds;
|
||||
});
|
||||
|
||||
_restTimer?.cancel();
|
||||
_restTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_restSeconds > 0) {
|
||||
setState(() => _restSeconds--);
|
||||
} else {
|
||||
timer.cancel();
|
||||
setState(() => _isResting = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _skipRest() {
|
||||
_restTimer?.cancel();
|
||||
setState(() {
|
||||
_isResting = false;
|
||||
_restSeconds = 0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _completeWorkout() async {
|
||||
final xpEarned = XPCalculator.calculateWorkoutXP(_exercises);
|
||||
|
||||
final userRepo = ref.read(userRepositoryProvider);
|
||||
await userRepo.updateXP(xpEarned);
|
||||
|
||||
final user = await userRepo.getLocalUser();
|
||||
if (user != null) {
|
||||
final newLevel = XPCalculator.calculateLevelFromXP(user.xp);
|
||||
if (newLevel > user.level) {
|
||||
await userRepo.updateLevel(newLevel);
|
||||
if (mounted) {
|
||||
_showLevelUpDialog(user.level, newLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.workoutId != null) {
|
||||
final workoutRepo = ref.read(workoutRepositoryProvider);
|
||||
final cycleRepo = ref.read(cycleRepositoryProvider);
|
||||
final cycle = await cycleRepo.getCurrentCycle();
|
||||
|
||||
final cycleIdRef = cycle?.serverId ?? cycle?.id.toString() ?? '';
|
||||
|
||||
var workout = await workoutRepo.getWorkoutByWeekDay(
|
||||
cycleId: cycleIdRef, week: widget.week, day: widget.day);
|
||||
|
||||
if (workout != null) {
|
||||
workout.exercisesJson =
|
||||
jsonEncode(_exercises.map((e) => e.toJson()).toList());
|
||||
await workoutRepo.completeWorkout(workout, xpEarned: xpEarned);
|
||||
|
||||
ref.read(syncServiceProvider).sync();
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('RAID COMPLETE!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.emoji_events,
|
||||
size: 64,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'+$xpEarned XP',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.go('/hub');
|
||||
},
|
||||
child: const Text('BACK TO HUB'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showLevelUpDialog(int oldLevel, int newLevel) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
title: const Text(
|
||||
'LEVEL UP!',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.military_tech, size: 80, color: Colors.black),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'You have grown stronger!',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The monsters tremble at your new power.', // Story Flavor
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black54, fontStyle: FontStyle.italic),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
'$oldLevel → $newLevel',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child:
|
||||
const Text('CONTINUE', style: TextStyle(color: Colors.black)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
if (_exercises.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Battle')),
|
||||
body: const Center(child: Text('No exercises configured')),
|
||||
);
|
||||
}
|
||||
|
||||
final currentExercise = _exercises[_currentExerciseIndex];
|
||||
final currentSet = currentExercise.sets[_currentSetIndex];
|
||||
final userRepo = ref.watch(userRepositoryProvider);
|
||||
|
||||
final totalHP = _exercises.fold<int>(
|
||||
0,
|
||||
(sum, ex) => sum + ex.sets.fold<int>(0, (s, set) => s + set.repsTarget),
|
||||
);
|
||||
|
||||
final completedHP = _exercises.take(_currentExerciseIndex).fold<int>(
|
||||
0,
|
||||
(sum, ex) =>
|
||||
sum + ex.sets.fold<int>(0, (s, set) => s + set.repsActual),
|
||||
) +
|
||||
currentExercise.sets
|
||||
.take(_currentSetIndex)
|
||||
.fold<int>(0, (sum, set) => sum + set.repsActual);
|
||||
|
||||
final isBodyweight = currentExercise.exerciseId != 'squat';
|
||||
final barWeight = isBodyweight
|
||||
? currentExercise.bodyweightAtSession
|
||||
: userRepo.getBarWeight();
|
||||
final availablePlates = userRepo.getAvailablePlates();
|
||||
final inventory = userRepo.getInventorySettings();
|
||||
final bandsList =
|
||||
(inventory['bands'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
final Map<String, double> availableBands = {};
|
||||
for (var band in bandsList) {
|
||||
final color = band['color'] as String;
|
||||
final resistance = (band['resistance_kg'] as num).toDouble();
|
||||
if (band['count'] as int > 0) {
|
||||
availableBands[color] = resistance;
|
||||
}
|
||||
}
|
||||
|
||||
final plateResult = PlateCalculator.calculate(
|
||||
targetWeight: currentSet.targetWeightTotal,
|
||||
barWeight: barWeight,
|
||||
availablePlates: availablePlates,
|
||||
availableBands: availableBands,
|
||||
isTwoSided: !isBodyweight,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Week ${widget.week} - Day ${widget.day}'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Abandon Raid?'),
|
||||
content: const Text('Your progress will not be saved.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.go('/hub');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppTheme.errorColor),
|
||||
child: const Text('ABANDON'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// 1. HINTERGRUND (Underground Gym)
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
AssetPaths.bgUndergroundGym,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Overlay (Atmosphäre & Lesbarkeit)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.7), // Dunkler Schleier
|
||||
),
|
||||
),
|
||||
|
||||
// 3. INHALT
|
||||
SafeArea(
|
||||
child: _isResting
|
||||
? _buildRestScreen() // Rest Screen überdeckt das Gym (oder man macht ihn auch transparent)
|
||||
: _buildWorkoutScreen(currentExercise, currentSet, plateResult,
|
||||
completedHP, totalHP),
|
||||
),
|
||||
],
|
||||
),
|
||||
// body: _isResting
|
||||
// ? _buildRestScreen()
|
||||
// : _buildWorkoutScreen(
|
||||
// currentExercise, currentSet, plateResult, completedHP, totalHP),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRestScreen() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppTheme.backgroundColor,
|
||||
AppTheme.surfaceColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'REST',
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CircularProgressIndicator(
|
||||
value: _restSeconds / 180,
|
||||
strokeWidth: 12,
|
||||
backgroundColor: AppTheme.xpBarBackground,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(_restSeconds),
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
fontSize: 48,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton(
|
||||
onPressed: _skipRest,
|
||||
child: const Text('SKIP REST'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkoutScreen(
|
||||
Exercise currentExercise,
|
||||
WorkoutSet currentSet,
|
||||
PlateLoadResult plateResult,
|
||||
int completedHP,
|
||||
int totalHP,
|
||||
) {
|
||||
// Styles
|
||||
final readableStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
const Shadow(color: Colors.black, blurRadius: 4, offset: Offset(0, 1))
|
||||
],
|
||||
);
|
||||
|
||||
final titleStyle = Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
const Shadow(color: Colors.black, blurRadius: 8, offset: Offset(0, 2))
|
||||
],
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// --- 1. GEGNER BEREICH (Immersive) ---
|
||||
// Wir nutzen Flexible, damit der Gegner Platz einnimmt, aber schrumpft, wenn nötig
|
||||
Flexible(
|
||||
flex: 4, // Verhältnis zum unteren Teil
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Gegner Bild (Groß & Frei)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40), // Platz für HP Bar
|
||||
child: Image.asset(
|
||||
_getEnemyAsset(currentExercise.exerciseId),
|
||||
fit: BoxFit.contain,
|
||||
// Ein Glow-Effekt hinter dem Gegner für bessere Abhebung vom Hintergrund
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
colorBlendMode: BlendMode.modulate,
|
||||
),
|
||||
),
|
||||
|
||||
// Wave Badge (Oben Rechts, dezent)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Text(
|
||||
'WAVE ${_currentExerciseIndex + 1} / ${_exercises.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// HP Bar (Direkt unter dem Gegner, Schwebend)
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 32,
|
||||
right: 32,
|
||||
child: Column(
|
||||
children: [
|
||||
// Kleines Herz Icon
|
||||
const Icon(Icons.favorite,
|
||||
color: AppTheme.errorColor, size: 24),
|
||||
const SizedBox(height: 4),
|
||||
EnemyHPBar(
|
||||
current: totalHP - completedHP,
|
||||
max: totalHP,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- 2. KONTROLL BEREICH (Scrollable) ---
|
||||
// Dieser Teil enthält die Trainings-Infos und den Counter
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor
|
||||
.withOpacity(0.95), // Fast undurchsichtig für Lesbarkeit
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -5))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24, 24, 24, 100), // Unten Platz für Button
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
currentExercise.exerciseName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
'Set ${_currentSetIndex + 1} of ${currentExercise.sets.length}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.white70),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Target Info (Kompakt)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_InfoBox(
|
||||
label: 'WEIGHT',
|
||||
value: '${currentSet.targetWeightTotal} kg'),
|
||||
_InfoBox(
|
||||
label: 'REPS',
|
||||
value:
|
||||
'${currentSet.repsTarget}${currentSet.isAmrap ? "+" : ""}'),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Load / Assistance Visualizer
|
||||
if (plateResult.bandAssistance != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.primaryColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.help,
|
||||
color: AppTheme.primaryColor, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('ASSISTANCE',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold)),
|
||||
Text(plateResult.bandAssistance!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
PlateVisualizer(
|
||||
plateConfiguration: plateResult.plateConfiguration,
|
||||
isTwoSided: currentExercise.exerciseId == 'squat',
|
||||
exerciseName: currentExercise.exerciseName,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// // Counter (Groß)
|
||||
// Text('REPS COMPLETED',
|
||||
// style: TextStyle(
|
||||
// color: Colors.grey,
|
||||
// fontSize: 12,
|
||||
// letterSpacing: 1.5,
|
||||
// fontWeight: FontWeight.bold)),
|
||||
// const SizedBox(height: 8),
|
||||
// Row(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// _CounterButton(
|
||||
// icon: Icons.remove,
|
||||
// onTap: _repsCompleted > 0
|
||||
// ? () => setState(() => _repsCompleted--)
|
||||
// : null),
|
||||
// Container(
|
||||
// width: 100,
|
||||
// alignment: Alignment.center,
|
||||
// child: Text(
|
||||
// '$_repsCompleted',
|
||||
// style: const TextStyle(
|
||||
// fontSize: 64,
|
||||
// fontWeight: FontWeight.bold,
|
||||
// color: Colors.white),
|
||||
// ),
|
||||
// ),
|
||||
// _CounterButton(
|
||||
// icon: Icons.add,
|
||||
// onTap: () => setState(() => _repsCompleted++)),
|
||||
// ],
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- 3. FIXIERTER BUTTON ---
|
||||
Container(
|
||||
color:
|
||||
AppTheme.surfaceColor, // Gleiche Farbe wie der Kontroll-Container
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
// onPressed: _repsCompleted >= currentSet.repsTarget
|
||||
// ? _completeSet
|
||||
// : null,
|
||||
onPressed: () => _handleCompletePress(currentSet),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('COMPLETE SET',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCompletePress(WorkoutSet currentSet) {
|
||||
if (currentSet.isAmrap) {
|
||||
_showAmrapDialog(currentSet);
|
||||
} else {
|
||||
// Standard-Satz: Wir gehen davon aus, dass das Ziel erreicht wurde
|
||||
setState(() {
|
||||
_repsCompleted = currentSet.repsTarget;
|
||||
});
|
||||
_completeSet();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _showAmrapDialog(WorkoutSet set) {
|
||||
// Startwert ist das Ziel (oder was bisher eingestellt war)
|
||||
int tempReps = set.repsTarget;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.surfaceColor,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (context) {
|
||||
return StatefulBuilder(// Wichtig für State im Dialog
|
||||
builder: (context, setModalState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'🔥 AMRAP RESULT 🔥',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Go all out! How many did you get?',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Großer Counter
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_CounterButton(
|
||||
icon: Icons.remove,
|
||||
onTap: tempReps > 0
|
||||
? () => setModalState(() => tempReps--)
|
||||
: null),
|
||||
Container(
|
||||
width: 120,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$tempReps',
|
||||
style: const TextStyle(
|
||||
fontSize: 72,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
),
|
||||
),
|
||||
_CounterButton(
|
||||
icon: Icons.add,
|
||||
onTap: () => setModalState(() => tempReps++)),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Dialog schließen
|
||||
// Wert übernehmen und Satz beenden
|
||||
setState(() {
|
||||
_repsCompleted = tempReps;
|
||||
});
|
||||
_completeSet();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('CONFIRM RESULT',
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16), // Puffer für iOS Home Bar
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoBox extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _InfoBox({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Der Counter Button Helper (kannst du so lassen oder anpassen)
|
||||
class _CounterButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
const _CounterButton({required this.icon, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: onTap != null
|
||||
? AppTheme.primaryColor
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon,
|
||||
size: 32, color: onTap != null ? Colors.black : Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class EnemyHPBar extends StatelessWidget {
|
||||
final int current;
|
||||
final int max;
|
||||
|
||||
const EnemyHPBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.max,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentage = max > 0 ? current / max : 0.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.favorite,
|
||||
color: AppTheme.errorColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'HP',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'$current / $max',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppTheme.errorColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Stack(
|
||||
children: [
|
||||
// Background
|
||||
Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.errorColor.withOpacity(0.5),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// HP Fill
|
||||
FractionallySizedBox(
|
||||
widthFactor: percentage.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.errorColor,
|
||||
Colors.red[300]!,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.errorColor.withOpacity(0.5),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/constants/asset_paths.dart';
|
||||
|
||||
class PlateVisualizer extends StatelessWidget {
|
||||
final List<double> plateConfiguration;
|
||||
final bool isTwoSided;
|
||||
final String exerciseName;
|
||||
|
||||
const PlateVisualizer({
|
||||
super.key,
|
||||
required this.plateConfiguration,
|
||||
required this.isTwoSided,
|
||||
required this.exerciseName,
|
||||
});
|
||||
|
||||
Color _getPlateColor(double weight) {
|
||||
final colorValue = PlateColors.colors[weight];
|
||||
return colorValue != null ? Color(colorValue) : Colors.grey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (plateConfiguration.isEmpty) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
isTwoSided ? Icons.fitness_center : Icons.accessibility,
|
||||
size: 64,
|
||||
color: AppTheme.primaryColor.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isTwoSided ? 'Bar Only' : 'Bodyweight Only',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
isTwoSided ? 'Load Per Side' : 'Load on Belt',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isTwoSided) _buildBarbellView() else _buildBeltView(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Total: ${plateConfiguration.fold<double>(0, (sum, p) => sum + p).toStringAsFixed(1)} kg ${isTwoSided ? 'per side' : ''}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBarbellView() {
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left collar
|
||||
Container(
|
||||
width: 8,
|
||||
height: 80,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
|
||||
// Plates (from largest to smallest)
|
||||
...plateConfiguration.map((weight) {
|
||||
final size = _getPlateSize(weight);
|
||||
return Container(
|
||||
width: 20,
|
||||
height: size,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getPlateColor(weight),
|
||||
border: Border.all(color: Colors.white24, width: 2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// Sleeve (bar end)
|
||||
Container(
|
||||
width: 40,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[600],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBeltView() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: plateConfiguration.map((weight) {
|
||||
return Container(
|
||||
width: _getPlateSize(weight) * 0.8,
|
||||
height: _getPlateSize(weight) * 0.8,
|
||||
decoration: BoxDecoration(
|
||||
color: _getPlateColor(weight),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white24, width: 3),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
weight == weight.toInt()
|
||||
? '${weight.toInt()}'
|
||||
: weight.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
double _getPlateSize(double weight) {
|
||||
// Scale plate size based on weight
|
||||
if (weight >= 20) return 120.0;
|
||||
if (weight >= 10) return 100.0;
|
||||
if (weight >= 5) return 80.0;
|
||||
return 60.0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
|
||||
class TimerWidget extends StatefulWidget {
|
||||
const TimerWidget({super.key});
|
||||
|
||||
@override
|
||||
State<TimerWidget> createState() => _TimerWidgetState();
|
||||
}
|
||||
|
||||
class _TimerWidgetState extends State<TimerWidget> {
|
||||
int _seconds = 0;
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _start() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
setState(() => _seconds++);
|
||||
});
|
||||
setState(() => _isRunning = true);
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer?.cancel();
|
||||
setState(() => _isRunning = false);
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_seconds = 0;
|
||||
_isRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime() {
|
||||
final minutes = _seconds ~/ 60;
|
||||
final secs = _seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.primaryColor,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: _isRunning ? _pause : _start,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _reset,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
27
lib/src/shared/data/local/collections/cycle_collection.dart
Normal file
27
lib/src/shared/data/local/collections/cycle_collection.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'cycle_collection.g.dart';
|
||||
|
||||
@collection
|
||||
class CycleCollection {
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true)
|
||||
String? serverId;
|
||||
|
||||
String userId = ''; // Local reference
|
||||
int cycleNumber = 1;
|
||||
|
||||
DateTime startDate = DateTime.now();
|
||||
DateTime? endDate;
|
||||
|
||||
bool isActive = true;
|
||||
|
||||
// Training Maxes (stored as JSON string)
|
||||
String trainingMaxesJson = '{}';
|
||||
|
||||
bool isDirty = false;
|
||||
DateTime createdAt = DateTime.now();
|
||||
DateTime updatedAt = DateTime.now();
|
||||
}
|
||||
|
||||
1649
lib/src/shared/data/local/collections/cycle_collection.g.dart
Normal file
1649
lib/src/shared/data/local/collections/cycle_collection.g.dart
Normal file
File diff suppressed because it is too large
Load diff
26
lib/src/shared/data/local/collections/user_collection.dart
Normal file
26
lib/src/shared/data/local/collections/user_collection.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'user_collection.g.dart';
|
||||
|
||||
@collection
|
||||
class UserCollection {
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: true)
|
||||
String? serverId; // PocketBase ID
|
||||
|
||||
String email = '';
|
||||
int xp = 0;
|
||||
int level = 1;
|
||||
double currentBodyweight = 70.0;
|
||||
|
||||
String? inventorySettingsJson; // JSON string
|
||||
String? avatarConfigJson; // JSON string
|
||||
|
||||
DateTime? lastSyncAt;
|
||||
bool isDirty = false; // Needs sync
|
||||
|
||||
DateTime createdAt = DateTime.now();
|
||||
DateTime updatedAt = DateTime.now();
|
||||
}
|
||||
|
||||
1921
lib/src/shared/data/local/collections/user_collection.g.dart
Normal file
1921
lib/src/shared/data/local/collections/user_collection.g.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'workout_collection.g.dart';
|
||||
|
||||
@collection
|
||||
class WorkoutCollection {
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
// @Index(unique: true)
|
||||
@Index()
|
||||
String? serverId;
|
||||
|
||||
String userId = '';
|
||||
String cycleId = '';
|
||||
|
||||
int week = 1; // 1-4
|
||||
int day = 1; // 1-3
|
||||
|
||||
DateTime? scheduledDate;
|
||||
DateTime? completedAt;
|
||||
|
||||
int xpEarned = 0;
|
||||
|
||||
// Exercises data (JSON string)
|
||||
String exercisesJson = '[]';
|
||||
|
||||
String notes = '';
|
||||
|
||||
bool isDirty = false;
|
||||
DateTime createdAt = DateTime.now();
|
||||
DateTime updatedAt = DateTime.now();
|
||||
}
|
||||
2145
lib/src/shared/data/local/collections/workout_collection.g.dart
Normal file
2145
lib/src/shared/data/local/collections/workout_collection.g.dart
Normal file
File diff suppressed because it is too large
Load diff
275
lib/src/shared/data/remote/api_client.dart
Normal file
275
lib/src/shared/data/remote/api_client.dart
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
final FlutterSecureStorage _storage;
|
||||
final Logger _logger;
|
||||
|
||||
ApiClient({
|
||||
FlutterSecureStorage? storage,
|
||||
Logger? logger,
|
||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||
_logger = logger ?? Logger() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConstants.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Add interceptors
|
||||
_dio.interceptors.add(
|
||||
PrettyDioLogger(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
|
||||
// Auth token interceptor
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: AppConstants.keyAuthToken);
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
_logger.w('Unauthorized - clearing token');
|
||||
await _storage.delete(key: AppConstants.keyAuthToken);
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
Future<Map<String, dynamic>> login(String email, String password) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.login,
|
||||
data: {
|
||||
'identity': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
final token = response.data['token'];
|
||||
if (token != null) {
|
||||
await _storage.write(key: AppConstants.keyAuthToken, value: token);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Login failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required double bodyweight,
|
||||
required Map<String, dynamic> inventorySettings,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.register,
|
||||
data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
'xp': 0,
|
||||
'level': 1,
|
||||
'current_bodyweight': bodyweight,
|
||||
'inventory_settings': inventorySettings,
|
||||
'avatar_config': {
|
||||
'skin_tone': 'medium',
|
||||
'hair_style': 'short_01',
|
||||
'clothing': 'basic_tee',
|
||||
'unlocked_items': ['basic_tee'],
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Registration failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.delete(key: AppConstants.keyAuthToken);
|
||||
await _storage.delete(key: AppConstants.keyUserId);
|
||||
}
|
||||
|
||||
// Sync
|
||||
Future<Map<String, dynamic>> sync({
|
||||
required String lastSyncTimestamp,
|
||||
required Map<String, dynamic> pushData,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.sync,
|
||||
data: {
|
||||
'last_sync_timestamp': lastSyncTimestamp,
|
||||
'push_data': pushData,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Sync failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle Management
|
||||
Future<Map<String, dynamic>> createCycle(
|
||||
Map<String, double> trainingMaxes) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.cycleCreate,
|
||||
data: {'training_maxes': trainingMaxes},
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Create cycle failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> finishCycle(String cycleId) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.cycleFinish,
|
||||
data: {'cycle_id': cycleId},
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Finish cycle failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCurrentCycle() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiEndpoints.cycleCurrent);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Get current cycle failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
Future<Map<String, dynamic>> getStatsHistory({
|
||||
required String exercise,
|
||||
required String range,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiEndpoints.statsHistory,
|
||||
queryParameters: {
|
||||
'exercise': exercise,
|
||||
'range': range,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Get stats history failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getStatsSummary() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiEndpoints.statsSummary);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
_logger.e('Get stats summary failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile
|
||||
Future<void> updateBodyweight(double bodyweight) async {
|
||||
try {
|
||||
await _dio.patch(
|
||||
ApiEndpoints.profileBodyweight,
|
||||
data: {'bodyweight': bodyweight},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Update bodyweight failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
||||
try {
|
||||
await _dio.patch(
|
||||
ApiEndpoints.profileInventory,
|
||||
data: inventory,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Update inventory failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updatePassword({
|
||||
required String userId,
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
required String newPasswordConfirm,
|
||||
}) async {
|
||||
try {
|
||||
// PocketBase erwartet oldPassword, password, passwordConfirm
|
||||
await _dio.patch(
|
||||
'${ApiEndpoints.userUpdate}/$userId',
|
||||
data: {
|
||||
'oldPassword': oldPassword,
|
||||
'password': newPassword,
|
||||
'passwordConfirm': newPasswordConfirm,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Update password failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String userId) async {
|
||||
try {
|
||||
await _dio.delete('${ApiEndpoints.userDelete}/$userId');
|
||||
} catch (e) {
|
||||
_logger.e('Delete account failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetProgress() async {
|
||||
try {
|
||||
await _dio.post(ApiEndpoints.profileReset);
|
||||
} catch (e) {
|
||||
_logger.e('Reset progress failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/src/shared/data/remote/sync_service.dart
Normal file
227
lib/src/shared/data/remote/sync_service.dart
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../../../main.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../local/collections/user_collection.dart';
|
||||
import '../local/collections/cycle_collection.dart';
|
||||
import '../local/collections/workout_collection.dart';
|
||||
import 'api_client.dart';
|
||||
import '../repositories/user_repository.dart';
|
||||
|
||||
final syncServiceProvider = Provider<SyncService>((ref) {
|
||||
final isar = ref.watch(isarProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return SyncService(isar: isar, apiClient: apiClient);
|
||||
});
|
||||
|
||||
class SyncService {
|
||||
final Isar isar;
|
||||
final ApiClient apiClient;
|
||||
final _storage = const FlutterSecureStorage();
|
||||
bool _isSyncing = false;
|
||||
|
||||
SyncService({required this.isar, required this.apiClient});
|
||||
|
||||
Future<void> sync() async {
|
||||
if (_isSyncing) return;
|
||||
_isSyncing = true;
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Starting Sync...');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STEP 1: Sync Cycles First (Parents of Workouts)
|
||||
// ---------------------------------------------------------
|
||||
final dirtyCycles =
|
||||
await isar.cycleCollections.filter().isDirtyEqualTo(true).findAll();
|
||||
|
||||
for (var cycle in dirtyCycles) {
|
||||
try {
|
||||
if (cycle.serverId == null) {
|
||||
// Create new cycle on server
|
||||
debugPrint(
|
||||
'📤 Pushing new cycle ${cycle.cycleNumber} to server...');
|
||||
|
||||
// Parse TMs safely
|
||||
Map<String, double> tmsMap = {};
|
||||
try {
|
||||
final tms = jsonDecode(cycle.trainingMaxesJson);
|
||||
tmsMap = Map<String, double>.from(
|
||||
tms.map((k, v) => MapEntry(k, (v as num).toDouble())));
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Error parsing TMs for cycle ${cycle.id}: $e');
|
||||
// Default fallback if parsing fails
|
||||
tmsMap = {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
|
||||
}
|
||||
|
||||
final response = await apiClient.createCycle(tmsMap);
|
||||
final newServerId = response['id'];
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// Update cycle with server ID
|
||||
cycle.serverId = newServerId;
|
||||
cycle.isDirty = false;
|
||||
await isar.cycleCollections.put(cycle);
|
||||
|
||||
// CRITICAL: Update all workouts that linked to the local ID of this cycle
|
||||
// Since we stored 'local ID string' in cycleId for offline workouts
|
||||
final oldLocalIdRef = cycle.id.toString();
|
||||
|
||||
final orphanWorkouts = await isar.workoutCollections
|
||||
.filter()
|
||||
.cycleIdEqualTo(oldLocalIdRef)
|
||||
.findAll();
|
||||
|
||||
for (var w in orphanWorkouts) {
|
||||
w.cycleId = newServerId; // Update link to valid server ID
|
||||
w.isDirty = true; // Ensure it gets picked up in next step
|
||||
await isar.workoutCollections.put(w);
|
||||
debugPrint('🔗 Relinked workout ${w.id} to cycle $newServerId');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Cycle already has server ID but marked dirty -> Update on server if needed
|
||||
// For MVP we assume cycles are immutable except for status, skipping update logic to avoid complexity
|
||||
await isar.writeTxn(() async {
|
||||
cycle.isDirty = false;
|
||||
await isar.cycleCollections.put(cycle);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to sync cycle: $e');
|
||||
// We stop here because workouts depend on cycles.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STEP 2: Sync Workouts & User Stats
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// 1. Gather local changes
|
||||
final dirtyUser =
|
||||
await isar.userCollections.filter().isDirtyEqualTo(true).findFirst();
|
||||
|
||||
final dirtyWorkouts =
|
||||
await isar.workoutCollections.filter().isDirtyEqualTo(true).findAll();
|
||||
|
||||
if (dirtyUser == null && dirtyWorkouts.isEmpty) {
|
||||
debugPrint('✅ Nothing to push.');
|
||||
} else {
|
||||
// 2. Prepare Push Data
|
||||
final pushData = <String, dynamic>{
|
||||
'workouts': dirtyWorkouts.where((w) {
|
||||
// Filter out workouts that still don't have a valid cycle Server ID (e.g. if cycle sync failed)
|
||||
// A valid PocketBase ID is 15 chars. A local ID is usually "1", "2".
|
||||
// This is a heuristic check.
|
||||
return w.cycleId.length > 5;
|
||||
}).map((w) {
|
||||
return {
|
||||
'id': w.serverId,
|
||||
'local_id': w.id,
|
||||
'cycle_id': w.cycleId, // Must be a Server ID
|
||||
'week': w.week,
|
||||
'day': w.day,
|
||||
'completed_at': w.completedAt?.toIso8601String(),
|
||||
'xp_earned': w.xpEarned,
|
||||
'notes': w.notes,
|
||||
'exercises': jsonDecode(w.exercisesJson),
|
||||
};
|
||||
}).toList(),
|
||||
'user_stats': dirtyUser != null
|
||||
? {
|
||||
'xp': dirtyUser.xp,
|
||||
'level': dirtyUser.level,
|
||||
'current_bodyweight': dirtyUser.currentBodyweight,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
// If we filtered out workouts, log it
|
||||
if ((pushData['workouts'] as List).length < dirtyWorkouts.length) {
|
||||
debugPrint(
|
||||
'⚠️ Skipped some workouts because they lack a valid server cycle ID.');
|
||||
}
|
||||
|
||||
// 3. Get Last Sync Timestamp
|
||||
final lastSync = await _storage.read(key: AppConstants.keyLastSync);
|
||||
|
||||
// 4. Call API
|
||||
if ((pushData['workouts'] as List).isNotEmpty ||
|
||||
pushData['user_stats'] != null) {
|
||||
debugPrint('📤 Pushing data...');
|
||||
final response = await apiClient.sync(
|
||||
lastSyncTimestamp: lastSync ?? '',
|
||||
pushData: pushData,
|
||||
);
|
||||
|
||||
// 5. Process Response
|
||||
await isar.writeTxn(() async {
|
||||
// Update User
|
||||
if (dirtyUser != null) {
|
||||
dirtyUser.isDirty = false;
|
||||
await isar.userCollections.put(dirtyUser);
|
||||
}
|
||||
|
||||
// Update pushed workouts (Clear dirty flags)
|
||||
for (var w in dirtyWorkouts) {
|
||||
// We assume success if no error thrown
|
||||
// Ideally we match IDs from response, but for MVP optimistically clearing is okay
|
||||
// providing we don't overwrite serverId if it was null.
|
||||
// The server usually returns the new/updated records in 'pull_data' anyway.
|
||||
w.isDirty = false;
|
||||
await isar.workoutCollections.put(w);
|
||||
}
|
||||
|
||||
// Process Pulled Workouts (Updates from Server)
|
||||
if (response['pull_data'] != null &&
|
||||
response['pull_data']['workouts'] != null) {
|
||||
final pulledWorkouts = response['pull_data']['workouts'] as List;
|
||||
for (var wJson in pulledWorkouts) {
|
||||
final serverId = wJson['id'];
|
||||
var workout = await isar.workoutCollections
|
||||
.filter()
|
||||
.serverIdEqualTo(serverId)
|
||||
.findFirst();
|
||||
|
||||
workout ??= WorkoutCollection();
|
||||
|
||||
workout
|
||||
..serverId = serverId
|
||||
..cycleId = wJson['cycle_id']
|
||||
..userId = wJson['user_id']
|
||||
..week = wJson['week']
|
||||
..day = wJson['day']
|
||||
..completedAt = DateTime.tryParse(wJson['completed_at'] ?? '')
|
||||
..xpEarned = wJson['xp_earned'] ?? 0
|
||||
..exercisesJson = jsonEncode(wJson['exercises'])
|
||||
..isDirty = false
|
||||
..updatedAt = DateTime.now();
|
||||
|
||||
await isar.workoutCollections.put(workout);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Save new Sync Timestamp
|
||||
if (response['server_timestamp'] != null) {
|
||||
await _storage.write(
|
||||
key: AppConstants.keyLastSync,
|
||||
value: response['server_timestamp'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ Sync completed successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Sync failed: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
lib/src/shared/data/repositories/cycle_repository.dart
Normal file
208
lib/src/shared/data/repositories/cycle_repository.dart
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../local/collections/cycle_collection.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
import 'user_repository.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../local/collections/workout_collection.dart';
|
||||
|
||||
final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
|
||||
final isar = ref.watch(isarProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return CycleRepository(isar: isar, apiClient: apiClient);
|
||||
});
|
||||
|
||||
class CycleRepository {
|
||||
final Isar isar;
|
||||
final ApiClient apiClient;
|
||||
|
||||
CycleRepository({required this.isar, required this.apiClient});
|
||||
|
||||
Future<CycleCollection?> getCurrentCycle() async {
|
||||
return await isar.cycleCollections
|
||||
.filter()
|
||||
.isActiveEqualTo(true)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
Future<List<CycleCollection>> getAllCycles() async {
|
||||
return await isar.cycleCollections.where().findAll();
|
||||
}
|
||||
|
||||
Future<CycleCollection> createCycle(Map<String, double> trainingMaxes) async {
|
||||
try {
|
||||
final currentCycle = await getCurrentCycle();
|
||||
if (currentCycle != null) {
|
||||
currentCycle.isActive = false;
|
||||
currentCycle.endDate = DateTime.now();
|
||||
await saveCycle(currentCycle);
|
||||
}
|
||||
|
||||
final allCycles = await getAllCycles();
|
||||
final nextNumber = allCycles.isEmpty
|
||||
? 1
|
||||
: allCycles
|
||||
.map((c) => c.cycleNumber)
|
||||
.reduce((a, b) => a > b ? a : b) +
|
||||
1;
|
||||
|
||||
final userRepo = UserRepository(isar: isar, apiClient: ApiClient());
|
||||
final user = await userRepo.getLocalUser();
|
||||
|
||||
if (user == null) {
|
||||
throw Exception('No user found for cycle creation');
|
||||
}
|
||||
|
||||
final newCycle = CycleCollection()
|
||||
..userId = user.serverId ?? user.id.toString()
|
||||
..cycleNumber = nextNumber
|
||||
..startDate = DateTime.now()
|
||||
..isActive = true
|
||||
..trainingMaxesJson = jsonEncode(trainingMaxes)
|
||||
..isDirty = true;
|
||||
|
||||
await saveCycle(newCycle);
|
||||
|
||||
try {
|
||||
final response = await apiClient.createCycle(trainingMaxes);
|
||||
newCycle.serverId = response['id'];
|
||||
newCycle.isDirty = false;
|
||||
await saveCycle(newCycle);
|
||||
} catch (e) {}
|
||||
|
||||
return newCycle;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CycleCollection> finishCycle() async {
|
||||
final currentCycle = await getCurrentCycle();
|
||||
if (currentCycle == null) {
|
||||
throw Exception('No active cycle to finish');
|
||||
}
|
||||
|
||||
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
|
||||
|
||||
// --- FIX START: Vollständigkeitsprüfung ---
|
||||
// Wir zählen, wie viele Workouts in Woche 1, 2 und 3 tatsächlich abgeschlossen wurden.
|
||||
// Es müssen genau 9 sein (3 Wochen * 3 Tage).
|
||||
final completedMainWorkouts = await isar.workoutCollections
|
||||
.filter()
|
||||
.weekLessThan(
|
||||
4) // Nur Woche 1-3 zählen (Deload Woche 4 ist optional für Finish)
|
||||
.completedAtIsNotNull() // Nur abgeschlossene zählen
|
||||
.group((q) => q
|
||||
.cycleIdEqualTo(cycleIdRef)
|
||||
.or()
|
||||
.cycleIdEqualTo(currentCycle.id.toString()))
|
||||
.count();
|
||||
|
||||
if (completedMainWorkouts < 9) {
|
||||
final missing = 9 - completedMainWorkouts;
|
||||
throw Exception(
|
||||
'Cycle incomplete! You still have $missing workouts left in the main phase (Weeks 1-3). Finish them before leveling up.');
|
||||
}
|
||||
final currentTMs =
|
||||
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
|
||||
|
||||
final newTMs = <String, double>{
|
||||
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
|
||||
'pullup': (currentTMs['pullup'] as num?)?.toDouble() ?? 0.0,
|
||||
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
|
||||
};
|
||||
|
||||
// final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
|
||||
|
||||
final week3Workouts = await isar.workoutCollections
|
||||
.filter()
|
||||
.weekEqualTo(3)
|
||||
.group((q) => q
|
||||
.cycleIdEqualTo(cycleIdRef)
|
||||
.or()
|
||||
.cycleIdEqualTo(currentCycle.id.toString()))
|
||||
.findAll();
|
||||
|
||||
bool checkSuccess(String exerciseId) {
|
||||
for (var workout in week3Workouts) {
|
||||
try {
|
||||
final exercises = jsonDecode(workout.exercisesJson) as List;
|
||||
for (var ex in exercises) {
|
||||
if (ex['exerciseId'] == exerciseId) {
|
||||
final sets = ex['sets'] as List;
|
||||
for (var s in sets) {
|
||||
if (s['isAmrap'] == true) {
|
||||
final reps = s['repsActual'] as int? ?? 0;
|
||||
if (reps >= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Error checking lift success for $exerciseId: $e');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (checkSuccess('squat')) {
|
||||
newTMs['squat'] = newTMs['squat']! + AppConstants.lowerBodyIncrement;
|
||||
debugPrint('✅ Squat Progress: TM increased');
|
||||
} else {
|
||||
debugPrint('⚠️ Squat Stall: TM kept same');
|
||||
}
|
||||
|
||||
if (checkSuccess('pullup')) {
|
||||
newTMs['pullup'] = newTMs['pullup']! + AppConstants.upperBodyIncrement;
|
||||
debugPrint('✅ Pullup Progress: TM increased');
|
||||
} else {
|
||||
debugPrint('⚠️ Pullup Stall: TM kept same');
|
||||
}
|
||||
|
||||
if (checkSuccess('dip')) {
|
||||
newTMs['dip'] = newTMs['dip']! + AppConstants.upperBodyIncrement;
|
||||
debugPrint('✅ Dip Progress: TM increased');
|
||||
} else {
|
||||
debugPrint('⚠️ Dip Stall: TM kept same');
|
||||
}
|
||||
|
||||
if (currentCycle.serverId != null) {
|
||||
try {
|
||||
await apiClient.finishCycle(currentCycle.serverId!);
|
||||
} catch (e) {
|
||||
// Fehler ignorieren, wird später gesynct
|
||||
}
|
||||
}
|
||||
|
||||
return await createCycle(newTMs);
|
||||
}
|
||||
|
||||
Future<void> saveCycle(CycleCollection cycle) async {
|
||||
cycle.updatedAt = DateTime.now();
|
||||
await isar.writeTxn(() async {
|
||||
await isar.cycleCollections.put(cycle);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, double> getCurrentTrainingMaxes() {
|
||||
final cycle =
|
||||
isar.cycleCollections.filter().isActiveEqualTo(true).findFirstSync();
|
||||
|
||||
if (cycle != null) {
|
||||
final tms = jsonDecode(cycle.trainingMaxesJson);
|
||||
return {
|
||||
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
|
||||
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
|
||||
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
return {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
|
||||
}
|
||||
}
|
||||
253
lib/src/shared/data/repositories/user_repository.dart
Normal file
253
lib/src/shared/data/repositories/user_repository.dart
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../local/collections/cycle_collection.dart';
|
||||
import '../local/collections/user_collection.dart';
|
||||
import '../local/collections/workout_collection.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||
final isar = ref.watch(isarProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return UserRepository(isar: isar, apiClient: apiClient);
|
||||
});
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
|
||||
class UserRepository {
|
||||
final Isar isar;
|
||||
final ApiClient apiClient;
|
||||
|
||||
UserRepository({required this.isar, required this.apiClient});
|
||||
|
||||
Future<UserCollection?> getLocalUser() async {
|
||||
return await isar.userCollections.where().findFirst();
|
||||
}
|
||||
|
||||
Future<void> saveLocalUser(UserCollection user) async {
|
||||
user.updatedAt = DateTime.now();
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userCollections.put(user);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateXP(int xpToAdd) async {
|
||||
final user = await getLocalUser();
|
||||
if (user != null) {
|
||||
user.xp += xpToAdd;
|
||||
user.isDirty = true;
|
||||
await saveLocalUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateLevel(int newLevel) async {
|
||||
final user = await getLocalUser();
|
||||
if (user != null) {
|
||||
user.level = newLevel;
|
||||
user.isDirty = true;
|
||||
await saveLocalUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateBodyweight(double bodyweight) async {
|
||||
final user = await getLocalUser();
|
||||
if (user != null) {
|
||||
user.currentBodyweight = bodyweight;
|
||||
user.isDirty = true;
|
||||
await saveLocalUser(user);
|
||||
|
||||
try {
|
||||
await apiClient.updateBodyweight(bodyweight);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateInventory(Map<String, dynamic> inventory) async {
|
||||
final user = await getLocalUser();
|
||||
if (user != null) {
|
||||
user.inventorySettingsJson = jsonEncode(inventory);
|
||||
user.isDirty = true;
|
||||
await saveLocalUser(user);
|
||||
|
||||
try {
|
||||
await apiClient.updateInventory(inventory);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserCollection> login(String email, String password) async {
|
||||
final response = await apiClient.login(email, password);
|
||||
|
||||
final user = UserCollection()
|
||||
..serverId = response['record']['id']
|
||||
..email = response['record']['email']
|
||||
..xp = response['record']['xp'] ?? 0
|
||||
..level = response['record']['level'] ?? 1
|
||||
..currentBodyweight =
|
||||
(response['record']['current_bodyweight'] ?? 70.0).toDouble()
|
||||
..inventorySettingsJson =
|
||||
jsonEncode(response['record']['inventory_settings'] ?? {})
|
||||
..avatarConfigJson = jsonEncode(response['record']['avatar_config'] ?? {})
|
||||
..lastSyncAt = DateTime.now();
|
||||
|
||||
await saveLocalUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<UserCollection> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required double bodyweight,
|
||||
required Map<String, dynamic> inventorySettings,
|
||||
}) async {
|
||||
try {
|
||||
final response = await apiClient.register(
|
||||
email: email,
|
||||
password: password,
|
||||
bodyweight: bodyweight,
|
||||
inventorySettings: inventorySettings,
|
||||
);
|
||||
|
||||
final record = response['record'] ?? response;
|
||||
|
||||
final user = UserCollection()
|
||||
..serverId = record['id']?.toString()
|
||||
..email = record['email']?.toString() ?? email
|
||||
..xp = (record['xp'] as num?)?.toInt() ?? 0
|
||||
..level = (record['level'] as num?)?.toInt() ?? 1
|
||||
..currentBodyweight =
|
||||
(record['current_bodyweight'] as num?)?.toDouble() ?? bodyweight
|
||||
..inventorySettingsJson =
|
||||
jsonEncode(record['inventory_settings'] ?? inventorySettings)
|
||||
..avatarConfigJson = jsonEncode(record['avatar_config'] ??
|
||||
{
|
||||
'skin_tone': 'medium',
|
||||
'hair_style': 'short_01',
|
||||
'clothing': 'basic_tee',
|
||||
'unlocked_items': ['basic_tee'],
|
||||
})
|
||||
..lastSyncAt = DateTime.now();
|
||||
|
||||
await saveLocalUser(user);
|
||||
|
||||
try {
|
||||
await apiClient.login(email, password);
|
||||
} catch (e) {}
|
||||
|
||||
return user;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await apiClient.logout();
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userCollections.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> getInventorySettings() {
|
||||
final user = isar.userCollections.where().findFirstSync();
|
||||
if (user?.inventorySettingsJson != null) {
|
||||
return jsonDecode(user!.inventorySettingsJson!);
|
||||
}
|
||||
return {
|
||||
'bar_weight': 20.0,
|
||||
'plates': [20, 20, 10, 10, 5, 5, 2.5, 2.5, 1.25, 1.25],
|
||||
'bands': [],
|
||||
};
|
||||
}
|
||||
|
||||
List<double> getAvailablePlates() {
|
||||
final inventory = getInventorySettings();
|
||||
final plates = inventory['plates'] as List?;
|
||||
return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
|
||||
}
|
||||
|
||||
double getBarWeight() {
|
||||
final inventory = getInventorySettings();
|
||||
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
|
||||
}
|
||||
|
||||
Future<void> changePassword(String oldPassword, String newPassword) async {
|
||||
final user = await getLocalUser();
|
||||
if (user?.serverId != null) {
|
||||
await apiClient.updatePassword(
|
||||
userId: user!.serverId!,
|
||||
oldPassword: oldPassword,
|
||||
newPassword: newPassword,
|
||||
newPasswordConfirm: newPassword,
|
||||
);
|
||||
} else {
|
||||
throw Exception('User not synced or offline');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAccount() async {
|
||||
final user = await getLocalUser();
|
||||
if (user?.serverId != null) {
|
||||
await apiClient.deleteAccount(user!.serverId!);
|
||||
}
|
||||
// Lokal alles löschen
|
||||
await logout();
|
||||
}
|
||||
|
||||
// Future<void> resetProgress() async {
|
||||
// final user = await getLocalUser();
|
||||
// if (user != null) {
|
||||
// // 1. User Stats reset
|
||||
// user.xp = 0;
|
||||
// user.level = 1;
|
||||
// user.isDirty = true;
|
||||
|
||||
// await isar.writeTxn(() async {
|
||||
// await isar.userCollections.put(user);
|
||||
|
||||
// // 2. Alle Cycles und Workouts löschen
|
||||
// await isar.cycleCollections.clear();
|
||||
// await isar.workoutCollections.clear();
|
||||
// });
|
||||
|
||||
// // Sync anstoßen, um Server zu aktualisieren (User Stats)
|
||||
// // Hinweis: Das Löschen der History auf dem Server erfordert ggf. separate Logik,
|
||||
// // da der Sync aktuell nur "Updates" pusht, aber keine "Deletes" für Listen.
|
||||
// // Für MVP reicht der lokale Reset + User Stats Update.
|
||||
// }
|
||||
// }
|
||||
Future<void> resetProgress() async {
|
||||
final user = await getLocalUser();
|
||||
if (user != null) {
|
||||
// 1. SERVER RESET (Zwingend zuerst!)
|
||||
try {
|
||||
// Wir versuchen, den Server zu bereinigen.
|
||||
await apiClient.resetProgress();
|
||||
} catch (e) {
|
||||
// Wenn das fehlschlägt (z.B. Offline), brechen wir ab.
|
||||
// Ein lokaler Reset ohne Server-Reset führt sonst zu Daten-Chaos beim nächsten Sync.
|
||||
throw Exception(
|
||||
"Server connection required to reset progress. Please try again when online.");
|
||||
}
|
||||
|
||||
// 2. LOKALER RESET (Nur wenn Server erfolgreich war)
|
||||
user.xp = 0;
|
||||
user.level = 1;
|
||||
|
||||
// Wichtig: Wir setzen isDirty auf FALSE.
|
||||
// Der Server weiß schon Bescheid (durch den API Call oben).
|
||||
// Wir müssen ihm nicht nochmal sagen, dass XP jetzt 0 ist.
|
||||
user.isDirty = false;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
// User speichern
|
||||
await isar.userCollections.put(user);
|
||||
|
||||
// Alle lokalen Trainingsdaten löschen
|
||||
await isar.cycleCollections.clear();
|
||||
await isar.workoutCollections.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/src/shared/data/repositories/workout_repository.dart
Normal file
108
lib/src/shared/data/repositories/workout_repository.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../local/collections/workout_collection.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../../../../main.dart';
|
||||
import 'user_repository.dart';
|
||||
|
||||
final workoutRepositoryProvider = Provider<WorkoutRepository>((ref) {
|
||||
final isar = ref.watch(isarProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return WorkoutRepository(isar: isar, apiClient: apiClient);
|
||||
});
|
||||
|
||||
class WorkoutRepository {
|
||||
final Isar isar;
|
||||
final ApiClient apiClient;
|
||||
|
||||
WorkoutRepository({required this.isar, required this.apiClient});
|
||||
|
||||
Future<List<WorkoutCollection>> getAllWorkouts() async {
|
||||
return await isar.workoutCollections.where().findAll();
|
||||
}
|
||||
|
||||
Future<List<WorkoutCollection>> getWorkoutsForCycle(String cycleId) async {
|
||||
return await isar.workoutCollections
|
||||
.filter()
|
||||
.cycleIdEqualTo(cycleId)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<List<WorkoutCollection>> getCompletedWorkouts(String userId) async {
|
||||
return await isar.workoutCollections
|
||||
.filter()
|
||||
.userIdEqualTo(userId)
|
||||
.completedAtIsNotNull()
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<void> saveWorkout(WorkoutCollection workout) async {
|
||||
workout.updatedAt = DateTime.now();
|
||||
workout.isDirty = true;
|
||||
await isar.writeTxn(() async {
|
||||
await isar.workoutCollections.put(workout);
|
||||
});
|
||||
}
|
||||
|
||||
Future<WorkoutCollection> createWorkout({
|
||||
required String userId,
|
||||
required String cycleId,
|
||||
required int week,
|
||||
required int day,
|
||||
required String exercisesJson,
|
||||
}) async {
|
||||
final workout = WorkoutCollection()
|
||||
..userId = userId
|
||||
..cycleId = cycleId
|
||||
..week = week
|
||||
..day = day
|
||||
..exercisesJson = exercisesJson
|
||||
..scheduledDate = DateTime.now();
|
||||
|
||||
await saveWorkout(workout);
|
||||
return workout;
|
||||
}
|
||||
|
||||
Future<void> completeWorkout(
|
||||
WorkoutCollection workout, {
|
||||
required int xpEarned,
|
||||
}) async {
|
||||
workout.completedAt = DateTime.now();
|
||||
workout.xpEarned = xpEarned;
|
||||
await saveWorkout(workout);
|
||||
}
|
||||
|
||||
// Future<WorkoutCollection?> getWorkoutByWeekDay({
|
||||
// required String cycleId,
|
||||
// required int week,
|
||||
// required int day,
|
||||
// }) async {
|
||||
// return await isar.workoutCollections
|
||||
// .filter()
|
||||
// .cycleIdEqualTo(cycleId)
|
||||
// .weekEqualTo(week)
|
||||
// .dayEqualTo(day)
|
||||
// .findFirst();
|
||||
// }
|
||||
Future<WorkoutCollection?> getWorkoutByWeekDay({
|
||||
required String cycleId, // Meist Server ID
|
||||
String? localCycleId, // NEU: Backup Local ID
|
||||
required int week,
|
||||
required int day,
|
||||
}) async {
|
||||
return await isar.workoutCollections
|
||||
.filter()
|
||||
.weekEqualTo(week)
|
||||
.dayEqualTo(day)
|
||||
.group((q) {
|
||||
// Wir suchen ENTWEDER nach der Server-ID ODER nach der lokalen ID
|
||||
var query = q.cycleIdEqualTo(cycleId);
|
||||
if (localCycleId != null) {
|
||||
query = query.or().cycleIdEqualTo(localCycleId);
|
||||
}
|
||||
return query;
|
||||
}).findFirst();
|
||||
}
|
||||
}
|
||||
19
lib/src/shared/domain/entities/exercise.dart
Normal file
19
lib/src/shared/domain/entities/exercise.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'workout_set.dart';
|
||||
|
||||
part 'exercise.freezed.dart';
|
||||
part 'exercise.g.dart';
|
||||
|
||||
@freezed
|
||||
class Exercise with _$Exercise {
|
||||
const factory Exercise({
|
||||
required String exerciseId,
|
||||
required String exerciseName,
|
||||
@Default(0.0) double bodyweightAtSession,
|
||||
@Default([]) List<WorkoutSet> sets,
|
||||
}) = _Exercise;
|
||||
|
||||
factory Exercise.fromJson(Map<String, dynamic> json) =>
|
||||
_$ExerciseFromJson(json);
|
||||
}
|
||||
|
||||
226
lib/src/shared/domain/entities/exercise.freezed.dart
Normal file
226
lib/src/shared/domain/entities/exercise.freezed.dart
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'exercise.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
Exercise _$ExerciseFromJson(Map<String, dynamic> json) {
|
||||
return _Exercise.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Exercise {
|
||||
String get exerciseId => throw _privateConstructorUsedError;
|
||||
String get exerciseName => throw _privateConstructorUsedError;
|
||||
double get bodyweightAtSession => throw _privateConstructorUsedError;
|
||||
List<WorkoutSet> get sets => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$ExerciseCopyWith<Exercise> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ExerciseCopyWith<$Res> {
|
||||
factory $ExerciseCopyWith(Exercise value, $Res Function(Exercise) then) =
|
||||
_$ExerciseCopyWithImpl<$Res, Exercise>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String exerciseId,
|
||||
String exerciseName,
|
||||
double bodyweightAtSession,
|
||||
List<WorkoutSet> sets});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ExerciseCopyWithImpl<$Res, $Val extends Exercise>
|
||||
implements $ExerciseCopyWith<$Res> {
|
||||
_$ExerciseCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? exerciseId = null,
|
||||
Object? exerciseName = null,
|
||||
Object? bodyweightAtSession = null,
|
||||
Object? sets = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
exerciseId: null == exerciseId
|
||||
? _value.exerciseId
|
||||
: exerciseId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
exerciseName: null == exerciseName
|
||||
? _value.exerciseName
|
||||
: exerciseName // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
bodyweightAtSession: null == bodyweightAtSession
|
||||
? _value.bodyweightAtSession
|
||||
: bodyweightAtSession // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
sets: null == sets
|
||||
? _value.sets
|
||||
: sets // ignore: cast_nullable_to_non_nullable
|
||||
as List<WorkoutSet>,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ExerciseImplCopyWith<$Res>
|
||||
implements $ExerciseCopyWith<$Res> {
|
||||
factory _$$ExerciseImplCopyWith(
|
||||
_$ExerciseImpl value, $Res Function(_$ExerciseImpl) then) =
|
||||
__$$ExerciseImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String exerciseId,
|
||||
String exerciseName,
|
||||
double bodyweightAtSession,
|
||||
List<WorkoutSet> sets});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ExerciseImplCopyWithImpl<$Res>
|
||||
extends _$ExerciseCopyWithImpl<$Res, _$ExerciseImpl>
|
||||
implements _$$ExerciseImplCopyWith<$Res> {
|
||||
__$$ExerciseImplCopyWithImpl(
|
||||
_$ExerciseImpl _value, $Res Function(_$ExerciseImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? exerciseId = null,
|
||||
Object? exerciseName = null,
|
||||
Object? bodyweightAtSession = null,
|
||||
Object? sets = null,
|
||||
}) {
|
||||
return _then(_$ExerciseImpl(
|
||||
exerciseId: null == exerciseId
|
||||
? _value.exerciseId
|
||||
: exerciseId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
exerciseName: null == exerciseName
|
||||
? _value.exerciseName
|
||||
: exerciseName // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
bodyweightAtSession: null == bodyweightAtSession
|
||||
? _value.bodyweightAtSession
|
||||
: bodyweightAtSession // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
sets: null == sets
|
||||
? _value._sets
|
||||
: sets // ignore: cast_nullable_to_non_nullable
|
||||
as List<WorkoutSet>,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$ExerciseImpl implements _Exercise {
|
||||
const _$ExerciseImpl(
|
||||
{required this.exerciseId,
|
||||
required this.exerciseName,
|
||||
this.bodyweightAtSession = 0.0,
|
||||
final List<WorkoutSet> sets = const []})
|
||||
: _sets = sets;
|
||||
|
||||
factory _$ExerciseImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$ExerciseImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String exerciseId;
|
||||
@override
|
||||
final String exerciseName;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double bodyweightAtSession;
|
||||
final List<WorkoutSet> _sets;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<WorkoutSet> get sets {
|
||||
if (_sets is EqualUnmodifiableListView) return _sets;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_sets);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Exercise(exerciseId: $exerciseId, exerciseName: $exerciseName, bodyweightAtSession: $bodyweightAtSession, sets: $sets)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ExerciseImpl &&
|
||||
(identical(other.exerciseId, exerciseId) ||
|
||||
other.exerciseId == exerciseId) &&
|
||||
(identical(other.exerciseName, exerciseName) ||
|
||||
other.exerciseName == exerciseName) &&
|
||||
(identical(other.bodyweightAtSession, bodyweightAtSession) ||
|
||||
other.bodyweightAtSession == bodyweightAtSession) &&
|
||||
const DeepCollectionEquality().equals(other._sets, _sets));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, exerciseId, exerciseName,
|
||||
bodyweightAtSession, const DeepCollectionEquality().hash(_sets));
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
||||
__$$ExerciseImplCopyWithImpl<_$ExerciseImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$ExerciseImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _Exercise implements Exercise {
|
||||
const factory _Exercise(
|
||||
{required final String exerciseId,
|
||||
required final String exerciseName,
|
||||
final double bodyweightAtSession,
|
||||
final List<WorkoutSet> sets}) = _$ExerciseImpl;
|
||||
|
||||
factory _Exercise.fromJson(Map<String, dynamic> json) =
|
||||
_$ExerciseImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get exerciseId;
|
||||
@override
|
||||
String get exerciseName;
|
||||
@override
|
||||
double get bodyweightAtSession;
|
||||
@override
|
||||
List<WorkoutSet> get sets;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$ExerciseImplCopyWith<_$ExerciseImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
27
lib/src/shared/domain/entities/exercise.g.dart
Normal file
27
lib/src/shared/domain/entities/exercise.g.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'exercise.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$ExerciseImpl _$$ExerciseImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ExerciseImpl(
|
||||
exerciseId: json['exerciseId'] as String,
|
||||
exerciseName: json['exerciseName'] as String,
|
||||
bodyweightAtSession:
|
||||
(json['bodyweightAtSession'] as num?)?.toDouble() ?? 0.0,
|
||||
sets: (json['sets'] as List<dynamic>?)
|
||||
?.map((e) => WorkoutSet.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ExerciseImplToJson(_$ExerciseImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'exerciseId': instance.exerciseId,
|
||||
'exerciseName': instance.exerciseName,
|
||||
'bodyweightAtSession': instance.bodyweightAtSession,
|
||||
'sets': instance.sets,
|
||||
};
|
||||
17
lib/src/shared/domain/entities/training_maxes.dart
Normal file
17
lib/src/shared/domain/entities/training_maxes.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'training_maxes.freezed.dart';
|
||||
part 'training_maxes.g.dart';
|
||||
|
||||
@freezed
|
||||
class TrainingMaxes with _$TrainingMaxes {
|
||||
const factory TrainingMaxes({
|
||||
@Default(0.0) double squat,
|
||||
@Default(0.0) double pullup,
|
||||
@Default(0.0) double dip,
|
||||
}) = _TrainingMaxes;
|
||||
|
||||
factory TrainingMaxes.fromJson(Map<String, dynamic> json) =>
|
||||
_$TrainingMaxesFromJson(json);
|
||||
}
|
||||
|
||||
190
lib/src/shared/domain/entities/training_maxes.freezed.dart
Normal file
190
lib/src/shared/domain/entities/training_maxes.freezed.dart
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'training_maxes.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
TrainingMaxes _$TrainingMaxesFromJson(Map<String, dynamic> json) {
|
||||
return _TrainingMaxes.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TrainingMaxes {
|
||||
double get squat => throw _privateConstructorUsedError;
|
||||
double get pullup => throw _privateConstructorUsedError;
|
||||
double get dip => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TrainingMaxesCopyWith<TrainingMaxes> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TrainingMaxesCopyWith<$Res> {
|
||||
factory $TrainingMaxesCopyWith(
|
||||
TrainingMaxes value, $Res Function(TrainingMaxes) then) =
|
||||
_$TrainingMaxesCopyWithImpl<$Res, TrainingMaxes>;
|
||||
@useResult
|
||||
$Res call({double squat, double pullup, double dip});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TrainingMaxesCopyWithImpl<$Res, $Val extends TrainingMaxes>
|
||||
implements $TrainingMaxesCopyWith<$Res> {
|
||||
_$TrainingMaxesCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? squat = null,
|
||||
Object? pullup = null,
|
||||
Object? dip = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
squat: null == squat
|
||||
? _value.squat
|
||||
: squat // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
pullup: null == pullup
|
||||
? _value.pullup
|
||||
: pullup // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
dip: null == dip
|
||||
? _value.dip
|
||||
: dip // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TrainingMaxesImplCopyWith<$Res>
|
||||
implements $TrainingMaxesCopyWith<$Res> {
|
||||
factory _$$TrainingMaxesImplCopyWith(
|
||||
_$TrainingMaxesImpl value, $Res Function(_$TrainingMaxesImpl) then) =
|
||||
__$$TrainingMaxesImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({double squat, double pullup, double dip});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TrainingMaxesImplCopyWithImpl<$Res>
|
||||
extends _$TrainingMaxesCopyWithImpl<$Res, _$TrainingMaxesImpl>
|
||||
implements _$$TrainingMaxesImplCopyWith<$Res> {
|
||||
__$$TrainingMaxesImplCopyWithImpl(
|
||||
_$TrainingMaxesImpl _value, $Res Function(_$TrainingMaxesImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? squat = null,
|
||||
Object? pullup = null,
|
||||
Object? dip = null,
|
||||
}) {
|
||||
return _then(_$TrainingMaxesImpl(
|
||||
squat: null == squat
|
||||
? _value.squat
|
||||
: squat // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
pullup: null == pullup
|
||||
? _value.pullup
|
||||
: pullup // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
dip: null == dip
|
||||
? _value.dip
|
||||
: dip // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$TrainingMaxesImpl implements _TrainingMaxes {
|
||||
const _$TrainingMaxesImpl(
|
||||
{this.squat = 0.0, this.pullup = 0.0, this.dip = 0.0});
|
||||
|
||||
factory _$TrainingMaxesImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TrainingMaxesImplFromJson(json);
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final double squat;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double pullup;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double dip;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TrainingMaxes(squat: $squat, pullup: $pullup, dip: $dip)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TrainingMaxesImpl &&
|
||||
(identical(other.squat, squat) || other.squat == squat) &&
|
||||
(identical(other.pullup, pullup) || other.pullup == pullup) &&
|
||||
(identical(other.dip, dip) || other.dip == dip));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, squat, pullup, dip);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
||||
__$$TrainingMaxesImplCopyWithImpl<_$TrainingMaxesImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TrainingMaxesImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TrainingMaxes implements TrainingMaxes {
|
||||
const factory _TrainingMaxes(
|
||||
{final double squat,
|
||||
final double pullup,
|
||||
final double dip}) = _$TrainingMaxesImpl;
|
||||
|
||||
factory _TrainingMaxes.fromJson(Map<String, dynamic> json) =
|
||||
_$TrainingMaxesImpl.fromJson;
|
||||
|
||||
@override
|
||||
double get squat;
|
||||
@override
|
||||
double get pullup;
|
||||
@override
|
||||
double get dip;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TrainingMaxesImplCopyWith<_$TrainingMaxesImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
21
lib/src/shared/domain/entities/training_maxes.g.dart
Normal file
21
lib/src/shared/domain/entities/training_maxes.g.dart
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'training_maxes.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TrainingMaxesImpl _$$TrainingMaxesImplFromJson(Map<String, dynamic> json) =>
|
||||
_$TrainingMaxesImpl(
|
||||
squat: (json['squat'] as num?)?.toDouble() ?? 0.0,
|
||||
pullup: (json['pullup'] as num?)?.toDouble() ?? 0.0,
|
||||
dip: (json['dip'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TrainingMaxesImplToJson(_$TrainingMaxesImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'squat': instance.squat,
|
||||
'pullup': instance.pullup,
|
||||
'dip': instance.dip,
|
||||
};
|
||||
23
lib/src/shared/domain/entities/workout_set.dart
Normal file
23
lib/src/shared/domain/entities/workout_set.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'workout_set.freezed.dart';
|
||||
part 'workout_set.g.dart';
|
||||
|
||||
@freezed
|
||||
class WorkoutSet with _$WorkoutSet {
|
||||
const factory WorkoutSet({
|
||||
@Default(1) int setNumber,
|
||||
@Default(0) int targetPercentage,
|
||||
@Default(0.0) double targetWeightTotal,
|
||||
@Default(0.0) double plateWeight,
|
||||
@Default(0) int repsTarget,
|
||||
@Default(0) int repsActual,
|
||||
@Default(false) bool isAmrap,
|
||||
@Default(false) bool completed,
|
||||
int? rpe,
|
||||
}) = _WorkoutSet;
|
||||
|
||||
factory WorkoutSet.fromJson(Map<String, dynamic> json) =>
|
||||
_$WorkoutSetFromJson(json);
|
||||
}
|
||||
|
||||
340
lib/src/shared/domain/entities/workout_set.freezed.dart
Normal file
340
lib/src/shared/domain/entities/workout_set.freezed.dart
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'workout_set.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
WorkoutSet _$WorkoutSetFromJson(Map<String, dynamic> json) {
|
||||
return _WorkoutSet.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$WorkoutSet {
|
||||
int get setNumber => throw _privateConstructorUsedError;
|
||||
int get targetPercentage => throw _privateConstructorUsedError;
|
||||
double get targetWeightTotal => throw _privateConstructorUsedError;
|
||||
double get plateWeight => throw _privateConstructorUsedError;
|
||||
int get repsTarget => throw _privateConstructorUsedError;
|
||||
int get repsActual => throw _privateConstructorUsedError;
|
||||
bool get isAmrap => throw _privateConstructorUsedError;
|
||||
bool get completed => throw _privateConstructorUsedError;
|
||||
int? get rpe => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$WorkoutSetCopyWith<WorkoutSet> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $WorkoutSetCopyWith<$Res> {
|
||||
factory $WorkoutSetCopyWith(
|
||||
WorkoutSet value, $Res Function(WorkoutSet) then) =
|
||||
_$WorkoutSetCopyWithImpl<$Res, WorkoutSet>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int setNumber,
|
||||
int targetPercentage,
|
||||
double targetWeightTotal,
|
||||
double plateWeight,
|
||||
int repsTarget,
|
||||
int repsActual,
|
||||
bool isAmrap,
|
||||
bool completed,
|
||||
int? rpe});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$WorkoutSetCopyWithImpl<$Res, $Val extends WorkoutSet>
|
||||
implements $WorkoutSetCopyWith<$Res> {
|
||||
_$WorkoutSetCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? setNumber = null,
|
||||
Object? targetPercentage = null,
|
||||
Object? targetWeightTotal = null,
|
||||
Object? plateWeight = null,
|
||||
Object? repsTarget = null,
|
||||
Object? repsActual = null,
|
||||
Object? isAmrap = null,
|
||||
Object? completed = null,
|
||||
Object? rpe = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
setNumber: null == setNumber
|
||||
? _value.setNumber
|
||||
: setNumber // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
targetPercentage: null == targetPercentage
|
||||
? _value.targetPercentage
|
||||
: targetPercentage // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
targetWeightTotal: null == targetWeightTotal
|
||||
? _value.targetWeightTotal
|
||||
: targetWeightTotal // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
plateWeight: null == plateWeight
|
||||
? _value.plateWeight
|
||||
: plateWeight // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
repsTarget: null == repsTarget
|
||||
? _value.repsTarget
|
||||
: repsTarget // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
repsActual: null == repsActual
|
||||
? _value.repsActual
|
||||
: repsActual // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
isAmrap: null == isAmrap
|
||||
? _value.isAmrap
|
||||
: isAmrap // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
completed: null == completed
|
||||
? _value.completed
|
||||
: completed // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
rpe: freezed == rpe
|
||||
? _value.rpe
|
||||
: rpe // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$WorkoutSetImplCopyWith<$Res>
|
||||
implements $WorkoutSetCopyWith<$Res> {
|
||||
factory _$$WorkoutSetImplCopyWith(
|
||||
_$WorkoutSetImpl value, $Res Function(_$WorkoutSetImpl) then) =
|
||||
__$$WorkoutSetImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{int setNumber,
|
||||
int targetPercentage,
|
||||
double targetWeightTotal,
|
||||
double plateWeight,
|
||||
int repsTarget,
|
||||
int repsActual,
|
||||
bool isAmrap,
|
||||
bool completed,
|
||||
int? rpe});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$WorkoutSetImplCopyWithImpl<$Res>
|
||||
extends _$WorkoutSetCopyWithImpl<$Res, _$WorkoutSetImpl>
|
||||
implements _$$WorkoutSetImplCopyWith<$Res> {
|
||||
__$$WorkoutSetImplCopyWithImpl(
|
||||
_$WorkoutSetImpl _value, $Res Function(_$WorkoutSetImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? setNumber = null,
|
||||
Object? targetPercentage = null,
|
||||
Object? targetWeightTotal = null,
|
||||
Object? plateWeight = null,
|
||||
Object? repsTarget = null,
|
||||
Object? repsActual = null,
|
||||
Object? isAmrap = null,
|
||||
Object? completed = null,
|
||||
Object? rpe = freezed,
|
||||
}) {
|
||||
return _then(_$WorkoutSetImpl(
|
||||
setNumber: null == setNumber
|
||||
? _value.setNumber
|
||||
: setNumber // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
targetPercentage: null == targetPercentage
|
||||
? _value.targetPercentage
|
||||
: targetPercentage // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
targetWeightTotal: null == targetWeightTotal
|
||||
? _value.targetWeightTotal
|
||||
: targetWeightTotal // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
plateWeight: null == plateWeight
|
||||
? _value.plateWeight
|
||||
: plateWeight // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
repsTarget: null == repsTarget
|
||||
? _value.repsTarget
|
||||
: repsTarget // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
repsActual: null == repsActual
|
||||
? _value.repsActual
|
||||
: repsActual // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
isAmrap: null == isAmrap
|
||||
? _value.isAmrap
|
||||
: isAmrap // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
completed: null == completed
|
||||
? _value.completed
|
||||
: completed // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
rpe: freezed == rpe
|
||||
? _value.rpe
|
||||
: rpe // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$WorkoutSetImpl implements _WorkoutSet {
|
||||
const _$WorkoutSetImpl(
|
||||
{this.setNumber = 1,
|
||||
this.targetPercentage = 0,
|
||||
this.targetWeightTotal = 0.0,
|
||||
this.plateWeight = 0.0,
|
||||
this.repsTarget = 0,
|
||||
this.repsActual = 0,
|
||||
this.isAmrap = false,
|
||||
this.completed = false,
|
||||
this.rpe});
|
||||
|
||||
factory _$WorkoutSetImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$WorkoutSetImplFromJson(json);
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final int setNumber;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int targetPercentage;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double targetWeightTotal;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double plateWeight;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int repsTarget;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int repsActual;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isAmrap;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool completed;
|
||||
@override
|
||||
final int? rpe;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WorkoutSet(setNumber: $setNumber, targetPercentage: $targetPercentage, targetWeightTotal: $targetWeightTotal, plateWeight: $plateWeight, repsTarget: $repsTarget, repsActual: $repsActual, isAmrap: $isAmrap, completed: $completed, rpe: $rpe)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$WorkoutSetImpl &&
|
||||
(identical(other.setNumber, setNumber) ||
|
||||
other.setNumber == setNumber) &&
|
||||
(identical(other.targetPercentage, targetPercentage) ||
|
||||
other.targetPercentage == targetPercentage) &&
|
||||
(identical(other.targetWeightTotal, targetWeightTotal) ||
|
||||
other.targetWeightTotal == targetWeightTotal) &&
|
||||
(identical(other.plateWeight, plateWeight) ||
|
||||
other.plateWeight == plateWeight) &&
|
||||
(identical(other.repsTarget, repsTarget) ||
|
||||
other.repsTarget == repsTarget) &&
|
||||
(identical(other.repsActual, repsActual) ||
|
||||
other.repsActual == repsActual) &&
|
||||
(identical(other.isAmrap, isAmrap) || other.isAmrap == isAmrap) &&
|
||||
(identical(other.completed, completed) ||
|
||||
other.completed == completed) &&
|
||||
(identical(other.rpe, rpe) || other.rpe == rpe));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
setNumber,
|
||||
targetPercentage,
|
||||
targetWeightTotal,
|
||||
plateWeight,
|
||||
repsTarget,
|
||||
repsActual,
|
||||
isAmrap,
|
||||
completed,
|
||||
rpe);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
||||
__$$WorkoutSetImplCopyWithImpl<_$WorkoutSetImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$WorkoutSetImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _WorkoutSet implements WorkoutSet {
|
||||
const factory _WorkoutSet(
|
||||
{final int setNumber,
|
||||
final int targetPercentage,
|
||||
final double targetWeightTotal,
|
||||
final double plateWeight,
|
||||
final int repsTarget,
|
||||
final int repsActual,
|
||||
final bool isAmrap,
|
||||
final bool completed,
|
||||
final int? rpe}) = _$WorkoutSetImpl;
|
||||
|
||||
factory _WorkoutSet.fromJson(Map<String, dynamic> json) =
|
||||
_$WorkoutSetImpl.fromJson;
|
||||
|
||||
@override
|
||||
int get setNumber;
|
||||
@override
|
||||
int get targetPercentage;
|
||||
@override
|
||||
double get targetWeightTotal;
|
||||
@override
|
||||
double get plateWeight;
|
||||
@override
|
||||
int get repsTarget;
|
||||
@override
|
||||
int get repsActual;
|
||||
@override
|
||||
bool get isAmrap;
|
||||
@override
|
||||
bool get completed;
|
||||
@override
|
||||
int? get rpe;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$WorkoutSetImplCopyWith<_$WorkoutSetImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
33
lib/src/shared/domain/entities/workout_set.g.dart
Normal file
33
lib/src/shared/domain/entities/workout_set.g.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'workout_set.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$WorkoutSetImpl _$$WorkoutSetImplFromJson(Map<String, dynamic> json) =>
|
||||
_$WorkoutSetImpl(
|
||||
setNumber: (json['setNumber'] as num?)?.toInt() ?? 1,
|
||||
targetPercentage: (json['targetPercentage'] as num?)?.toInt() ?? 0,
|
||||
targetWeightTotal: (json['targetWeightTotal'] as num?)?.toDouble() ?? 0.0,
|
||||
plateWeight: (json['plateWeight'] as num?)?.toDouble() ?? 0.0,
|
||||
repsTarget: (json['repsTarget'] as num?)?.toInt() ?? 0,
|
||||
repsActual: (json['repsActual'] as num?)?.toInt() ?? 0,
|
||||
isAmrap: json['isAmrap'] as bool? ?? false,
|
||||
completed: json['completed'] as bool? ?? false,
|
||||
rpe: (json['rpe'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$WorkoutSetImplToJson(_$WorkoutSetImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'setNumber': instance.setNumber,
|
||||
'targetPercentage': instance.targetPercentage,
|
||||
'targetWeightTotal': instance.targetWeightTotal,
|
||||
'plateWeight': instance.plateWeight,
|
||||
'repsTarget': instance.repsTarget,
|
||||
'repsActual': instance.repsActual,
|
||||
'isAmrap': instance.isAmrap,
|
||||
'completed': instance.completed,
|
||||
'rpe': instance.rpe,
|
||||
};
|
||||
343
lib/src/shared/domain/logic/plate_calculator.dart
Normal file
343
lib/src/shared/domain/logic/plate_calculator.dart
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// import 'dart:math';
|
||||
|
||||
// class PlateLoadResult {
|
||||
// final bool success;
|
||||
// final List<double> plateConfiguration;
|
||||
// final String? bandAssistance; // Name of the band needed
|
||||
// final double totalAchieved;
|
||||
// final String message;
|
||||
|
||||
// PlateLoadResult({
|
||||
// required this.success,
|
||||
// required this.plateConfiguration,
|
||||
// this.bandAssistance,
|
||||
// required this.totalAchieved,
|
||||
// this.message = '',
|
||||
// });
|
||||
// }
|
||||
|
||||
// class PlateCalculator {
|
||||
// /// Calculate plate loading for a target weight
|
||||
// ///
|
||||
// /// [targetWeight]: Total weight to achieve
|
||||
// /// [barWeight]: Weight of the bar (or Bodyweight for calisthenics)
|
||||
// /// [availablePlates]: List of available plate weights
|
||||
// /// [availableBands]: Map of Band Name -> Resistance in KG
|
||||
// /// [isTwoSided]: true for barbell, false for dip belt
|
||||
// static PlateLoadResult calculate({
|
||||
// required double targetWeight,
|
||||
// required double barWeight,
|
||||
// required List<double> availablePlates,
|
||||
// Map<String, double> availableBands = const {},
|
||||
// required bool isTwoSided,
|
||||
// }) {
|
||||
// double needed = targetWeight - barWeight;
|
||||
|
||||
// if (needed < 0 && !isTwoSided) {
|
||||
// final deficit = needed.abs();
|
||||
// String? bestBand;
|
||||
// double minDiff = double.infinity;
|
||||
|
||||
// availableBands.forEach((name, resistance) {
|
||||
// final diff = (resistance - deficit).abs();
|
||||
// if (diff < minDiff) {
|
||||
// minDiff = diff;
|
||||
// bestBand = name;
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (bestBand != null) {
|
||||
// return PlateLoadResult(
|
||||
// success: true,
|
||||
// plateConfiguration: [],
|
||||
// bandAssistance: bestBand,
|
||||
// totalAchieved: targetWeight,
|
||||
// message: 'Use $bestBand band for assistance',
|
||||
// );
|
||||
// }
|
||||
|
||||
// return PlateLoadResult(
|
||||
// success: false,
|
||||
// plateConfiguration: [],
|
||||
// totalAchieved: barWeight,
|
||||
// message: 'Need assistance but no bands available',
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (needed <= 0.1) {
|
||||
// return PlateLoadResult(
|
||||
// success: true,
|
||||
// plateConfiguration: [],
|
||||
// totalAchieved: barWeight,
|
||||
// message: isTwoSided ? 'Bar only' : 'Bodyweight Only',
|
||||
// );
|
||||
// }
|
||||
|
||||
// final sortedPlates = List<double>.from(availablePlates)
|
||||
// ..sort((a, b) => b.compareTo(a));
|
||||
|
||||
// if (sortedPlates.isEmpty) {
|
||||
// return PlateLoadResult(
|
||||
// success: false,
|
||||
// plateConfiguration: [],
|
||||
// totalAchieved: barWeight,
|
||||
// message: 'No plates available',
|
||||
// );
|
||||
// }
|
||||
|
||||
// // ROUNDING LOGIC:
|
||||
// // We must round the needed weight to the nearest increment of the smallest plate.
|
||||
// // Example: Needed 9.7kg, Smallest plate 1.25kg -> Round to 10.0kg (8 * 1.25).
|
||||
// final smallestPlate = sortedPlates.last;
|
||||
// final targetPerSideRaw = isTwoSided ? needed / 2 : needed;
|
||||
|
||||
// final roundedPerSide =
|
||||
// (targetPerSideRaw / smallestPlate).round() * smallestPlate;
|
||||
|
||||
// if (roundedPerSide <= 0.001) {
|
||||
// return PlateLoadResult(
|
||||
// success: true,
|
||||
// plateConfiguration: [],
|
||||
// totalAchieved: barWeight,
|
||||
// message: isTwoSided ? 'Bar only' : 'Bodyweight Only',
|
||||
// );
|
||||
// }
|
||||
|
||||
// final result = _greedyFit(roundedPerSide, sortedPlates);
|
||||
|
||||
// if (!result.success) {
|
||||
// return PlateLoadResult(
|
||||
// success: false,
|
||||
// plateConfiguration: [],
|
||||
// totalAchieved: barWeight,
|
||||
// message: 'Cannot achieve target with available plates',
|
||||
// );
|
||||
// }
|
||||
|
||||
// final totalLoaded = isTwoSided
|
||||
// ? barWeight + (result.totalWeight * 2)
|
||||
// : barWeight + result.totalWeight;
|
||||
|
||||
// return PlateLoadResult(
|
||||
// success: true,
|
||||
// plateConfiguration: result.plates,
|
||||
// totalAchieved: totalLoaded,
|
||||
// );
|
||||
// }
|
||||
|
||||
// static _FitResult _greedyFit(double target, List<double> plates) {
|
||||
// final loaded = <double>[];
|
||||
// var remaining = target;
|
||||
|
||||
// const epsilon = 0.01;
|
||||
|
||||
// for (final plate in plates) {
|
||||
// while (remaining >= plate - epsilon) {
|
||||
// loaded.add(plate);
|
||||
// remaining -= plate;
|
||||
// }
|
||||
// }
|
||||
|
||||
// final success = remaining.abs() < epsilon;
|
||||
// return _FitResult(
|
||||
// success: success,
|
||||
// plates: loaded,
|
||||
// totalWeight: loaded.fold(0.0, (sum, p) => sum + p),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// class _FitResult {
|
||||
// final bool success;
|
||||
// final List<double> plates;
|
||||
// final double totalWeight;
|
||||
|
||||
// _FitResult({
|
||||
// required this.success,
|
||||
// required this.plates,
|
||||
// required this.totalWeight,
|
||||
// });
|
||||
// }
|
||||
import 'dart:math';
|
||||
|
||||
class PlateLoadResult {
|
||||
final bool success;
|
||||
final List<double> plateConfiguration;
|
||||
final String? bandAssistance; // Name of the band needed
|
||||
final double totalAchieved;
|
||||
final String message;
|
||||
|
||||
PlateLoadResult({
|
||||
required this.success,
|
||||
required this.plateConfiguration,
|
||||
this.bandAssistance,
|
||||
required this.totalAchieved,
|
||||
this.message = '',
|
||||
});
|
||||
}
|
||||
|
||||
class PlateCalculator {
|
||||
/// Calculate plate loading for a target weight
|
||||
///
|
||||
/// [targetWeight]: Total weight to achieve
|
||||
/// [barWeight]: Weight of the bar (or Bodyweight for calisthenics)
|
||||
/// [availablePlates]: List of available plate weights
|
||||
/// [availableBands]: Map of Band Name -> Resistance in KG
|
||||
/// [isTwoSided]: true for barbell, false for dip belt
|
||||
static PlateLoadResult calculate({
|
||||
required double targetWeight,
|
||||
required double barWeight,
|
||||
required List<double> availablePlates,
|
||||
Map<String, double> availableBands = const {},
|
||||
required bool isTwoSided,
|
||||
}) {
|
||||
double needed = targetWeight - barWeight;
|
||||
|
||||
// 1. Handle Assistance (Negative weight needed)
|
||||
if (needed < 0 && !isTwoSided) {
|
||||
final deficit = needed.abs();
|
||||
String? bestBand;
|
||||
double closestResistance = 0.0;
|
||||
double minDiff = double.infinity;
|
||||
|
||||
// Finde das am besten passende Band
|
||||
availableBands.forEach((name, resistance) {
|
||||
final diff = (resistance - deficit).abs();
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestBand = name;
|
||||
closestResistance = resistance;
|
||||
}
|
||||
});
|
||||
|
||||
// SMART FALLBACK LOGIK:
|
||||
// Prüfen, ob "Kein Band" (Bodyweight) näher am Ziel liegt als das "Beste Band".
|
||||
// Beispiel: Benötigt 2kg Support. Kleinstes Band 10kg.
|
||||
// - Fehler mit Band: |10 - 2| = 8kg zu viel Hilfe.
|
||||
// - Fehler ohne Band: 2kg zu wenig Hilfe.
|
||||
// -> Da 2kg < 8kg, ist Bodyweight präziser (und trainingstechnisch besser als viel zu leicht).
|
||||
|
||||
final deviationWithBand = minDiff; // Abweichung bei Band-Nutzung
|
||||
final deviationWithBW = deficit; // Abweichung bei Bodyweight (0 Support)
|
||||
|
||||
// Wir erlauben Bodyweight auch, wenn der Defizit extrem klein ist (< 1kg),
|
||||
// da Bänder selten so fein abgestuft sind.
|
||||
if (bestBand == null ||
|
||||
deviationWithBW <= deviationWithBand ||
|
||||
deficit < 1.0) {
|
||||
return PlateLoadResult(
|
||||
success: true,
|
||||
plateConfiguration: [],
|
||||
totalAchieved: barWeight,
|
||||
message: 'Bodyweight Only (Closer to target than band)',
|
||||
);
|
||||
}
|
||||
|
||||
// Band gefunden und es ist sinnvoll
|
||||
return PlateLoadResult(
|
||||
success: true,
|
||||
plateConfiguration: [],
|
||||
bandAssistance: bestBand,
|
||||
// Wir geben hier das echte erreichte Gewicht an (Körpergewicht - Bandstärke)
|
||||
totalAchieved: barWeight - closestResistance,
|
||||
message: 'Use $bestBand band for assistance',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Handle Added Weight (Plates)
|
||||
// Check if we effectively need 0 weight (with small tolerance)
|
||||
if (needed <= 0.1) {
|
||||
return PlateLoadResult(
|
||||
success: true,
|
||||
plateConfiguration: [],
|
||||
totalAchieved: barWeight,
|
||||
message: isTwoSided ? 'Bar only' : 'Bodyweight Only',
|
||||
);
|
||||
}
|
||||
|
||||
// Sort plates descending to find smallest plate later
|
||||
final sortedPlates = List<double>.from(availablePlates)
|
||||
..sort((a, b) => b.compareTo(a));
|
||||
|
||||
if (sortedPlates.isEmpty) {
|
||||
return PlateLoadResult(
|
||||
success: false,
|
||||
plateConfiguration: [],
|
||||
totalAchieved: barWeight,
|
||||
message: 'No plates available',
|
||||
);
|
||||
}
|
||||
|
||||
// ROUNDING LOGIC (wie vorher besprochen)
|
||||
final smallestPlate = sortedPlates.last;
|
||||
final targetPerSideRaw = isTwoSided ? needed / 2 : needed;
|
||||
|
||||
// Round to nearest smallest plate
|
||||
final roundedPerSide =
|
||||
(targetPerSideRaw / smallestPlate).round() * smallestPlate;
|
||||
|
||||
if (roundedPerSide <= 0.001) {
|
||||
return PlateLoadResult(
|
||||
success: true,
|
||||
plateConfiguration: [],
|
||||
totalAchieved: barWeight,
|
||||
message: isTwoSided ? 'Bar only' : 'Bodyweight Only',
|
||||
);
|
||||
}
|
||||
|
||||
// Try to fit the ROUNDED weight
|
||||
final result = _greedyFit(roundedPerSide, sortedPlates);
|
||||
|
||||
if (!result.success) {
|
||||
return PlateLoadResult(
|
||||
success: false,
|
||||
plateConfiguration: [],
|
||||
totalAchieved: barWeight,
|
||||
message: 'Cannot achieve target with available plates',
|
||||
);
|
||||
}
|
||||
|
||||
final totalLoaded = isTwoSided
|
||||
? barWeight + (result.totalWeight * 2)
|
||||
: barWeight + result.totalWeight;
|
||||
|
||||
return PlateLoadResult(
|
||||
success: true,
|
||||
plateConfiguration: result.plates,
|
||||
totalAchieved: totalLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
/// Greedy algorithm to fit plates
|
||||
static _FitResult _greedyFit(double target, List<double> plates) {
|
||||
final loaded = <double>[];
|
||||
var remaining = target;
|
||||
const epsilon = 0.01;
|
||||
|
||||
for (final plate in plates) {
|
||||
while (remaining >= plate - epsilon) {
|
||||
loaded.add(plate);
|
||||
remaining -= plate;
|
||||
}
|
||||
}
|
||||
|
||||
final success = remaining.abs() < epsilon;
|
||||
return _FitResult(
|
||||
success: success,
|
||||
plates: loaded,
|
||||
totalWeight: loaded.fold(0.0, (sum, p) => sum + p),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FitResult {
|
||||
final bool success;
|
||||
final List<double> plates;
|
||||
final double totalWeight;
|
||||
|
||||
_FitResult({
|
||||
required this.success,
|
||||
required this.plates,
|
||||
required this.totalWeight,
|
||||
});
|
||||
}
|
||||
106
lib/src/shared/domain/logic/wendler_calculator.dart
Normal file
106
lib/src/shared/domain/logic/wendler_calculator.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:math';
|
||||
import '../entities/workout_set.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
enum ExerciseType { squat, pullup, dip }
|
||||
|
||||
class WendlerCalculator {
|
||||
static const Map<int, List<double>> weekPercentages = {
|
||||
1: [0.65, 0.75, 0.85],
|
||||
2: [0.70, 0.80, 0.90],
|
||||
3: [0.75, 0.85, 0.95],
|
||||
4: [0.40, 0.50, 0.60],
|
||||
};
|
||||
|
||||
static const Map<int, List<int>> weekReps = {
|
||||
1: [5, 5, 5],
|
||||
2: [3, 3, 3],
|
||||
3: [5, 3, 1],
|
||||
4: [5, 5, 5],
|
||||
};
|
||||
|
||||
static List<WorkoutSet> generateSets({
|
||||
required int week,
|
||||
required double trainingMax,
|
||||
required ExerciseType exerciseType,
|
||||
required double currentBodyweight,
|
||||
}) {
|
||||
final percentages = weekPercentages[week]!;
|
||||
final reps = weekReps[week]!;
|
||||
final sets = <WorkoutSet>[];
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final targetTotal = trainingMax * percentages[i];
|
||||
final rounded = _roundWeight(targetTotal, exerciseType);
|
||||
|
||||
double plateWeight = 0;
|
||||
if (exerciseType != ExerciseType.squat) {
|
||||
plateWeight = max(0, rounded - currentBodyweight);
|
||||
}
|
||||
|
||||
sets.add(WorkoutSet(
|
||||
setNumber: i + 1,
|
||||
targetPercentage: (percentages[i] * 100).round(),
|
||||
targetWeightTotal: rounded,
|
||||
plateWeight: plateWeight,
|
||||
repsTarget: reps[i],
|
||||
isAmrap: (i == 2 && week != 4),
|
||||
));
|
||||
}
|
||||
|
||||
return sets;
|
||||
}
|
||||
|
||||
static double _roundWeight(double weight, ExerciseType type) {
|
||||
final step = type == ExerciseType.squat
|
||||
? AppConstants.squatRoundingStep
|
||||
: AppConstants.calisthenicsRoundingStep;
|
||||
return (weight / step).floor() * step;
|
||||
}
|
||||
|
||||
static double calculate1RM(double weight, int reps) {
|
||||
if (reps == 1) return weight;
|
||||
return weight * (1 + reps / 30.0);
|
||||
}
|
||||
|
||||
static double calculateTrainingMax(double oneRM) {
|
||||
return oneRM * AppConstants.trainingMaxPercentage;
|
||||
}
|
||||
|
||||
static double progressTrainingMax(double currentTM, ExerciseType type) {
|
||||
if (type == ExerciseType.squat) {
|
||||
return currentTM + AppConstants.lowerBodyIncrement;
|
||||
}
|
||||
return currentTM + AppConstants.upperBodyIncrement;
|
||||
}
|
||||
|
||||
static List<WorkoutSet> generateFSLSets({
|
||||
required double trainingMax,
|
||||
required ExerciseType exerciseType,
|
||||
required double currentBodyweight,
|
||||
}) {
|
||||
final sets = <WorkoutSet>[];
|
||||
final firstSetPercentage = 0.65;
|
||||
|
||||
final targetTotal = trainingMax * firstSetPercentage;
|
||||
final rounded = _roundWeight(targetTotal, exerciseType);
|
||||
|
||||
double plateWeight = 0;
|
||||
if (exerciseType != ExerciseType.squat) {
|
||||
plateWeight = max(0, rounded - currentBodyweight);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
sets.add(WorkoutSet(
|
||||
setNumber: i + 1,
|
||||
targetPercentage: 65,
|
||||
targetWeightTotal: rounded,
|
||||
plateWeight: plateWeight,
|
||||
repsTarget: 5,
|
||||
isAmrap: false,
|
||||
));
|
||||
}
|
||||
|
||||
return sets;
|
||||
}
|
||||
}
|
||||
116
lib/src/shared/domain/logic/xp_calculator.dart
Normal file
116
lib/src/shared/domain/logic/xp_calculator.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import 'dart:math';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../entities/exercise.dart';
|
||||
|
||||
class XPCalculator {
|
||||
/// Calculate level from total XP
|
||||
static int calculateLevelFromXP(int xp) {
|
||||
int level = 1;
|
||||
while (level <= AppConstants.maxLevel) {
|
||||
final requiredXP = xpRequiredForLevel(level + 1);
|
||||
if (xp < requiredXP) {
|
||||
return level;
|
||||
}
|
||||
level++;
|
||||
}
|
||||
return AppConstants.maxLevel;
|
||||
}
|
||||
|
||||
/// Calculate XP required to reach a specific level
|
||||
static int xpRequiredForLevel(int level) {
|
||||
if (level <= 1) return 0;
|
||||
return (AppConstants.baseXP *
|
||||
pow(AppConstants.xpMultiplier, level - 1)).round();
|
||||
}
|
||||
|
||||
/// Calculate XP for next level
|
||||
static int xpForNextLevel(int currentLevel) {
|
||||
return xpRequiredForLevel(currentLevel + 1);
|
||||
}
|
||||
|
||||
/// Calculate XP progress percentage
|
||||
static double xpProgressPercentage(int currentXP, int currentLevel) {
|
||||
final currentLevelXP = xpRequiredForLevel(currentLevel);
|
||||
final nextLevelXP = xpRequiredForLevel(currentLevel + 1);
|
||||
final xpInCurrentLevel = currentXP - currentLevelXP;
|
||||
final xpNeededForLevel = nextLevelXP - currentLevelXP;
|
||||
|
||||
if (xpNeededForLevel <= 0) return 1.0;
|
||||
return (xpInCurrentLevel / xpNeededForLevel).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Calculate XP earned from a completed workout
|
||||
static int calculateWorkoutXP(List<Exercise> exercises, {bool isPR = false}) {
|
||||
int totalXP = AppConstants.workoutCompleteXP; // Base completion bonus
|
||||
|
||||
for (final exercise in exercises) {
|
||||
for (final set in exercise.sets) {
|
||||
if (!set.completed) continue;
|
||||
|
||||
// Volume XP: 0.1 XP per kg moved
|
||||
final volume = set.targetWeightTotal * set.repsActual;
|
||||
totalXP += (volume * AppConstants.volumeXPRate).round();
|
||||
|
||||
// AMRAP bonus: 25 XP per extra rep
|
||||
if (set.isAmrap) {
|
||||
final extraReps = set.repsActual - set.repsTarget;
|
||||
if (extraReps > 0) {
|
||||
totalXP += extraReps * AppConstants.amrapBonusXPPerRep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PR bonus
|
||||
if (isPR) {
|
||||
totalXP += AppConstants.prBonusXP;
|
||||
}
|
||||
|
||||
return totalXP;
|
||||
}
|
||||
|
||||
/// Add XP and check for level up
|
||||
static LevelUpResult addXP({
|
||||
required int currentXP,
|
||||
required int currentLevel,
|
||||
required int xpToAdd,
|
||||
}) {
|
||||
int newXP = currentXP + xpToAdd;
|
||||
int newLevel = currentLevel;
|
||||
final levelsGained = <int>[];
|
||||
|
||||
while (newLevel < AppConstants.maxLevel) {
|
||||
final requiredForNext = xpRequiredForLevel(newLevel + 1);
|
||||
if (newXP >= requiredForNext) {
|
||||
newLevel++;
|
||||
levelsGained.add(newLevel);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return LevelUpResult(
|
||||
newXP: newXP,
|
||||
newLevel: newLevel,
|
||||
levelsGained: levelsGained,
|
||||
xpForNextLevel: xpRequiredForLevel(newLevel + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LevelUpResult {
|
||||
final int newXP;
|
||||
final int newLevel;
|
||||
final List<int> levelsGained;
|
||||
final int xpForNextLevel;
|
||||
|
||||
LevelUpResult({
|
||||
required this.newXP,
|
||||
required this.newLevel,
|
||||
required this.levelsGained,
|
||||
required this.xpForNextLevel,
|
||||
});
|
||||
|
||||
bool get didLevelUp => levelsGained.isNotEmpty;
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue