Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added autocomplete options to url input #128

Merged
merged 12 commits into from
Nov 2, 2024
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) {
showDialog(
context: context,
Expand Down
Loading