initial commit with working version

This commit is contained in:
Patryk Hegenberg 2025-11-28 15:59:06 +01:00
commit 7e4dd30599
235 changed files with 23683 additions and 0 deletions

38
lib/main.dart Normal file
View 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
View 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,
);
}
}

View 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';
}

View 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,
};
}

View 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),
// ),
// ],
// ),
// ),
// );
// }
}

View 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,
),
),
);
}
}

View file

@ -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'),
),
],
),
],
),
),
),
),
),
);
}
}

View file

@ -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),
),
),
],
),
);
}
}

View file

@ -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'),
),
],
),
],
),
),
),
),
),
);
}
}

View 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,
),
],
),
),
);
}
}

View file

@ -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,
),
),
],
),
);
}
}

View file

@ -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,
),
),
],
),
),
),
);
},
);
}
}

View file

@ -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,
),
],
),
),
),
],
),
],
);
}
}

View file

@ -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);
}

View file

@ -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,
),
),
],
),
),
],
),
),
);
}
}

View file

@ -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,
),
),
);
},
),
),
],
);
}
}

View file

@ -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),
),
),
);
}
}

View file

@ -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(),
],
),
);
}
}

View file

@ -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'),
),
],
),
),
);
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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,
// ),
);
}
}

View file

@ -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'),
),
],
),
),
),
);
}
}

View file

@ -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,
// ),
// ],
// ),
// ),
// );
// }
// }

View file

@ -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,
),
),
],
),
);
}
}

View file

@ -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,
),
],
),
),
],
);
}
}

View 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(),
);
}
}

View 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),
),
);
}
}

View 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();
},
),
),
),
),
),
],
),
);
}
}

View file

@ -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

View file

@ -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,
),
],
),
),
),
],
),
],
);
}
}

View file

@ -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;
}
}

View file

@ -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,
),
],
);
}
}

View 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();
}

File diff suppressed because it is too large Load diff

View 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();
}

File diff suppressed because it is too large Load diff

View file

@ -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();
}

File diff suppressed because it is too large Load diff

View 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;
}
}
}

View 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;
}
}
}

View 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};
}
}

View 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();
});
}
}
}

View 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();
}
}

View 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);
}

View 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;
}

View 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,
};

View 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);
}

View 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;
}

View 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,
};

View 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);
}

View 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;
}

View 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,
};

View 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,
});
}

View 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;
}
}

View 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;
}