feat(mobile): Auto switching server URLs (#14437)

This commit is contained in:
Alex
2024-12-05 09:11:48 -06:00
committed by GitHub
parent 3c38851d50
commit 055f1fc72f
38 changed files with 1828 additions and 108 deletions

View File

@@ -0,0 +1,155 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
class EndpointInput extends StatefulHookConsumerWidget {
const EndpointInput({
super.key,
required this.initialValue,
required this.index,
required this.onValidated,
required this.onDismissed,
this.enabled = true,
});
final AuxilaryEndpoint initialValue;
final int index;
final Function(String url, int index, AuxCheckStatus status) onValidated;
final Function(int index) onDismissed;
final bool enabled;
@override
EndpointInputState createState() => EndpointInputState();
}
class EndpointInputState extends ConsumerState<EndpointInput> {
late final TextEditingController controller;
late final FocusNode focusNode;
late AuxCheckStatus auxCheckStatus;
bool isInputValid = false;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialValue.url);
focusNode = FocusNode()..addListener(_onOutFocus);
setState(() {
auxCheckStatus = widget.initialValue.status;
});
}
@override
void dispose() {
focusNode.removeListener(_onOutFocus);
focusNode.dispose();
controller.dispose();
super.dispose();
}
void _onOutFocus() {
if (!focusNode.hasFocus && isInputValid) {
validateAuxilaryServerUrl();
}
}
Future<void> validateAuxilaryServerUrl() async {
final url = controller.text;
setState(() => auxCheckStatus = AuxCheckStatus.loading);
final isValid =
await ref.read(authProvider.notifier).validateAuxilaryServerUrl(url);
setState(() {
if (mounted) {
auxCheckStatus = isValid ? AuxCheckStatus.valid : AuxCheckStatus.error;
}
});
widget.onValidated(url, widget.index, auxCheckStatus);
}
String? validateUrl(String? url) {
try {
if (url == null || url.isEmpty || !Uri.parse(url).isAbsolute) {
isInputValid = false;
return 'validate_endpoint_error'.tr();
}
} catch (_) {
isInputValid = false;
return 'validate_endpoint_error'.tr();
}
isInputValid = true;
return null;
}
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(widget.index.toString()),
direction: DismissDirection.endToStart,
onDismissed: (_) => widget.onDismissed(widget.index),
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(
Icons.delete,
color: Colors.white,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: ReorderableDragStartListener(
enabled: widget.enabled,
index: widget.index,
child: const Icon(Icons.drag_handle_rounded),
),
leading: NetworkStatusIcon(
key: ValueKey('status_$auxCheckStatus'),
status: auxCheckStatus,
enabled: widget.enabled,
),
subtitle: TextFormField(
enabled: widget.enabled,
onTapOutside: (_) => focusNode.unfocus(),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateUrl,
keyboardType: TextInputType.url,
style: const TextStyle(
fontFamily: 'Inconsolata',
fontWeight: FontWeight.w600,
fontSize: 14,
),
decoration: InputDecoration(
hintText: 'http(s)://immich.domain.com',
contentPadding: const EdgeInsets.all(16),
filled: true,
fillColor: context.colorScheme.surfaceContainer,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red[300]!),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color:
context.isDarkTheme ? Colors.grey[900]! : Colors.grey[300]!,
),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
),
controller: controller,
focusNode: focusNode,
),
),
);
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/entities/store.entity.dart' as db_store;
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
class ExternalNetworkPreference extends HookConsumerWidget {
const ExternalNetworkPreference({super.key, required this.enabled});
final bool enabled;
@override
Widget build(BuildContext context, WidgetRef ref) {
final entries =
useState([AuxilaryEndpoint(url: '', status: AuxCheckStatus.unknown)]);
final canSave = useState(false);
saveEndpointList() {
canSave.value =
entries.value.every((e) => e.status == AuxCheckStatus.valid);
final endpointList = entries.value
.where((url) => url.status == AuxCheckStatus.valid)
.toList();
final jsonString = jsonEncode(endpointList);
db_store.Store.put(
db_store.StoreKey.externalEndpointList,
jsonString,
);
}
updateValidationStatus(String url, int index, AuxCheckStatus status) {
entries.value[index] =
entries.value[index].copyWith(url: url, status: status);
saveEndpointList();
}
handleReorder(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final entry = entries.value.removeAt(oldIndex);
entries.value.insert(newIndex, entry);
entries.value = [...entries.value];
saveEndpointList();
}
handleDismiss(int index) {
entries.value = [...entries.value..removeAt(index)];
saveEndpointList();
}
Widget proxyDecorator(
Widget child,
int index,
Animation<double> animation,
) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
return Material(
color: context.colorScheme.surfaceContainerHighest,
shadowColor: context.colorScheme.primary.withOpacity(0.2),
child: child,
);
},
child: child,
);
}
useEffect(
() {
final jsonString =
db_store.Store.tryGet(db_store.StoreKey.externalEndpointList);
if (jsonString == null) {
return null;
}
final List<dynamic> jsonList = jsonDecode(jsonString);
entries.value =
jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return null;
},
const [],
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.colorScheme.surfaceContainerLow,
border: Border.all(
color: context.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Stack(
children: [
Positioned(
bottom: -36,
right: -36,
child: Icon(
Icons.dns_rounded,
size: 120,
color: context.primaryColor.withOpacity(0.05),
),
),
ListView(
padding: const EdgeInsets.symmetric(vertical: 16.0),
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 24,
),
child: Text(
"external_network_sheet_info".tr(),
style: context.textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Divider(color: context.colorScheme.surfaceContainerHighest),
Form(
key: GlobalKey<FormState>(),
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
proxyDecorator: proxyDecorator,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: entries.value.length,
onReorder: handleReorder,
itemBuilder: (context, index) {
return EndpointInput(
key: Key(index.toString()),
index: index,
initialValue: entries.value[index],
onValidated: updateValidationStatus,
onDismissed: handleDismiss,
enabled: enabled,
);
},
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: SizedBox(
height: 48,
child: OutlinedButton.icon(
icon: const Icon(Icons.add),
label: Text('add_endpoint'.tr().toUpperCase()),
onPressed: enabled
? () {
entries.value = [
...entries.value,
AuxilaryEndpoint(
url: '',
status: AuxCheckStatus.unknown,
),
];
}
: null,
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,256 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/network.provider.dart';
class LocalNetworkPreference extends HookConsumerWidget {
const LocalNetworkPreference({
super.key,
required this.enabled,
});
final bool enabled;
Future<String?> _showEditDialog(
BuildContext context,
String title,
String hintText,
String initialValue,
) {
final controller = TextEditingController(text: initialValue);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: hintText,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'cancel'.tr().toUpperCase(),
style: const TextStyle(color: Colors.red),
),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text('save'.tr().toUpperCase()),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final wifiNameText = useState("");
final localEndpointText = useState("");
useEffect(
() {
final wifiName = ref.read(authProvider.notifier).getSavedWifiName();
final localEndpoint =
ref.read(authProvider.notifier).getSavedLocalEndpoint();
if (wifiName != null) {
wifiNameText.value = wifiName;
}
if (localEndpoint != null) {
localEndpointText.value = localEndpoint;
}
return null;
},
[],
);
saveWifiName(String wifiName) {
wifiNameText.value = wifiName;
return ref.read(authProvider.notifier).saveWifiName(wifiName);
}
saveLocalEndpoint(String url) {
localEndpointText.value = url;
return ref.read(authProvider.notifier).saveLocalEndpoint(url);
}
handleEditWifiName() async {
final wifiName = await _showEditDialog(
context,
"wifi_name".tr(),
"your_wifi_name".tr(),
wifiNameText.value,
);
if (wifiName != null) {
await saveWifiName(wifiName);
}
}
handleEditServerEndpoint() async {
final localEndpoint = await _showEditDialog(
context,
"server_endpoint".tr(),
"http://local-ip:2283/api",
localEndpointText.value,
);
if (localEndpoint != null) {
await saveLocalEndpoint(localEndpoint);
}
}
autofillCurrentNetwork() async {
final wifiName = await ref.read(networkProvider.notifier).getWifiName();
if (wifiName == null) {
context.showSnackBar(
SnackBar(
content: Text(
"get_wifiname_error".tr(),
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSecondary,
),
),
backgroundColor: context.colorScheme.secondary,
),
);
} else {
saveWifiName(wifiName);
}
final serverEndpoint =
ref.read(authProvider.notifier).getServerEndpoint();
if (serverEndpoint != null) {
saveLocalEndpoint(serverEndpoint);
}
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Stack(
children: [
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.colorScheme.surfaceContainerLow,
border: Border.all(
color: context.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Stack(
children: [
Positioned(
bottom: -36,
right: -36,
child: Icon(
Icons.home_outlined,
size: 120,
color: context.primaryColor.withOpacity(0.05),
),
),
ListView(
padding: const EdgeInsets.symmetric(vertical: 16.0),
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 24,
),
child: Text(
"local_network_sheet_info".tr(),
style: context.textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Divider(
color: context.colorScheme.surfaceContainerHighest,
),
ListTile(
enabled: enabled,
contentPadding: const EdgeInsets.only(left: 24, right: 8),
leading: const Icon(Icons.wifi_rounded),
title: Text("wifi_name".tr()),
subtitle: wifiNameText.value.isEmpty
? Text("enter_wifi_name".tr())
: Text(
wifiNameText.value,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: enabled
? context.primaryColor
: context.colorScheme.onSurface
.withAlpha(100),
fontFamily: 'Inconsolata',
),
),
trailing: IconButton(
onPressed: enabled ? handleEditWifiName : null,
icon: const Icon(Icons.edit_rounded),
),
),
ListTile(
enabled: enabled,
contentPadding: const EdgeInsets.only(left: 24, right: 8),
leading: const Icon(Icons.lan_rounded),
title: Text("server_endpoint".tr()),
subtitle: localEndpointText.value.isEmpty
? const Text("http://local-ip:2283/api")
: Text(
localEndpointText.value,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: enabled
? context.primaryColor
: context.colorScheme.onSurface
.withAlpha(100),
fontFamily: 'Inconsolata',
),
),
trailing: IconButton(
onPressed: enabled ? handleEditServerEndpoint : null,
icon: const Icon(Icons.edit_rounded),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
),
child: SizedBox(
height: 48,
child: OutlinedButton.icon(
icon: const Icon(Icons.wifi_find_rounded),
label:
Text('use_current_connection'.tr().toUpperCase()),
onPressed: enabled ? autofillCurrentNetwork : null,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,266 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/network.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/entities/store.entity.dart' as db_store;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NetworkingSettings extends HookConsumerWidget {
const NetworkingSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint =
db_store.Store.get(db_store.StoreKey.serverEndpoint);
final featureEnabled =
useAppSettingsState(AppSettingsEnum.autoEndpointSwitching);
Future<void> checkWifiReadPermission() async {
final [hasLocationInUse, hasLocationAlways] = await Future.wait([
ref.read(networkProvider.notifier).getWifiReadPermission(),
ref.read(networkProvider.notifier).getWifiReadBackgroundPermission(),
]);
bool? isGrantLocationAlwaysPermission;
if (!hasLocationInUse) {
await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("location_permission".tr()),
content: Text("location_permission_content".tr()),
actions: [
TextButton(
onPressed: () async {
final isGrant = await ref
.read(networkProvider.notifier)
.requestWifiReadPermission();
Navigator.pop(context, isGrant);
},
child: Text("grant_permission".tr()),
),
],
);
},
);
}
if (!hasLocationAlways) {
isGrantLocationAlwaysPermission = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("background_location_permission".tr()),
content: Text("background_location_permission_content".tr()),
actions: [
TextButton(
onPressed: () async {
final isGrant = await ref
.read(networkProvider.notifier)
.requestWifiReadBackgroundPermission();
Navigator.pop(context, isGrant);
},
child: Text("grant_permission".tr()),
),
],
);
},
);
}
if (isGrantLocationAlwaysPermission != null &&
!isGrantLocationAlwaysPermission) {
await ref.read(networkProvider.notifier).openSettings();
}
}
useEffect(
() {
if (featureEnabled.value == true) {
checkWifiReadPermission();
}
return null;
},
[featureEnabled.value],
);
return ListView(
padding: const EdgeInsets.only(bottom: 96),
physics: const ClampingScrollPhysics(),
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8),
child: NetworkPreferenceTitle(
title: "current_server_address".tr().toUpperCase(),
icon: currentEndpoint.startsWith('https')
? Icons.https_outlined
: Icons.http_outlined,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
side: BorderSide(
color: context.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: ListTile(
leading:
const Icon(Icons.check_circle_rounded, color: Colors.green),
title: Text(
currentEndpoint,
style: TextStyle(
fontSize: 16,
fontFamily: 'Inconsolata',
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Divider(
color: context.colorScheme.surfaceContainerHighest,
),
),
SettingsSwitchListTile(
enabled: true,
valueNotifier: featureEnabled,
title: "automatic_endpoint_switching_title".tr(),
subtitle: "automatic_endpoint_switching_subtitle".tr(),
),
Padding(
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 16),
child: NetworkPreferenceTitle(
title: "local_network".tr().toUpperCase(),
icon: Icons.home_outlined,
),
),
LocalNetworkPreference(
enabled: featureEnabled.value,
),
Padding(
padding: const EdgeInsets.only(top: 32, left: 16, bottom: 16),
child: NetworkPreferenceTitle(
title: "external_network".tr().toUpperCase(),
icon: Icons.dns_outlined,
),
),
ExternalNetworkPreference(
enabled: featureEnabled.value,
),
],
);
}
}
class NetworkPreferenceTitle extends StatelessWidget {
const NetworkPreferenceTitle({
super.key,
required this.icon,
required this.title,
});
final IconData icon;
final String title;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(
icon,
color: context.colorScheme.onSurface.withAlpha(150),
),
const SizedBox(width: 8),
Text(
title,
style: context.textTheme.displaySmall?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
fontWeight: FontWeight.w500,
),
),
],
);
}
}
class NetworkStatusIcon extends StatelessWidget {
const NetworkStatusIcon({
super.key,
required this.status,
this.enabled = true,
}) : super();
final AuxCheckStatus status;
final bool enabled;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _buildIcon(context),
);
}
Widget _buildIcon(BuildContext context) {
switch (status) {
case AuxCheckStatus.loading:
return Padding(
padding: const EdgeInsets.only(left: 4.0),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: context.primaryColor,
strokeWidth: 2,
key: const ValueKey('loading'),
),
),
);
case AuxCheckStatus.valid:
return enabled
? const Icon(
Icons.check_circle_rounded,
color: Colors.green,
key: ValueKey('success'),
)
: Icon(
Icons.check_circle_rounded,
color: context.colorScheme.onSurface.withAlpha(100),
key: const ValueKey('success'),
);
case AuxCheckStatus.error:
return enabled
? const Icon(
Icons.error_rounded,
color: Colors.red,
key: ValueKey('error'),
)
: const Icon(
Icons.error_rounded,
color: Colors.grey,
key: ValueKey('error'),
);
default:
return const Icon(Icons.circle_outlined, key: ValueKey('unknown'));
}
}
}