Merge branch 'focus-mode-tweaks' into 'main'

Focus mode tweaks

See merge request mysocialportal/relatica!63
This commit is contained in:
HankG 2024-08-31 00:41:18 +00:00
commit c4f48106a0
8 changed files with 362 additions and 171 deletions

View file

@ -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<Duration?> _chooseDuration(
Future<FocusModeData?> _chooseFocusMode(
BuildContext context,
) {
var hours = 0;
var minutes = 30;
int hours = 0;
int minutes = 30;
var difficulty = Difficulty.medium;
return showDialog<Duration?>(
return showDialog<FocusModeData?>(
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<Difficulty>(
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
},
),
]);
},
),
);
}

View file

@ -22,7 +22,7 @@ class _FocusModeStatusHeadlineState extends State<FocusModeStatusHeadline> {
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<FocusModeStatusHeadline> {
@override
void dispose() {
print('Disposing');
updateTimer?.cancel();
super.dispose();
}

View file

@ -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<App> {
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;

View file

@ -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<String, dynamic> json) => FocusModeData(
json['enabled'],
maxNumber: json['maxNumber'] ?? defaultMaxNumber,
disableTime: DateTime.tryParse(json['disableTime'] ?? ''),
);
Map<String, dynamic> toJson() => {
'enabled': enabled,
'maxNumber': maxNumber,
if (disableTime != null) 'disableTime': disableTime!.toIso8601String(),
};
}

View file

@ -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<DisableFocusModeScreen> {
final formKey = GlobalKey<FormState>();
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);
}
}

View file

@ -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.###");

View file

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

View file

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