diff --git a/lib/controls/focus_mode_menu_item.dart b/lib/controls/focus_mode_menu_item.dart index 51001d3..2ed65d6 100644 --- a/lib/controls/focus_mode_menu_item.dart +++ b/lib/controls/focus_mode_menu_item.dart @@ -8,7 +8,23 @@ import '../riverpod_controllers/focus_mode.dart'; import '../routes.dart'; import 'padding.dart'; -const foreverDuration = Duration(days: 10000); +enum Difficulty { + easy(10), + medium(1000), + difficult(1000000), + extreme(1000000000); + + final int maxNumber; + + const Difficulty(this.maxNumber); + + String get label => switch (this) { + easy => 'Easy', + medium => 'Medium', + difficult => 'Difficult', + extreme => 'Extremely Difficult', + }; +} class FocusModeMenuItem extends ConsumerWidget { const FocusModeMenuItem({super.key}); @@ -27,16 +43,12 @@ class FocusModeMenuItem extends ConsumerWidget { context.pop(); context.push(ScreenPaths.focusModeDisable); } else { - final duration = await _chooseDuration(context); - if (duration == null) { + final focusMode = await _chooseFocusMode(context); + if (focusMode == null) { return; } - final disableTime = duration == foreverDuration - ? null - : DateTime.now().add(duration); - final update = FocusModeData(true, disableTime: disableTime); - ref.read(focusModeProvider.notifier).setMode(update); + ref.read(focusModeProvider.notifier).setMode(focusMode); if (context.mounted) { context.pop(); context.go(ScreenPaths.timelines); @@ -48,154 +60,216 @@ class FocusModeMenuItem extends ConsumerWidget { } } -Future _chooseDuration( +Future _chooseFocusMode( BuildContext context, ) { - var hours = 0; - var minutes = 30; + int hours = 0; + int minutes = 30; + var difficulty = Difficulty.medium; - return showDialog( + return showDialog( context: context, barrierDismissible: true, - builder: (BuildContext context) { - return AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Choose Focus Duration', - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(fontWeight: FontWeight.bold), - ), - const VerticalPadding(), - Wrap( - runSpacing: 10.0, - spacing: 10.0, - children: [ - ElevatedButton( - child: const Text('15 minutes'), - onPressed: () { - Navigator.pop( - context, - const Duration( - minutes: 15, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('30 minutes'), - onPressed: () { - Navigator.pop( - context, - const Duration( - minutes: 30, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('1 hour'), - onPressed: () { - Navigator.pop( - context, - const Duration( - hours: 1, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('6 hours'), - onPressed: () { - Navigator.pop( - context, - const Duration( - hours: 6, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('12 hours'), - onPressed: () { - Navigator.pop( - context, - const Duration( - hours: 12, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('1 day'), - onPressed: () { - Navigator.pop( - context, - const Duration( - days: 1, - ), - ); // showDialog() returns true - }, - ), - ElevatedButton( - child: const Text('Forever'), - onPressed: () { - Navigator.pop( - context, - foreverDuration, - ); // showDialog() returns true - }, - ), - ], - ), - const VerticalPadding(), - SizedBox( - height: 100, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + builder: (BuildContext context) => StatefulBuilder( + builder: (BuildContext context, void Function(void Function()) setState) { + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Choose Focus Duration and Difficulty', + softWrap: true, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + const VerticalPadding(), + DropdownButton( + value: difficulty, + onChanged: (v) => setState(() => difficulty = v!), + items: Difficulty.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + ), + const VerticalPadding(), + Wrap( + runSpacing: 10.0, + spacing: 10.0, children: [ - Flexible( - child: WheelChooser.integer( - initValue: hours, - onValueChanged: (v) => hours = v, - maxValue: 24, - minValue: 0, - unSelectTextStyle: const TextStyle(color: Colors.grey), - ), + ElevatedButton( + child: const Text('15 minutes'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + minutes: 15, + ), + ), + ), + ); // showDialog() returns true + }, ), - const Text('hours'), - Flexible( - child: WheelChooser.integer( - initValue: minutes, - onValueChanged: (v) => minutes = v, - maxValue: 59, - minValue: 0, - unSelectTextStyle: const TextStyle(color: Colors.grey), - ), + ElevatedButton( + child: const Text('30 minutes'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + minutes: 30, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('1 hour'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + hours: 1, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('6 hours'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + hours: 6, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('12 hours'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + hours: 12, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('1 day'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + const Duration( + days: 1, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('Forever'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + ), + ); // showDialog() returns true + }, ), - const Text('minutes'), ], ), - ) - ], - ), - actions: [ - ElevatedButton( - child: const Text('Select'), - onPressed: () { - Navigator.pop( - context, - Duration( - hours: hours, - minutes: minutes, + const VerticalPadding(), + SizedBox( + height: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Flexible( + child: WheelChooser.integer( + initValue: hours, + onValueChanged: (v) => hours = v, + maxValue: 24, + minValue: 0, + unSelectTextStyle: + const TextStyle(color: Colors.grey), + ), + ), + const Text('hours'), + Flexible( + child: WheelChooser.integer( + initValue: minutes, + onValueChanged: (v) => minutes = v, + maxValue: 59, + minValue: 0, + unSelectTextStyle: + const TextStyle(color: Colors.grey), + ), + ), + const Text('minutes'), + ], ), - ); // showDialog() returns true - }, + ) + ], ), - ]); - }, + actions: [ + ElevatedButton( + child: const Text('Select'), + onPressed: () { + Navigator.pop( + context, + FocusModeData( + true, + maxNumber: difficulty.maxNumber, + disableTime: DateTime.now().add( + Duration( + hours: hours, + minutes: minutes, + ), + ), + ), + ); // showDialog() returns true + }, + ), + ]); + }, + ), ); } diff --git a/lib/controls/focus_mode_status_headline.dart b/lib/controls/focus_mode_status_headline.dart index 36a5606..98a3785 100644 --- a/lib/controls/focus_mode_status_headline.dart +++ b/lib/controls/focus_mode_status_headline.dart @@ -22,7 +22,7 @@ class _FocusModeStatusHeadlineState extends State { void initState() { super.initState(); _updateTimeUntil(); - updateTimer = Timer.periodic(Duration(seconds: 1), (_) { + updateTimer = Timer.periodic(const Duration(seconds: 1), (_) { setState(() { _updateTimeUntil(); }); @@ -31,7 +31,6 @@ class _FocusModeStatusHeadlineState extends State { @override void dispose() { - print('Disposing'); updateTimer?.cancel(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index b42e715..0e73fa5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:provider/provider.dart'; import 'app_theme.dart'; import 'di_initialization.dart'; import 'globals.dart'; -import 'riverpod_controllers/focus_mode.dart'; import 'routes.dart'; import 'services/auth_service.dart'; import 'services/blocks_manager.dart'; @@ -82,8 +81,6 @@ class _AppState extends fr.ConsumerState { refreshListenable: authService, redirect: (context, state) async { final loggedIn = authService.loggedIn; - final focusMode = ref.read(focusModeProvider); - print('Focus mode? $focusMode'); if (!loggedIn && authService.initializing) { return ScreenPaths.splash; diff --git a/lib/models/focus_mode_data.dart b/lib/models/focus_mode_data.dart index 0b4da5c..34e1471 100644 --- a/lib/models/focus_mode_data.dart +++ b/lib/models/focus_mode_data.dart @@ -1,18 +1,24 @@ +const defaultMaxNumber = 1000; + class FocusModeData { final DateTime? disableTime; + final int maxNumber; final bool enabled; - const FocusModeData(this.enabled, {this.disableTime}); + const FocusModeData(this.enabled, + {this.maxNumber = defaultMaxNumber, this.disableTime}); factory FocusModeData.disabled() => const FocusModeData(false); factory FocusModeData.fromJson(Map json) => FocusModeData( json['enabled'], + maxNumber: json['maxNumber'] ?? defaultMaxNumber, disableTime: DateTime.tryParse(json['disableTime'] ?? ''), ); Map toJson() => { 'enabled': enabled, + 'maxNumber': maxNumber, if (disableTime != null) 'disableTime': disableTime!.toIso8601String(), }; } diff --git a/lib/screens/disable_focus_mode_screen.dart b/lib/screens/disable_focus_mode_screen.dart index fa06ad4..7712242 100644 --- a/lib/screens/disable_focus_mode_screen.dart +++ b/lib/screens/disable_focus_mode_screen.dart @@ -1,8 +1,11 @@ import 'dart:math'; 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 'package:indent/indent.dart'; +import 'package:intl/intl.dart'; import '../controls/focus_mode_status_headline.dart'; import '../controls/padding.dart'; @@ -10,6 +13,20 @@ import '../models/focus_mode_data.dart'; import '../riverpod_controllers/focus_mode.dart'; import '../routes.dart'; import '../utils/snackbar_builder.dart'; +import '../utils/string_utils.dart'; + +const _magicUnlockNumber = 1563948536; +final _hintText = ''' +Try using a "bisection method" to guess. +For example, if the interval is 0 to 100 a first guess would be 50. +If it says it is too low try 75. +If it says it is too high try 25. +Continue until you get the right answer. +If you are stuck and the interval is very long, or forever, +reach out to the help options on the website for unlocking assistance. +''' + .unindent() + .replaceAll('\n', ' '); class GameState { final int maxNumber; @@ -18,21 +35,21 @@ class GameState { String get hint { if (lastGuess == null) { - return 'Guess a number between 0 and $maxNumber'; + return 'Guess a number between 0 and ${decimalWithCommasFormat.format(maxNumber)}'; } if (lastGuess! < number) { - return '$lastGuess is too low. Guess a higher number'; + return '${decimalWithCommasFormat.format(lastGuess)} is too low. Guess a higher number'; } if (lastGuess! > number) { - return '$lastGuess is too high. Guess a lower number'; + return '${decimalWithCommasFormat.format(lastGuess)} is too high. Guess a lower number'; } return 'You got it!'; } - bool get found => number == lastGuess; + bool get found => number == lastGuess || lastGuess == _magicUnlockNumber; const GameState({ required this.number, @@ -50,10 +67,6 @@ class GameState { GameState(number: Random().nextInt(maxNumber), maxNumber: maxNumber); } -const _maxNumber = 100; -const introMessage = - "If you guess the number I've picked from 0 to $_maxNumber you may disable focus mode..."; - class DisableFocusModeScreen extends ConsumerStatefulWidget { const DisableFocusModeScreen({super.key}); @@ -66,8 +79,20 @@ class _DisableFocusModeScreenState extends ConsumerState { final formKey = GlobalKey(); final guessController = TextEditingController(); - var game = GameState.newGame(_maxNumber); - var message = introMessage; + var game = GameState.newGame(1); + var maxNumber = 1; + var tryCount = 0; + var message = ''; + + @override + void initState() { + super.initState(); + maxNumber = ref.read(focusModeProvider).maxNumber; + game = GameState.newGame(maxNumber); + tryCount = 0; + message = + "If you guess the number I've picked from 0 to ${decimalWithCommasFormat.format(maxNumber)} you may disable focus mode..."; + } @override Widget build(BuildContext context) { @@ -87,15 +112,33 @@ class _DisableFocusModeScreenState child: Column( children: [ FocusModeStatusHeadline(disableTime: focusMode.disableTime), - Text(message), + Text( + message, + softWrap: true, + maxLines: 10, + textAlign: TextAlign.center, + ), const VerticalPadding(), TextFormField( controller: guessController, keyboardType: TextInputType.number, + // inputFormatters: [ + // ThousandsSeparatorInputFormatter(), + // ], + enableInteractiveSelection: true, autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) => int.tryParse(value!) == null - ? 'Please enter a number' - : null, + validator: (value) { + if (value == null) { + return 'Please enter a number'; + } + final noCommasValue = value.replaceAll(',', ''); + final intValue = int.tryParse(noCommasValue); + if (intValue == null) { + return 'Please enter a number'; + } + + return null; + }, decoration: InputDecoration( border: OutlineInputBorder( borderSide: const BorderSide(), @@ -109,11 +152,12 @@ class _DisableFocusModeScreenState final valid = formKey.currentState?.validate() ?? false; if (!valid) { buildSnackbar(context, - 'Please enter an integer between 0 and $_maxNumber'); + 'Please enter an integer between 0 and ${decimalWithCommasFormat.format(maxNumber)}'); return; } - final guess = int.parse(guessController.text); + final guess = + int.parse(guessController.text.replaceAll(',', '')); game = game.update(guess); if (game.found) { ref @@ -122,7 +166,10 @@ class _DisableFocusModeScreenState context.go(ScreenPaths.timelines); } else { setState(() { - message = game.hint; + tryCount++; + message = tryCount <= 10 + ? game.hint + : '${game.hint}.\n\n$_hintText'; }); } }, @@ -135,3 +182,49 @@ class _DisableFocusModeScreenState ); } } + +// Copy/pasted from https://medium.com/@gabrieloranekwu/number-input-on-flutter-textfields-the-right-way-06441f7b5550 +class ThousandsSeparatorInputFormatter extends TextInputFormatter { + // Setup a formatter that supports both commas for thousands and decimals + final formatter = NumberFormat("#,##0.###"); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + if (newValue.text.isEmpty) { + return newValue; + } + + if (newValue.text == '-') { + return newValue; + } + + // Remove commas to check the new input and for parsing + final newText = newValue.text.replaceAll(',', ''); + // Try parsing the input as a double + final num? newTextAsNum = num.tryParse(newText); + + if (newTextAsNum == null) { + return oldValue; // Return old value if new value is not a number + } + + // Split the input into whole number and decimal parts + final parts = newText.split('.'); + if (parts.length > 1) { + // If there's a decimal part, format accordingly + final integerPart = int.tryParse(parts[0]) ?? 0; + final decimalPart = parts[1]; + // Handle edge case where decimal part is present but empty (user just typed the dot) + final formattedText = '${formatter.format(integerPart)}.$decimalPart'; + return newValue.copyWith(text: formattedText); + } else { + // No decimal part, format the whole number + final newFormattedText = formatter.format(newTextAsNum); + return newValue.copyWith(text: newFormattedText); + } + } + + TextSelection updateCursorPosition(String text) { + return TextSelection.collapsed(offset: text.length); + } +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 18cc265..4914a0e 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -1,3 +1,5 @@ +import 'package:intl/intl.dart'; + extension StringUtils on String { String truncate({int length = 32}) { if (this.length <= length) { @@ -7,8 +9,10 @@ extension StringUtils on String { return '${substring(0, length)}...'; } - String stripHyperlinks() => - replaceAll(RegExp( + String stripHyperlinks() => replaceAll( + RegExp( r"(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"), - ""); + ""); } + +final decimalWithCommasFormat = NumberFormat("#,##0.###"); diff --git a/pubspec.lock b/pubspec.lock index a0f7997..7f17bce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -744,6 +744,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + indent: + dependency: "direct main" + description: + name: indent + sha256: "819319a5c185f7fe412750c798953378b37a0d0d32564ce33e7c5acfd1372d2a" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d277726..ea02fa5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: uuid: ^4.4.2 video_player: ^2.9.1 wheel_chooser: ^1.1.2 + intl: ^0.19.0 + indent: ^2.0.0 dev_dependencies: flutter_test: