Skip to content

Instantly share code, notes, and snippets.

@kingdomseed
Created November 7, 2024 12:26
Show Gist options
  • Select an option

  • Save kingdomseed/0c40d93162ba02642c0b6608fa9622b5 to your computer and use it in GitHub Desktop.

Select an option

Save kingdomseed/0c40d93162ba02642c0b6608fa9622b5 to your computer and use it in GitHub Desktop.
A Dice Roller View
// 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