mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: custom device target
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
## 1.3.0 (2023-01-)
|
||||
|
||||
- feat: enter custom target address
|
||||
- fix: update nearby device attributes when scan again
|
||||
|
||||
## 1.2.0 (2022-12-31)
|
||||
|
||||
- feat: drag and drop files
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"done": "Done",
|
||||
"edit": "Edit",
|
||||
"error": "Error",
|
||||
"example": "Example",
|
||||
"finished": "Finished",
|
||||
"hide": "Hide",
|
||||
"offline": "Offline",
|
||||
@@ -48,6 +49,7 @@
|
||||
},
|
||||
"nearbyDevices": "Nearby devices",
|
||||
"thisDevice": "This Device",
|
||||
"scan": "Look for devices",
|
||||
"help": "Please ensure that the desired target is also in the same wifi network.",
|
||||
"placeItems": "Place items to share."
|
||||
},
|
||||
@@ -121,6 +123,11 @@
|
||||
},
|
||||
"messageInput": {
|
||||
"title": "Type message"
|
||||
},
|
||||
"addressInput": {
|
||||
"title": "Enter address",
|
||||
"hashtag": "Hashtag",
|
||||
"ip": "IP"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"done": "Fertig",
|
||||
"edit": "Bearbeiten",
|
||||
"error": "Fehler",
|
||||
"example": "Beispiel",
|
||||
"finished": "Abgeschlossen",
|
||||
"hide": "Verstecken",
|
||||
"offline": "Offline",
|
||||
@@ -48,6 +49,7 @@
|
||||
},
|
||||
"nearbyDevices": "Geräte in der Nähe",
|
||||
"thisDevice": "Dieses Gerät",
|
||||
"scan": "Geräte suchen",
|
||||
"help": "Bitte stelle sicher, dass sich das gewünschte Ziel auch im selben WLAN-Netzwerk befindet.",
|
||||
"placeItems": "Dateien ablegen, um zu teilen."
|
||||
},
|
||||
@@ -121,6 +123,11 @@
|
||||
},
|
||||
"messageInput": {
|
||||
"title": "Nachricht eingeben"
|
||||
},
|
||||
"addressInput": {
|
||||
"title": "Adresse eingeben",
|
||||
"hashtag": "Hashtag",
|
||||
"ip": "IP"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,9 +6,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/pages/home_page.dart';
|
||||
import 'package:localsend_app/provider/network/server_provider.dart';
|
||||
import 'package:localsend_app/provider/persistence_provider.dart';
|
||||
import 'package:localsend_app/provider/selected_files_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:localsend_app/service/persistence_service.dart';
|
||||
import 'package:localsend_app/theme.dart';
|
||||
import 'package:localsend_app/util/snackbar.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/init.dart';
|
||||
import 'package:localsend_app/pages/home_page.dart';
|
||||
import 'package:localsend_app/provider/device_info_provider.dart';
|
||||
import 'package:localsend_app/provider/persistence_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:localsend_app/theme.dart';
|
||||
import 'package:localsend_app/util/device_info_helper.dart';
|
||||
@@ -16,7 +17,7 @@ Future<void> main() async {
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
deviceRawInfoProvider.overrideWithValue(await getDeviceInfo()),
|
||||
settingsProvider.overrideWith((ref) => SettingsNotifier(persistenceService)),
|
||||
persistenceProvider.overrideWithValue(persistenceService),
|
||||
],
|
||||
child: const LocalSendApp(),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,6 @@ part 'nearby_devices_state.freezed.dart';
|
||||
class NearbyDevicesState with _$NearbyDevicesState {
|
||||
const factory NearbyDevicesState({
|
||||
required bool running,
|
||||
required List<Device> devices,
|
||||
required Map<String, Device> devices, // ip -> device
|
||||
}) = _NearbyDevicesState;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/model/cross_file.dart';
|
||||
import 'package:localsend_app/model/device.dart';
|
||||
import 'package:localsend_app/pages/selected_files_page.dart';
|
||||
import 'package:localsend_app/provider/device_info_provider.dart';
|
||||
import 'package:localsend_app/provider/network/nearby_devices_provider.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:localsend_app/util/file_picker.dart';
|
||||
import 'package:localsend_app/util/file_size_helper.dart';
|
||||
import 'package:localsend_app/widget/big_button.dart';
|
||||
import 'package:localsend_app/widget/dialogs/add_file_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/address_input_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/no_files_dialog.dart';
|
||||
import 'package:localsend_app/widget/list_tile/device_list_tile.dart';
|
||||
import 'package:localsend_app/widget/rotating_widget.dart';
|
||||
@@ -182,17 +184,52 @@ class _SendTabState extends ConsumerState<SendTab> {
|
||||
Row(
|
||||
children: [
|
||||
Text(t.sendTab.nearbyDevices, style: Theme.of(context).textTheme.subtitle1),
|
||||
RotatingWidget(
|
||||
duration: const Duration(seconds: 2),
|
||||
spinning: nearbyDevicesState.running,
|
||||
reverse: true,
|
||||
const SizedBox(width: 10),
|
||||
Tooltip(
|
||||
message: t.sendTab.scan,
|
||||
child: RotatingWidget(
|
||||
duration: const Duration(seconds: 2),
|
||||
spinning: nearbyDevicesState.running,
|
||||
reverse: true,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
minimumSize: Size.zero,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
onPressed: _scan,
|
||||
child: const Icon(Icons.sync),
|
||||
),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: t.dialogs.addressInput.title,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
minimumSize: Size.zero,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
onPressed: _scan,
|
||||
child: const Icon(Icons.sync),
|
||||
onPressed: () async {
|
||||
final files = ref.read(selectedFilesProvider);
|
||||
if (files.isEmpty) {
|
||||
context.pushBottomSheet(() => const NoFilesDialog());
|
||||
return;
|
||||
}
|
||||
final device = await showDialog<Device?>(
|
||||
context: context,
|
||||
builder: (_) => const AddressInputDialog(),
|
||||
);
|
||||
if (device != null && mounted) {
|
||||
ref.read(sendProvider.notifier).startSession(
|
||||
target: device,
|
||||
files: files,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.ads_click),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -205,7 +242,7 @@ class _SendTabState extends ConsumerState<SendTab> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...nearbyDevicesState.devices.map((device) {
|
||||
...nearbyDevicesState.devices.values.map((device) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Hero(
|
||||
|
||||
@@ -14,7 +14,7 @@ final nearbyDevicesProvider = StateNotifierProvider<NearbyDevicesNotifier, Nearb
|
||||
|
||||
class NearbyDevicesNotifier extends StateNotifier<NearbyDevicesState> {
|
||||
final Dio dio;
|
||||
NearbyDevicesNotifier(this.dio) : super(const NearbyDevicesState(running: false, devices: []));
|
||||
NearbyDevicesNotifier(this.dio) : super(const NearbyDevicesState(running: false, devices: {}));
|
||||
|
||||
Future<void> startScan({required int port, required String localIp}) async {
|
||||
if (state.running) {
|
||||
@@ -24,11 +24,9 @@ class NearbyDevicesNotifier extends StateNotifier<NearbyDevicesState> {
|
||||
state = state.copyWith(running: true);
|
||||
|
||||
await _getStream(localIp, port).forEach((device) {
|
||||
if (state.devices.every((d) => d.ip != device.ip)) {
|
||||
state = state.copyWith(
|
||||
devices: [...state.devices, device],
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
devices: {...state.devices}..update(device.ip, (_) => device, ifAbsent: () => device),
|
||||
);
|
||||
});
|
||||
|
||||
state = state.copyWith(running: false);
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:localsend_app/model/server/server_state.dart';
|
||||
import 'package:localsend_app/model/session_status.dart';
|
||||
import 'package:localsend_app/pages/receive_page.dart';
|
||||
import 'package:localsend_app/provider/device_info_provider.dart';
|
||||
import 'package:localsend_app/provider/persistence_provider.dart';
|
||||
import 'package:localsend_app/provider/progress_provider.dart';
|
||||
import 'package:localsend_app/service/persistence_service.dart';
|
||||
import 'package:localsend_app/util/alias_generator.dart';
|
||||
import 'package:localsend_app/util/api_route_builder.dart';
|
||||
import 'package:localsend_app/util/device_info_helper.dart';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/model/device.dart';
|
||||
import 'package:localsend_app/model/dto/info_dto.dart';
|
||||
import 'package:localsend_app/provider/dio_provider.dart';
|
||||
import 'package:localsend_app/util/api_route_builder.dart';
|
||||
|
||||
final targetedDiscoveryProvider = Provider((ref) {
|
||||
final dio = ref.watch(dioProvider(2 * 1000));
|
||||
return TargetedDiscoveryService(dio);
|
||||
});
|
||||
|
||||
/// Try to discover a single device using the given IP and port.
|
||||
class TargetedDiscoveryService {
|
||||
final Dio _dio;
|
||||
|
||||
TargetedDiscoveryService(this._dio);
|
||||
|
||||
Future<Device?> discover(String ip, int port) async {
|
||||
final url = ApiRoute.info.targetRaw(ip, port);
|
||||
Device? device;
|
||||
try {
|
||||
final response = await _dio.get(url);
|
||||
final dto = InfoDto.fromJson(response.data);
|
||||
device = dto.toDevice(ip, port);
|
||||
} on DioError catch (e) {
|
||||
device = null;
|
||||
print('$url: ${e.error}');
|
||||
} catch (e) {
|
||||
device = null;
|
||||
print(e);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/util/alias_generator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final persistenceProvider = Provider<PersistenceService>((ref) {
|
||||
throw Exception('persistenceProvider not initialized');
|
||||
});
|
||||
|
||||
const _aliasKey = 'ls_alias';
|
||||
const _themeKey = 'ls_theme';
|
||||
const _localeKey = 'ls_locale';
|
||||
@@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/model/settings.dart';
|
||||
import 'package:localsend_app/service/persistence_service.dart';
|
||||
import 'package:localsend_app/provider/persistence_provider.dart';
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>((ref) {
|
||||
throw Exception('settingsProvider not initialized');
|
||||
final persistenceService = ref.watch(persistenceProvider);
|
||||
return SettingsNotifier(persistenceService);
|
||||
});
|
||||
|
||||
class SettingsNotifier extends StateNotifier<Settings> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -36,6 +37,12 @@ ThemeData getTheme(Brightness brightness) {
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: brightness == Brightness.dark ? Colors.white : null,
|
||||
padding: defaultTargetPlatform == TargetPlatform.windows ? const EdgeInsets.symmetric(horizontal: 8, vertical: 16) : null,
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: defaultTargetPlatform == TargetPlatform.windows ? const EdgeInsets.symmetric(horizontal: 8, vertical: 16) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BigButton extends StatelessWidget {
|
||||
@@ -16,7 +15,6 @@ class BigButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: defaultTargetPlatform == TargetPlatform.windows ? 20 : 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/provider/network/targeted_discovery_provider.dart';
|
||||
import 'package:localsend_app/provider/network_info_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:routerino/routerino.dart';
|
||||
|
||||
enum _InputMode {
|
||||
hashtag,
|
||||
ip;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case _InputMode.hashtag:
|
||||
return t.dialogs.addressInput.hashtag;
|
||||
case _InputMode.ip:
|
||||
return t.dialogs.addressInput.ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AddressInputDialog extends ConsumerStatefulWidget {
|
||||
const AddressInputDialog();
|
||||
|
||||
@override
|
||||
ConsumerState<AddressInputDialog> createState() => _AddressInputDialogState();
|
||||
}
|
||||
|
||||
class _AddressInputDialogState extends ConsumerState<AddressInputDialog> {
|
||||
final _addressController = TextEditingController();
|
||||
final _selected = List.generate(_InputMode.values.length, (index) => index == 0);
|
||||
_InputMode _mode = _InputMode.hashtag;
|
||||
bool _fetching = false;
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localIp = ref.watch(networkInfoProvider.select((info) => info?.localIp));
|
||||
final ipPrefix = localIp?.split('.').take(3).join('.');
|
||||
final settings = ref.watch(settingsProvider);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(t.dialogs.addressInput.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ToggleButtons(
|
||||
isSelected: _selected,
|
||||
onPressed: (int index) {
|
||||
setState(() {
|
||||
for (int i = 0; i < _selected.length; i++) {
|
||||
_selected[i] = i == index;
|
||||
}
|
||||
_mode = _InputMode.values[index];
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
children: _InputMode.values.map((mode) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(mode.label),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _addressController,
|
||||
autofocus: true,
|
||||
enabled: !_fetching,
|
||||
decoration: InputDecoration(
|
||||
prefixText: _mode == _InputMode.hashtag ? '# ' : 'IP: ',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text('${t.general.example}: ${_mode == _InputMode.hashtag ? '123' : '${ipPrefix ?? '192.168.2'}.123'}'),
|
||||
if (_failed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(t.general.error, style: const TextStyle(color: Colors.orange)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).brightness == Brightness.dark ? Theme.of(context).buttonTheme.colorScheme!.onPrimary : null,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(t.general.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).buttonTheme.colorScheme!.primary,
|
||||
foregroundColor: Theme.of(context).buttonTheme.colorScheme!.onPrimary,
|
||||
),
|
||||
onPressed: _fetching ? null : () async {
|
||||
final String ip;
|
||||
final String input = _addressController.text.trim();
|
||||
if (_mode == _InputMode.ip) {
|
||||
ip = input;
|
||||
} else {
|
||||
ip = '$ipPrefix.$input';
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_fetching = true;
|
||||
});
|
||||
|
||||
final device = await ref.read(targetedDiscoveryProvider).discover(ip, settings.port);
|
||||
if (device != null) {
|
||||
if (mounted) {
|
||||
context.pop(device);
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_fetching = false;
|
||||
_failed = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(t.general.confirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user