Skip to content

Commit

Permalink
Merge pull request #128 from RA341/url-autofill
Browse files Browse the repository at this point in the history
feat: added autocomplete options to url input
  • Loading branch information
jdk-21 authored Nov 2, 2024
2 parents 3dccd4e + 95d5530 commit 18184d2
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 9 deletions.
92 changes: 92 additions & 0 deletions lib/components/custom_autocomplete_options.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:jellyflix/providers/url_autocomplete_provider.dart';

class CustomAutocompleteOptions<T extends Object> extends ConsumerWidget {
const CustomAutocompleteOptions({
super.key,
required this.displayStringForOption,
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
required this.maxOptionsWidth,
});

final AutocompleteOptionToString<T> displayStringForOption;

final AutocompleteOnSelected<T> onSelected;
final OptionsViewOpenDirection openDirection;

final Iterable<T> options;
final double maxOptionsHeight;
final double maxOptionsWidth;

@override
Widget build(BuildContext context, WidgetRef ref) {
final AlignmentDirectional optionsAlignment = switch (openDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
};
// taken from flutter\lib\src\material\autocomplete.dart and slightly modified
return Align(
alignment: optionsAlignment,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxOptionsHeight,
maxWidth: maxOptionsWidth,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final T option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Builder(builder: (BuildContext context) {
final bool highlight =
AutocompleteHighlightedOption.of(context) == index;
if (highlight) {
// wrapped in a future to update the options after building
Future(
() => ref.read(selectedOptionProvider.notifier).state =
option as String,
);
SchedulerBinding.instance.addPostFrameCallback(
(Duration timeStamp) {
Scrollable.ensureVisible(context, alignment: 0.5);
}, debugLabel: 'AutocompleteOptions.ensureVisible');
}

return Container(
color: highlight ? Theme.of(context).focusColor : null,
padding: const EdgeInsets.all(16.0),
// do not show shortcut hint on mobile platforms
child: highlight && !(Platform.isAndroid || Platform.isIOS)
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(displayStringForOption(option)),
const SizedBox(width: 20),
const Text('Enter to fill')
],
)
: Text(displayStringForOption(option)),
);
}),
);
},
),
),
),
);
}
}
100 changes: 100 additions & 0 deletions lib/components/url_autocomplete_field.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:jellyflix/components/custom_autocomplete_options.dart';
import 'package:jellyflix/providers/auth_provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:jellyflix/providers/url_autocomplete_provider.dart';

// Courtesy of sevenrats for the shortcuts

class UrlFieldInput extends ConsumerWidget {
const UrlFieldInput({super.key, required this.serverAddress});

final TextEditingController serverAddress;

@override
Widget build(BuildContext context, WidgetRef ref) {
final savedAddress = ref.read(allProfilesProvider);

// Needed to get width of the URL text field
// so we can assign that to the autocomplete width
final urlTextFieldKey = GlobalKey();

// Create a custom FocusNode to listen for key events
final focusNode = FocusNode();

// Attach the key listener for Tab key press
focusNode.onKeyEvent = (FocusNode node, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter) {
serverAddress.text = ref.read(selectedOptionProvider);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};

return RawAutocomplete<String>(
focusNode: focusNode,
textEditingController: serverAddress,
optionsBuilder: (TextEditingValue textEditingValue) {
final result = savedAddress.valueOrNull
?.where(
(element) =>
element.serverAdress!
.contains(textEditingValue.text.toLowerCase()) &&
// ensure that the option is not already filled
element.serverAdress! != textEditingValue.text.toLowerCase(),
)
.map((e) => e.serverAdress!)
.toSet(); // remove duplicates

final options = result == null || result.isEmpty
? ['http://', 'https://'].where((e) =>
e.contains(textEditingValue.text.toLowerCase()) &&
// ensure that the option is not already filled
e != textEditingValue.text.toLowerCase())
: result;

// clear options on change
ref.invalidate(selectedOptionProvider);

return options;
},
onSelected: (option) => serverAddress.text = option,
optionsViewOpenDirection: OptionsViewOpenDirection.down,
fieldViewBuilder: (context, controller, focusNode, _) {
return TextField(
key: urlTextFieldKey,
focusNode: focusNode,
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.serverAddress,
hintText: 'http://',
),
);
},
optionsViewBuilder: (
BuildContext context,
void Function(String) onSelected,
Iterable<String> options,
) {
RenderBox? renderBox;
if (urlTextFieldKey.currentContext?.findRenderObject() != null) {
renderBox =
urlTextFieldKey.currentContext!.findRenderObject() as RenderBox;
}

return CustomAutocompleteOptions(
displayStringForOption: RawAutocomplete.defaultStringForOption,
onSelected: onSelected,
options: options,
openDirection: OptionsViewOpenDirection.down,
maxOptionsHeight: 150,
maxOptionsWidth: renderBox?.size.width ?? 300,
);
},
);
}
}
5 changes: 5 additions & 0 deletions lib/providers/url_autocomplete_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

final selectedOptionProvider = StateProvider<String>((ref) {
return '';
});
12 changes: 3 additions & 9 deletions lib/screens/login_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:jellyflix/components/url_autocomplete_field.dart';
import 'package:jellyflix/models/screen_paths.dart';
import 'package:jellyflix/models/user.dart';
import 'package:jellyflix/providers/auth_provider.dart';
Expand Down Expand Up @@ -56,15 +57,7 @@ class LoginScreen extends HookConsumerWidget {
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: TextField(
controller: serverAddress,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText:
AppLocalizations.of(context)!.serverAddress,
hintText: 'http://',
),
),
child: UrlFieldInput(serverAddress: serverAddress),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
Expand Down Expand Up @@ -266,3 +259,4 @@ class LoginScreen extends HookConsumerWidget {
.trim();
}
}

1 change: 1 addition & 0 deletions lib/screens/player_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class _PlayerSreenState extends ConsumerState<PlayerScreen> {
);
});

if (!context.mounted) return;
player.stream.error.listen((error) {
if (mounted) {
showDialog(
Expand Down

0 comments on commit 18184d2

Please sign in to comment.