Created
November 7, 2024 12:26
-
-
Save kingdomseed/0c40d93162ba02642c0b6608fa9622b5 to your computer and use it in GitHub Desktop.
A Dice Roller View
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Flutter imports: | |
| import 'package:flutter/material.dart'; | |
| // Package imports: | |
| import 'package:provider/provider.dart'; | |
| // Project imports: | |
| import '../../core/domain_models/dice_roll_result.dart'; | |
| import '../../resources/color_theme.dart'; | |
| import '../view_models/dice_roller_viewmodel.dart'; | |
| import '../view_models/notifiers/theme_notifier.dart'; | |
| import '../widgets/all_views/app_drawer.dart'; | |
| import '../widgets/all_views/mythic_app_bar.dart'; | |
| import '../widgets/dice_roller/dice_help_bottomsheet.dart'; | |
| class DiceRollerView extends StatefulWidget { | |
| const DiceRollerView({super.key}); | |
| @override | |
| State<DiceRollerView> createState() => _DiceRollerViewState(); | |
| } | |
| class _DiceRollerViewState extends State<DiceRollerView> { | |
| final ScrollController _rollHistoryController = ScrollController(); | |
| bool _showSettings = false; | |
| @override | |
| void dispose() { | |
| _rollHistoryController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final viewModel = Provider.of<DiceRollerViewModel>(context); | |
| final theme = Theme.of(context); | |
| final isDarkMode = theme.brightness == Brightness.dark; | |
| final colorScheme = theme.colorScheme; | |
| return Scaffold( | |
| appBar: MythicAppBar(title: "Dice Roller"), | |
| drawer: const AppDrawer(), | |
| body: GestureDetector( | |
| onTap: () => FocusScope.of(context).unfocus(), | |
| child: Container( | |
| constraints: BoxConstraints.expand(), | |
| decoration: BoxDecoration( | |
| gradient: ColorTheme.getBackgroundGradient( | |
| isDarkMode, | |
| context.watch<ThemeNotifier>().colorblindMode, | |
| ), | |
| ), | |
| child: SafeArea( | |
| child: SingleChildScrollView( | |
| child: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| children: [ | |
| _buildRollHistory(context, viewModel, colorScheme), | |
| const SizedBox(height: 16), | |
| _buildDiceInput(context, viewModel, colorScheme), | |
| const SizedBox(height: 16), | |
| _buildActionButtons(context, viewModel, colorScheme), | |
| const SizedBox(height: 16), | |
| _buildSettingsSection(context, viewModel, colorScheme), | |
| const SizedBox(height: 16), | |
| _buildSavedFormulas(context, viewModel, colorScheme), | |
| const SizedBox(height: 24), | |
| _buildHelpButton(context, colorScheme), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildRollHistory( | |
| BuildContext context, | |
| DiceRollerViewModel viewModel, | |
| ColorScheme colorScheme, | |
| ) { | |
| // Create a more natural description based on summing preference | |
| String rollHistoryDescription = viewModel.currentRolls.isEmpty | |
| ? "No dice rolls yet" | |
| : viewModel.currentRolls.map((roll) { | |
| String resultDescription = roll.explosionSequences.isEmpty | |
| ? roll.results.join(", ") // Join normal roll results | |
| : roll.explosionSequences | |
| .map((sequence) => sequence.join(" exploding to ")) | |
| .join(", "); // Handle exploding dice | |
| String modifierText = ""; | |
| if (roll.modifier != null && roll.modifier != 0) { | |
| modifierText = | |
| " ${roll.modifier! >= 0 ? 'plus' : 'minus'} ${roll.modifier!.abs()}"; | |
| } | |
| // Only include total if summing is enabled | |
| String totalText = | |
| viewModel.shouldSumRolls ? ", total ${roll.total}" : ""; | |
| return "Roll ${roll.formula} resulted in $resultDescription$modifierText$totalText"; | |
| }).join(". "); | |
| return Material( | |
| elevation: 2, | |
| borderRadius: BorderRadius.circular(16), | |
| clipBehavior: Clip.antiAlias, | |
| child: SizedBox( | |
| height: 200, | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| children: [ | |
| DecoratedBox( | |
| decoration: BoxDecoration( | |
| color: colorScheme.primary, | |
| ), | |
| child: Text( | |
| 'Roll History', | |
| textAlign: TextAlign.center, | |
| style: Theme.of(context).textTheme.titleMedium?.copyWith( | |
| fontWeight: FontWeight.w700, | |
| color: colorScheme.onPrimary.withOpacity(0.8), | |
| ), | |
| ), | |
| ), | |
| Container( | |
| height: 1, | |
| color: colorScheme.onPrimary.withOpacity(0.1), | |
| ), | |
| Expanded( | |
| child: viewModel.currentRolls.isEmpty | |
| ? Center( | |
| child: Text( | |
| 'No rolls yet', | |
| style: TextStyle(color: colorScheme.onSurface), | |
| ), | |
| ) | |
| : RawScrollbar( | |
| controller: _rollHistoryController, | |
| thumbVisibility: true, | |
| trackVisibility: true, | |
| thickness: 8, | |
| radius: const Radius.circular(4), | |
| thumbColor: colorScheme.onSurface.withOpacity(0.3), | |
| trackColor: colorScheme.surface.withOpacity(0.1), | |
| child: ListView.builder( | |
| controller: _rollHistoryController, | |
| padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), | |
| itemCount: viewModel.currentRolls.length, | |
| itemBuilder: (context, index) { | |
| final roll = viewModel.currentRolls[index]; | |
| return Padding( | |
| padding: const EdgeInsets.symmetric(vertical: 4.0), | |
| child: _buildRollDisplay(roll, colorScheme), | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildDiceInput( | |
| BuildContext context, | |
| DiceRollerViewModel viewModel, | |
| ColorScheme colorScheme, | |
| ) { | |
| return TextField( | |
| controller: viewModel.formulaController, | |
| decoration: InputDecoration( | |
| labelText: | |
| 'Dice Roll Formula field', // Add "field" to match working example | |
| hintText: 'e.g., 2d6+1, 3d8!, 4d6kh3', | |
| errorText: viewModel.validationError, | |
| filled: true, | |
| contentPadding: const EdgeInsets.symmetric( | |
| horizontal: 12, | |
| vertical: 8, | |
| ), | |
| border: OutlineInputBorder( | |
| borderRadius: BorderRadius.circular(8), | |
| borderSide: BorderSide(color: colorScheme.outline), | |
| ), | |
| fillColor: colorScheme.surface, | |
| labelStyle: TextStyle(color: colorScheme.onSurface), | |
| hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.7)), | |
| errorStyle: TextStyle(color: colorScheme.error), | |
| ), | |
| style: TextStyle(color: colorScheme.onSurface), | |
| onSubmitted: (_) => viewModel.rollDice(), | |
| onChanged: (value) { | |
| viewModel.resetValidation(); | |
| }, | |
| ); | |
| } | |
| Widget _buildActionButtons( | |
| BuildContext context, | |
| DiceRollerViewModel viewModel, | |
| ColorScheme colorScheme, | |
| ) { | |
| final textScaler = MediaQuery.textScalerOf(context); | |
| return Column( | |
| children: [ | |
| Column( | |
| children: [ | |
| ElevatedButton.icon( | |
| onPressed: viewModel.rollDice, | |
| icon: const Icon(Icons.casino), | |
| label: Text( | |
| 'Roll Dice', | |
| textScaler: | |
| textScaler.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.5), | |
| ), | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: colorScheme.primary, | |
| foregroundColor: colorScheme.onPrimary, | |
| minimumSize: const Size(double.infinity, 48), | |
| ), | |
| ), | |
| const SizedBox(height: 8), | |
| Row( | |
| children: [ | |
| Expanded( | |
| child: ElevatedButton.icon( | |
| onPressed: () => viewModel.saveFormula(null), | |
| icon: const Icon(Icons.save), | |
| label: Text( | |
| 'Save Formula', | |
| textScaler: textScaler.clamp( | |
| minScaleFactor: 0.8, maxScaleFactor: 1.5), | |
| ), | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: colorScheme.primary, | |
| foregroundColor: colorScheme.onPrimary, | |
| ), | |
| ), | |
| ), | |
| const SizedBox(width: 8), | |
| Expanded( | |
| child: ElevatedButton.icon( | |
| onPressed: viewModel.clearAll, | |
| icon: const Icon(Icons.clear_all), | |
| label: Text( | |
| 'Clear All', | |
| textScaler: textScaler.clamp( | |
| minScaleFactor: 0.8, maxScaleFactor: 1.5), | |
| ), | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: colorScheme.primary, | |
| foregroundColor: colorScheme.onPrimary, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ], | |
| ); | |
| } | |
| Widget _buildHelpButton( | |
| BuildContext context, | |
| ColorScheme colorScheme, | |
| ) { | |
| return Consumer<DiceRollerViewModel>( | |
| builder: (context, viewModel, child) { | |
| return Stack( | |
| alignment: Alignment.center, | |
| children: [ | |
| if (viewModel.shouldPulseHelp) ...[ | |
| TweenAnimationBuilder( | |
| key: const Key('help_pulse_outer'), | |
| tween: Tween<double>(begin: 0, end: 24), | |
| duration: const Duration(seconds: 1), | |
| curve: Curves.easeInOut, | |
| builder: (context, double value, child) { | |
| return Container( | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(24), | |
| border: Border.all( | |
| color: colorScheme.error.withOpacity(1 - value / 24), | |
| width: 2, | |
| ), | |
| ), | |
| width: 200 + value, | |
| height: 48 + value, | |
| ); | |
| }, | |
| ), | |
| TweenAnimationBuilder( | |
| key: const Key('help_pulse_inner'), | |
| tween: Tween<double>(begin: 0, end: 16), | |
| duration: const Duration(seconds: 1), | |
| curve: Curves.easeInOut, | |
| builder: (context, double value, child) { | |
| return Container( | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(24), | |
| border: Border.all( | |
| color: colorScheme.error.withOpacity(1 - value / 16), | |
| width: 2, | |
| ), | |
| ), | |
| width: 200 + value, | |
| height: 48 + value, | |
| ); | |
| }, | |
| ), | |
| ], | |
| OutlinedButton.icon( | |
| icon: Icon( | |
| Icons.help_outline, | |
| color: viewModel.shouldPulseHelp | |
| ? colorScheme.error | |
| : colorScheme.onPrimary, | |
| ), | |
| label: Text( | |
| 'Dice Notation Help', | |
| style: TextStyle( | |
| color: viewModel.shouldPulseHelp | |
| ? colorScheme.error | |
| : colorScheme.onPrimary, | |
| ), | |
| textScaler: MediaQuery.textScalerOf(context).clamp( | |
| minScaleFactor: 0.8, | |
| maxScaleFactor: 1.5, | |
| ), | |
| ), | |
| style: OutlinedButton.styleFrom( | |
| side: BorderSide( | |
| color: viewModel.shouldPulseHelp | |
| ? colorScheme.error | |
| : colorScheme.onPrimary, | |
| ), | |
| backgroundColor: colorScheme.surface.withOpacity(0.1), | |
| ), | |
| onPressed: () { | |
| viewModel.resetValidation(); | |
| showModalBottomSheet( | |
| context: context, | |
| isScrollControlled: true, | |
| useSafeArea: true, | |
| backgroundColor: Colors.transparent, | |
| builder: (context) => const DiceHelpBottomSheet(), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| } | |
| Widget _buildSavedFormulas( | |
| BuildContext context, | |
| DiceRollerViewModel viewModel, | |
| ColorScheme colorScheme, | |
| ) { | |
| if (viewModel.savedFormulas.isEmpty) { | |
| return const SizedBox.shrink(); | |
| } | |
| return Card( | |
| color: colorScheme.surface, | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| Padding( | |
| padding: const EdgeInsets.all(16), | |
| child: Text( | |
| 'Saved Formulas', | |
| style: Theme.of(context).textTheme.titleMedium?.copyWith( | |
| color: colorScheme.onSurface, | |
| ), | |
| textScaler: MediaQuery.textScalerOf(context).clamp( | |
| minScaleFactor: 0.8, | |
| maxScaleFactor: 1.5, | |
| ), | |
| ), | |
| ), | |
| ListView.builder( | |
| shrinkWrap: true, | |
| physics: const NeverScrollableScrollPhysics(), | |
| itemCount: viewModel.savedFormulas.length, | |
| itemBuilder: (context, index) { | |
| final formula = viewModel.savedFormulas[index]; | |
| return Dismissible( | |
| key: ValueKey( | |
| 'formula_${formula.formula}_${formula.dateAdded.millisecondsSinceEpoch}'), | |
| background: Container( | |
| color: colorScheme.error, | |
| alignment: Alignment.centerRight, | |
| padding: const EdgeInsets.only(right: 16), | |
| child: Icon(Icons.delete, color: colorScheme.onError), | |
| ), | |
| direction: DismissDirection.endToStart, | |
| onDismissed: (_) => viewModel.deleteFormula(index), | |
| child: ListTile( | |
| title: Text( | |
| formula.formula, | |
| style: TextStyle(color: colorScheme.onSurface), | |
| textScaler: MediaQuery.textScalerOf(context).clamp( | |
| minScaleFactor: 0.8, | |
| maxScaleFactor: 1.5, | |
| ), | |
| ), | |
| subtitle: formula.description.isNotEmpty | |
| ? Text( | |
| formula.description, | |
| style: TextStyle(color: colorScheme.onSurfaceVariant), | |
| textScaler: MediaQuery.textScalerOf(context).clamp( | |
| minScaleFactor: 0.8, | |
| maxScaleFactor: 1.5, | |
| ), | |
| ) | |
| : null, | |
| onTap: () => viewModel.selectFormula(formula.formula), | |
| trailing: Icon( | |
| Icons.chevron_left, | |
| color: colorScheme.onSurfaceVariant, | |
| semanticLabel: "Use formula", | |
| ), | |
| ), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| Widget _buildSettingsSection( | |
| BuildContext context, | |
| DiceRollerViewModel viewModel, | |
| ColorScheme colorScheme, | |
| ) { | |
| return Card( | |
| color: colorScheme.surface, | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| InkWell( | |
| onTap: () { | |
| setState(() { | |
| _showSettings = !_showSettings; | |
| }); | |
| }, | |
| child: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Row( | |
| children: [ | |
| Text( | |
| 'Roll Settings', | |
| style: Theme.of(context).textTheme.titleMedium?.copyWith( | |
| color: colorScheme.onSurface, | |
| fontWeight: FontWeight.bold, | |
| ), | |
| ), | |
| const Spacer(), | |
| Icon( | |
| _showSettings ? Icons.expand_less : Icons.expand_more, | |
| color: colorScheme.onSurface, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| if (_showSettings) | |
| Padding( | |
| padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), | |
| child: Column( | |
| children: [ | |
| const Divider(), | |
| _buildOptionSwitch( | |
| context, | |
| 'Sum Multiple Rolls', | |
| 'Add results of multiple dice rolls together', | |
| viewModel.shouldSumRolls, | |
| (value) => viewModel.toggleSumRolls(), | |
| colorScheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _buildOptionSwitch( | |
| context, | |
| 'Exploding Dice', | |
| 'Re-roll dice that show maximum value', | |
| viewModel.isExplodingDice, | |
| (value) => viewModel.isExplodingDice = value, | |
| colorScheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _buildOptionSwitch( | |
| context, | |
| 'Advantage', | |
| 'Roll with advantage (keep highest)', | |
| viewModel.hasAdvantage, | |
| (value) => viewModel.hasAdvantage = value, | |
| colorScheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _buildOptionSwitch( | |
| context, | |
| 'Disadvantage', | |
| 'Roll with disadvantage (keep lowest)', | |
| viewModel.hasDisadvantage, | |
| (value) => viewModel.hasDisadvantage = value, | |
| colorScheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _buildOptionSwitch( | |
| context, | |
| 'Reroll Ones', | |
| 'Automatically reroll any 1s', | |
| viewModel.rerollOnes, | |
| (value) => viewModel.rerollOnes = value, | |
| colorScheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _buildOptionSwitch( | |
| context, | |
| 'Count Criticals', | |
| 'Track critical successes and failures', | |
| viewModel.countCriticals, | |
| (value) => viewModel.countCriticals = value, | |
| colorScheme, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| Widget _buildOptionSwitch( | |
| BuildContext context, | |
| String title, | |
| String description, | |
| bool value, | |
| ValueChanged<bool> onChanged, | |
| ColorScheme colorScheme, | |
| ) { | |
| final textScaler = MediaQuery.textScalerOf(context); | |
| return Row( | |
| children: [ | |
| Expanded( | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| Text( | |
| title, | |
| style: TextStyle( | |
| color: colorScheme.onSurface, | |
| ), | |
| textScaler: | |
| textScaler.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.5), | |
| ), | |
| Text( | |
| description, | |
| style: Theme.of(context).textTheme.bodySmall?.copyWith( | |
| color: colorScheme.onSurface.withOpacity(0.7), | |
| ), | |
| textScaler: | |
| textScaler.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.5), | |
| ), | |
| ], | |
| ), | |
| ), | |
| Switch( | |
| value: value, | |
| onChanged: onChanged, | |
| ), | |
| ], | |
| ); | |
| } | |
| Widget _buildRollDisplay(DiceRollResult roll, ColorScheme colorScheme) { | |
| final spans = <TextSpan>[]; | |
| // Add formula | |
| spans.add(TextSpan(text: '${roll.formula}: ')); | |
| // Add opening bracket for roll results | |
| spans.add(const TextSpan(text: '[')); | |
| // Add results (either normal or exploding) | |
| if (roll.explosionSequences.isEmpty) { | |
| // Add normal roll results | |
| for (int i = 0; i < roll.results.length; i++) { | |
| spans.add(TextSpan( | |
| text: '${roll.results[i]}', | |
| style: TextStyle(color: colorScheme.onSurface), | |
| )); | |
| // Add comma if not the last result | |
| if (i < roll.results.length - 1) { | |
| spans.add(const TextSpan(text: ', ')); | |
| } | |
| } | |
| } else { | |
| // Add exploding roll results | |
| for (int i = 0; i < roll.explosionSequences.length; i++) { | |
| final sequence = roll.explosionSequences[i]; | |
| for (int j = 0; j < sequence.length; j++) { | |
| spans.add(TextSpan( | |
| text: '${sequence[j]}', | |
| style: TextStyle( | |
| color: j < sequence.length - 1 | |
| ? colorScheme.error | |
| : colorScheme.onSurface, | |
| fontWeight: | |
| j < sequence.length - 1 ? FontWeight.bold : FontWeight.normal, | |
| ), | |
| )); | |
| if (j < sequence.length - 1) { | |
| spans.add(TextSpan( | |
| text: ' → ', | |
| style: TextStyle( | |
| color: colorScheme.onPrimary.withOpacity(0.8), | |
| fontWeight: FontWeight.bold, | |
| ), | |
| )); | |
| } | |
| } | |
| if (i < roll.explosionSequences.length - 1) { | |
| spans.add(const TextSpan(text: ', ')); | |
| } | |
| } | |
| } | |
| // Add closing bracket for roll results | |
| spans.add(const TextSpan(text: ']')); | |
| // Add modifier if present | |
| if (roll.modifier != null && roll.modifier != 0) { | |
| spans.add(TextSpan( | |
| text: ' ${roll.modifier! >= 0 ? '+ ' : '- '}${roll.modifier!.abs()} ', | |
| style: TextStyle( | |
| color: colorScheme.onSurface, | |
| ), | |
| )); | |
| } | |
| // Add total if present | |
| if (roll.total != null) { | |
| spans.add(TextSpan( | |
| text: '= ${roll.total}', | |
| style: TextStyle( | |
| color: colorScheme.onSurface, | |
| ), | |
| )); | |
| } | |
| return RichText( | |
| text: TextSpan( | |
| style: TextStyle( | |
| fontFamily: 'RobotoMono', | |
| color: colorScheme.onSurface, | |
| ), | |
| children: spans, | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment