feat: add receiver, discovery and settings

This commit is contained in:
Tien Do Nam
2022-12-20 00:11:50 +01:00
parent 9f3d3f2508
commit a20b8bd9e8
39 changed files with 1573 additions and 167 deletions
+4
View File
@@ -1,3 +1,7 @@
*.g.dart
*.gen.dart
*.freezed.dart
# Miscellaneous
*.class
*.log
+2 -16
View File
@@ -10,20 +10,6 @@
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
avoid_print: false
use_key_in_widget_constructors: false
+30
View File
@@ -0,0 +1,30 @@
{
"appName": "LocalSend",
"general": {
"advanced": "Advanced",
"hide": "Hide",
"unknown": "Unknown"
},
"receive": {
"title": "Receive",
"advanced": {
"ip": "IP:",
"server": "Server:",
"subnetMask": "Subnet mask:",
"alias": "Alias:"
}
},
"send": {
"title": "Send"
},
"settings": {
"title": "Settings",
"alias": "Alias",
"theme": "Theme",
"themeOptions": {
"system": "System",
"dark": "Dark",
"light": "Light"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+10
View File
@@ -0,0 +1,10 @@
targets:
$default:
builders:
slang_build_runner:
options:
base_locale: en
fallback_strategy: base_locale
input_directory: assets/i18n
input_file_pattern: .i18n.json
output_directory: lib/gen
+4
View File
@@ -47,5 +47,9 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
</dict>
</plist>
+33 -107
View File
@@ -1,118 +1,44 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:localsend_app/constants.dart';
import 'package:localsend_app/service/receiver_service.dart';
import 'package:network_info_plus/network_info_plus.dart';
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/settings_provider.dart';
import 'package:localsend_app/service/persistence_service.dart';
import 'package:localsend_app/theme.dart';
import 'package:window_manager/window_manager.dart';
void main() {
runApp(const MyApp());
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocale();
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
await windowManager.ensureInitialized();
WindowManager.instance.setMinimumSize(const Size(400, 500));
}
final persistenceService = await PersistenceService.initialize();
runApp(ProviderScope(
overrides: [
settingsProvider.overrideWith((ref) => SettingsNotifier(persistenceService)),
],
child: const LocalSendApp(),
));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class LocalSendApp extends ConsumerWidget {
const LocalSendApp();
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(settingsProvider.select((settings) => settings.theme));
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
title: t.appName,
debugShowCheckedModeBanner: false,
theme: getTheme(Brightness.light),
darkTheme: getTheme(Brightness.dark),
themeMode: themeMode,
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage();
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _receiver = ReceiverService();
String? _ip;
String? _mask;
String _targetIp = '';
String? _response;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final info = NetworkInfo();
_ip = await info.getWifiIP();
_mask = await info.getWifiSubmask();
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
children: [
Text('IP: $_ip'),
Text('Mask: $_mask'),
Text('Status: ${_receiver.running}'),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () async {
await _receiver.start();
setState(() {});
},
child: const Text('Start'),
),
const SizedBox(width: 20),
ElevatedButton(
onPressed: () async {
await _receiver.stop();
setState(() {});
},
child: const Text('Stop'),
),
],
),
const SizedBox(height: 20),
TextField(
onChanged: (s) {
setState(() {
_targetIp = s;
});
},
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
try {
final response = await Dio().get('http://$_targetIp:${Constants.defaultPort}/hello');
setState(() {
_response = response.data.toString();
});
} catch (e) {
print(e);
setState(() {
_response = e.toString();
});
}
},
child: const Text('Ping'),
),
const SizedBox(height: 20),
Text('Response: $_response'),
],
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'device.freezed.dart';
part 'device.g.dart';
enum DeviceType {
mobile(Icons.smartphone),
desktop(Icons.computer),
web(Icons.language);
const DeviceType(this.icon);
final IconData icon;
}
@freezed
class Device with _$Device {
const factory Device({
required String ip,
required String alias,
required String? deviceModel,
required DeviceType deviceType,
}) = _Device;
factory Device.fromJson(Map<String, Object?> json) => _$DeviceFromJson(json);
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/device.dart';
part 'info_dto.freezed.dart';
part 'info_dto.g.dart';
@freezed
class InfoDto with _$InfoDto {
const factory InfoDto({
required String alias,
required String? deviceModel,
required DeviceType deviceType,
}) = _InfoDto;
factory InfoDto.fromJson(Map<String, Object?> json) => _$InfoDtoFromJson(json);
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'network_info.freezed.dart';
@freezed
class NetworkInfo with _$NetworkInfo {
const factory NetworkInfo({
required String? localIp,
required String? netMask,
}) = _NetworkInfo;
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings.freezed.dart';
@freezed
class Settings with _$Settings {
const factory Settings({
required String alias,
required ThemeMode theme,
}) = _Settings;
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/pages/tabs/receive_page.dart';
import 'package:localsend_app/pages/tabs/send_page.dart';
import 'package:localsend_app/pages/tabs/settings_page.dart';
enum _Tab {
receive(Icons.wifi),
send(Icons.send),
settings(Icons.settings);
const _Tab(this.icon);
final IconData icon;
String get label {
switch (this) {
case _Tab.receive:
return t.receive.title;
case _Tab.send:
return t.send.title;
case _Tab.settings:
return t.settings.title;
}
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
_Tab _currentTab = _Tab.receive;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: IndexedStack(
index: _currentTab.index,
children: const [
ReceivePage(),
SendPage(),
SettingsPage(),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentTab.index,
onDestinationSelected: (index) {
setState(() {
_currentTab = _Tab.values[index];
});
},
destinations: _Tab.values.map((tab) {
return NavigationDestination(icon: Icon(tab.icon), label: tab.label);
}).toList(),
),
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/constants.dart';
import 'package:localsend_app/gen/assets.gen.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/provider/network_info_provider.dart';
import 'package:localsend_app/provider/receiver_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/ip_helper.dart';
import 'package:localsend_app/widget/rotating_widget.dart';
class ReceivePage extends ConsumerStatefulWidget {
const ReceivePage({Key? key}) : super(key: key);
@override
ConsumerState<ReceivePage> createState() => _ReceivePageState();
}
class _ReceivePageState extends ConsumerState<ReceivePage> {
bool _advanced = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(receiverProvider.notifier).startServer();
});
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final networkInfo = ref.watch(networkInfoProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RotatingWidget(
duration: const Duration(seconds: 10),
child: Assets.img.logo512.image(width: 200),
),
Text(networkInfo?.localIp?.visualId ?? t.general.unknown, style: const TextStyle(fontSize: 48)),
Text(settings.alias, style: const TextStyle(fontSize: 24)),
],
),
),
),
),
Center(
child: TextButton(
onPressed: () {
setState(() => _advanced = !_advanced);
},
child: Text(_advanced ? t.general.hide : t.general.advanced),
),
),
AnimatedCrossFade(
crossFadeState: _advanced ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
firstChild: Container(),
secondChild: Card(
child: Padding(
padding: const EdgeInsets.all(15),
child: Table(
columnWidths: const {
0: IntrinsicColumnWidth(),
1: IntrinsicColumnWidth(),
2: FlexColumnWidth(),
},
children: [
TableRow(
children: [
Text(t.receive.advanced.alias),
const SizedBox(width: 10),
Text(settings.alias),
],
),
TableRow(
children: [
Text(t.receive.advanced.ip),
const SizedBox(width: 10),
Text(networkInfo?.localIp ?? t.general.unknown),
],
),
TableRow(
children: [
Text(t.receive.advanced.server),
const SizedBox(width: 10),
Text(networkInfo?.localIp == null ? t.general.unknown : '${networkInfo!.localIp}:${Constants.defaultPort}/localsend'),
],
),
TableRow(
children: [
Text(t.receive.advanced.subnetMask),
const SizedBox(width: 10),
Text(networkInfo?.netMask ?? t.general.unknown),
],
),
],
),
),
),
),
const SizedBox(height: 15),
],
),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/provider/nearby_devices_provider.dart';
import 'package:localsend_app/widget/device_widget.dart';
class SendPage extends ConsumerStatefulWidget {
const SendPage({Key? key}) : super(key: key);
@override
ConsumerState<SendPage> createState() => _SendPageState();
}
class _SendPageState extends ConsumerState<SendPage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final devices = ref.watch(nearbyDevicesProvider);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 30),
children: [
...devices.when(
data: (data) {
return data.map((device) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DeviceWidget(device: device),
);
});
},
error: (e, st) {
return [
Center(
child: Text(e.toString()),
),
];
},
loading: () {
return [
const Center(
child: CircularProgressIndicator(),
),
];
},
),
],
);
}
}
+84
View File
@@ -0,0 +1,84 @@
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/settings_provider.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({Key? key}) : super(key: key);
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 30),
children: [
_SettingsEntry(
label: t.settings.alias,
child: SizedBox(
width: 200,
child: TextField(
onChanged: (s) {},
),
),
),
_SettingsEntry(
label: t.settings.theme,
child: DropdownButton<ThemeMode>(
value: settings.theme,
items: ThemeMode.values.map((theme) {
return DropdownMenuItem(
value: theme,
child: Text(theme.humanName),
);
}).toList(),
onChanged: (theme) {
if (theme != null) {
ref.read(settingsProvider.notifier).setTheme(theme);
}
},
),
),
],
);
}
}
class _SettingsEntry extends StatelessWidget {
final String label;
final Widget child;
const _SettingsEntry({required this.label, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
children: [
Expanded(
child: Text(label),
),
child,
],
),
);
}
}
extension on ThemeMode {
String get humanName {
switch (this) {
case ThemeMode.system:
return t.settings.themeOptions.system;
case ThemeMode.light:
return t.settings.themeOptions.light;
case ThemeMode.dark:
return t.settings.themeOptions.dark;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/provider/network_info_provider.dart';
import 'package:localsend_app/service/polling_service.dart';
final nearbyDevicesProvider = StreamProvider.autoDispose<List<Device>>((ref) {
final networkInfo = ref.watch(networkInfoProvider)!;
final localIp = networkInfo.localIp;
if (localIp == null) {
return Stream.value([]);
}
return PollingService(localIp.split('.').take(3).join('.')).startPolling();
});
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/model/network_info.dart';
import 'package:network_info_plus/network_info_plus.dart' as plugin;
final networkInfoProvider = StateNotifierProvider<NetworkInfoNotifier, NetworkInfo?>((ref) => NetworkInfoNotifier());
class NetworkInfoNotifier extends StateNotifier<NetworkInfo?> {
NetworkInfoNotifier() : super(null) {
_init();
}
Future<void> _init() async {
final info = plugin.NetworkInfo();
String? ip;
String? mask;
try {
ip = await info.getWifiIP();
mask = await info.getWifiSubmask();
} catch (e) {
print(e);
}
state = NetworkInfo(
localIp: ip,
netMask: mask,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/constants.dart';
import 'package:localsend_app/model/dto/info_dto.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/device_info_helper.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';
final receiverProvider = StateNotifierProvider((ref) {
final alias = ref.watch(settingsProvider.select((settings) => settings.alias));
return ReceiverNotifier(alias);
});
class ReceiverNotifier extends StateNotifier<HttpServer?> {
final String alias;
ReceiverNotifier(this.alias) : super(null);
Future<void> startServer() async {
if (state != null) {
print('Server already running.');
return;
}
final app = Router();
final deviceInfo = await getDeviceInfo();
app.get('/localsend/v1/info', (Request request) {
final dto = InfoDto(
alias: alias,
deviceModel: deviceInfo.deviceModel,
deviceType: deviceInfo.deviceType,
);
return Response.ok(jsonEncode(dto.toJson()), headers: {'Content-Type': 'application/json'});
});
app.get('/user/<user>', (Request request, String user) {
return Response.ok('hello $user');
});
print('Starting server...');
state = await serve(app, '0.0.0.0', Constants.defaultPort);
print('Server started. (Port: ${state?.port})');
}
Future<void> stop() async {
await state?.close(force: true);
state = null;
print('Server stopped.');
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/model/settings.dart';
import 'package:localsend_app/service/persistence_service.dart';
final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>((ref) {
throw Exception('settingsProvider not initialized');
});
class SettingsNotifier extends StateNotifier<Settings> {
final PersistenceService _service;
SettingsNotifier(this._service) : super(_loadFromPersistence(_service));
static Settings _loadFromPersistence(PersistenceService service) {
return Settings(
alias: service.getAlias(),
theme: service.getTheme(),
);
}
Future<void> setAlias(String alias) async {
await _service.setAlias(alias);
state = state.copyWith(
alias: alias,
);
}
Future<void> setTheme(ThemeMode theme) async {
await _service.setTheme(theme);
state = state.copyWith(
theme: theme,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:localsend_app/util/alias_generator.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _aliasKey = 'alias';
const _themeKey = 'theme';
/// This service abstracts the persistence layer.
class PersistenceService {
final SharedPreferences _prefs;
PersistenceService._(this._prefs);
static Future<PersistenceService> initialize() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getString(_aliasKey) == null) {
await prefs.setString(_aliasKey, generateRandomAlias());
}
if (prefs.getString(_themeKey) == null) {
await prefs.setString(_themeKey, ThemeMode.system.name);
}
return PersistenceService._(prefs);
}
String getAlias() {
return _prefs.getString(_aliasKey) ?? generateRandomAlias();
}
Future<void> setAlias(String alias) async {
await _prefs.setString(_aliasKey, alias);
}
ThemeMode getTheme() {
final value = _prefs.getString(_themeKey);
if (value == null) {
return ThemeMode.system;
}
return ThemeMode.values.firstWhereOrNull((theme) => theme.name == value) ?? ThemeMode.system;
}
Future<void> setTheme(ThemeMode theme) async {
await _prefs.setString(_themeKey, theme.name);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:dio/dio.dart';
import 'package:localsend_app/constants.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/dto/info_dto.dart';
import 'package:localsend_app/util/sleep.dart';
import 'package:localsend_app/util/task_runner.dart';
final _dio = Dio(
BaseOptions(
connectTimeout: 2000,
sendTimeout: 2000,
),
);
const _concurrentRequests = 50;
class PollingService {
final List<String> _possibleIps;
int _requestCount = 0;
bool _running = false;
PollingService(String ipPrefix) : _possibleIps = List.generate(256, (i) => '$ipPrefix.$i');
Stream<List<Device>> startPolling() async* {
if (_running) {
print('Already running poll.');
return;
}
_running = true;
final deviceMap = <String, Device>{}; // ip -> device
final runner = TaskRunner<int, _RunnerResult>(
task: _doRequest,
maxConcurrentTasks: _concurrentRequests,
);
runner.addAll(List.generate(_concurrentRequests, (i) => i));
await for (final result in runner.stream) {
final device = result.device;
if (device != null) {
deviceMap[device.ip] = device;
yield deviceMap.values.toList();
}
if (_requestCount >= 1024) {
_running = false;
}
if (_running) {
_requestCount++;
// print('#$_requestCount');
runner.add((result.index + _concurrentRequests) % 256);
}
}
}
Future<_RunnerResult> _doRequest(int index) async {
if (_requestCount > 512) {
await sleepAsync(2000);
} else if (_requestCount > 256) {
await sleepAsync(1000);
}
final String currentIp = _possibleIps[index];
// print('Requesting $currentIp');
final url = 'http://$currentIp:${Constants.defaultPort}/localsend/v1/info';
Device? device;
try {
final response = await _dio.get(url);
final dto = InfoDto.fromJson(response.data);
device = Device(
ip: currentIp,
alias: dto.alias,
deviceModel: dto.deviceModel,
deviceType: dto.deviceType,
);
} on DioError catch (e) {
device = null;
// print('$url: ${e.error}');
} catch (e) {
device = null;
// print(e);
}
return _RunnerResult(index, device);
}
void cancel() {
_running = true;
}
}
class _RunnerResult {
final int index;
final Device? device;
_RunnerResult(this.index, this.device);
}
-41
View File
@@ -1,41 +0,0 @@
import 'dart:io';
import 'package:localsend_app/constants.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
class ReceiverService {
HttpServer? _server;
ReceiverService();
Future<void> start() async {
if (_server != null) {
print('Server already running.');
return;
}
final app = Router();
app.get('/hello', (Request request) {
return Response.ok('hello-world');
});
app.get('/user/<user>', (Request request, String user) {
return Response.ok('hello $user');
});
print('Starting server...');
_server = await io.serve(app, '0.0.0.0', Constants.defaultPort);
print('Server started. (Port: ${_server?.port})');
}
Future<void> stop() async {
await _server?.close(force: true);
_server = null;
print('Server stopped.');
}
bool get running => _server != null;
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
ThemeData getTheme(Brightness brightness) {
return ThemeData(
brightness: brightness,
primarySwatch: Colors.teal,
useMaterial3: true,
);
}
+11
View File
@@ -0,0 +1,11 @@
import 'dart:math';
const _adj = ['Fast', 'Slow', 'Big', 'Small', 'Good', 'Bad'];
const _fruit = ['Apple', 'Banana', 'Cherry', 'Coconut', 'Lemon', 'Orange'];
String generateRandomAlias() {
final random = Random();
return '${_adj[random.nextInt(_adj.length)]} ${_fruit[random.nextInt(_adj.length)]}';
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:localsend_app/model/device.dart';
import 'package:slang/builder/model/enums.dart';
import 'package:slang/builder/utils/string_extensions.dart';
class DeviceInfoResult {
final DeviceType deviceType;
final String? deviceModel;
DeviceInfoResult(this.deviceType, this.deviceModel);
}
Future<DeviceInfoResult> getDeviceInfo() async {
final plugin = DeviceInfoPlugin();
final DeviceType deviceType;
final String? deviceModel;
if (kIsWeb) {
deviceType = DeviceType.web;
final deviceInfo = await plugin.webBrowserInfo;
deviceModel = deviceInfo.browserName.humanName;
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
deviceType = DeviceType.mobile;
break;
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.fuchsia:
deviceType = DeviceType.desktop;
break;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
final deviceInfo = await plugin.androidInfo;
deviceModel = deviceInfo.brand.toCase(CaseStyle.pascal);
break;
case TargetPlatform.iOS:
final deviceInfo = await plugin.iosInfo;
deviceModel = deviceInfo.localizedModel;
break;
case TargetPlatform.linux:
final info = await plugin.linuxInfo;
deviceModel = info.name;
break;
case TargetPlatform.macOS:
deviceModel = 'MacOS';
break;
case TargetPlatform.windows:
deviceModel = 'Windows';
break;
case TargetPlatform.fuchsia:
deviceModel = 'Fuchsia';
break;
}
}
return DeviceInfoResult(deviceType, deviceModel);
}
extension on BrowserName {
String? get humanName {
switch (this) {
case BrowserName.firefox:
return 'Firefox';
case BrowserName.samsungInternet:
return 'Samsung Internet';
case BrowserName.opera:
return 'Opera';
case BrowserName.msie:
return 'Internet Explorer';
case BrowserName.edge:
return 'Microsoft Edge';
case BrowserName.chrome:
return 'Google Chrome';
case BrowserName.safari:
return 'Safari';
case BrowserName.unknown:
return null;
}
}
}
+3
View File
@@ -0,0 +1,3 @@
extension StringIpExt on String {
String get visualId => split('.').last;
}
+3
View File
@@ -0,0 +1,3 @@
Future<void> sleepAsync(int millis) {
return Future.delayed(Duration(milliseconds: millis), () {});
}
+49
View File
@@ -0,0 +1,49 @@
import 'dart:async';
import 'dart:collection';
// https://stackoverflow.com/questions/62878704/how-to-implement-an-async-task-queue-with-multiple-concurrent-workers-async-in
class TaskRunner<A, B> {
final Queue<A> _input = Queue();
final StreamController<B> _streamController = StreamController();
final Future<B> Function(A) task;
final int maxConcurrentTasks;
int runningTasks = 0;
TaskRunner({
required this.task,
required this.maxConcurrentTasks,
});
Stream<B> get stream => _streamController.stream;
void add(A value) {
_input.add(value);
_startExecution();
}
void addAll(Iterable<A> iterable) {
_input.addAll(iterable);
_startExecution();
}
void _startExecution() {
if (runningTasks == maxConcurrentTasks || _input.isEmpty) {
return;
}
while (_input.isNotEmpty && runningTasks < maxConcurrentTasks) {
runningTasks++;
task(_input.removeFirst()).then((value) async {
_streamController.add(value);
while (_input.isNotEmpty) {
_streamController.add(await task(_input.removeFirst()));
}
runningTasks--;
});
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/util/ip_helper.dart';
class DeviceWidget extends StatelessWidget {
final Device device;
const DeviceWidget({required this.device});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
children: [
Icon(device.deviceType.icon, size: 46),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(device.alias, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 5),
Wrap(
runSpacing: 10,
spacing: 10,
children: [
_Badge(
color: Theme.of(context).colorScheme.tertiaryContainer,
label: '#${device.ip.visualId}',
),
if (device.deviceModel != null)
_Badge(
color: Theme.of(context).colorScheme.tertiaryContainer,
label: device.deviceModel!,
),
],
),
],
),
],
),
),
);
}
}
class _Badge extends StatelessWidget {
final Color color;
final String label;
const _Badge({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(5),
),
child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onInverseSurface)),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class RotatingWidget extends StatefulWidget {
final Duration duration;
final Widget child;
const RotatingWidget({required this.duration, required this.child, super.key});
@override
State<RotatingWidget> createState() => _RotatingWidgetState();
}
class _RotatingWidgetState extends State<RotatingWidget> with TickerProviderStateMixin{
late final AnimationController _controller = AnimationController(
duration: widget.duration,
vsync: this,
)..repeat();
late final Animation<double> _animation = CurvedAnimation(
parent: _controller,
curve: Curves.linear,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _animation,
child: widget.child,
);
}
}
@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <screen_retriever/screen_retriever_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}
+2
View File
@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,8 +5,16 @@
import FlutterMacOS
import Foundation
import device_info_plus
import network_info_plus
import screen_retriever
import shared_preferences_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+520 -2
View File
@@ -1,6 +1,20 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "50.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
args:
dependency: transitive
description:
@@ -15,6 +29,62 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.0"
build:
dependency: transitive
description:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.1"
build_config:
dependency: transitive
description:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
build_daemon:
dependency: transitive
description:
name: build_daemon
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
build_runner:
dependency: "direct dev"
description:
name: build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.3"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
url: "https://pub.dartlang.org"
source: hosted
version: "7.2.7"
built_collection:
dependency: transitive
description:
name: built_collection
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.4.2"
characters:
dependency: transitive
description:
@@ -22,13 +92,76 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
collection:
checked_yaml:
dependency: transitive
description:
name: checked_yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
code_builder:
dependency: transitive
description:
name: code_builder
url: "https://pub.dartlang.org"
source: hosted
version: "4.3.0"
collection:
dependency: "direct main"
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
color:
dependency: transitive
description:
name: color
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.2"
csv:
dependency: transitive
description:
name: csv
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.1"
dart_style:
dependency: transitive
description:
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.4"
dartx:
dependency: transitive
description:
name: dartx
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
dbus:
dependency: transitive
description:
@@ -36,6 +169,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.0"
dio:
dependency: "direct main"
description:
@@ -50,11 +197,39 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
fixnum:
dependency: transitive
description:
name: fixnum
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_gen_core:
dependency: transitive
description:
name: flutter_gen_core
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0+1"
flutter_gen_runner:
dependency: "direct dev"
description:
name: flutter_gen_runner
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0+1"
flutter_lints:
dependency: "direct dev"
description:
@@ -62,11 +237,53 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.2"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
graphs:
dependency: transitive
description:
name: graphs
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
http_methods:
dependency: transitive
description:
@@ -74,6 +291,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1"
http_parser:
dependency: transitive
description:
@@ -81,6 +305,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.2"
io:
dependency: transitive
description:
name: io
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
js:
dependency: transitive
description:
@@ -88,6 +319,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.4"
json2yaml:
dependency: transitive
description:
name: json2yaml
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.7.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "6.5.4"
lints:
dependency: transitive
description:
@@ -95,6 +347,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.14"
material_color_utilities:
dependency: transitive
description:
@@ -109,6 +375,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
network_info_plus:
dependency: "direct main"
description:
@@ -130,6 +403,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
@@ -137,6 +417,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.7"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
petitparser:
dependency: transitive
description:
@@ -144,6 +445,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
plugin_platform_interface:
dependency: transitive
description:
@@ -151,6 +459,104 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
pool:
dependency: transitive
description:
name: pool
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.1"
process:
dependency: transitive
description:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.4"
pub_semver:
dependency: transitive
description:
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
riverpod:
dependency: transitive
description:
name: riverpod
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
screen_retriever:
dependency: transitive
description:
name: screen_retriever
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.4"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.15"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.14"
shared_preferences_ios:
dependency: transitive
description:
name: shared_preferences_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
shelf:
dependency: "direct main"
description:
@@ -165,11 +571,53 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
slang:
dependency: "direct main"
description:
name: slang
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
slang_build_runner:
dependency: "direct dev"
description:
name: slang_build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
slang_flutter:
dependency: "direct main"
description:
name: slang_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.6"
source_helper:
dependency: transitive
description:
name: source_helper
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.3"
source_span:
dependency: transitive
description:
@@ -184,6 +632,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
state_notifier:
dependency: transitive
description:
name: state_notifier
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.2+1"
stream_channel:
dependency: transitive
description:
@@ -191,6 +646,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
stream_transform:
dependency: transitive
description:
name: stream_transform
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
@@ -205,6 +667,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
time:
dependency: transitive
description:
name: time
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
timing:
dependency: transitive
description:
name: timing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
typed_data:
dependency: transitive
description:
@@ -219,6 +695,41 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.3"
window_manager:
dependency: "direct main"
description:
name: window_manager
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.8"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0+2"
xml:
dependency: transitive
description:
@@ -226,6 +737,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.0"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.18.5 <3.0.0"
flutter: ">=2.11.0"
flutter: ">=3.0.0"
+17
View File
@@ -9,15 +9,32 @@ environment:
sdk: '>=2.18.5 <3.0.0'
dependencies:
collection: 1.16.0
device_info_plus: 8.0.0
dio: 4.0.6
flutter:
sdk: flutter
network_info_plus: 3.0.1
flutter_riverpod: 2.1.1
freezed_annotation: 2.2.0
json_annotation: 4.7.0
shared_preferences: 2.0.15
shelf: 1.4.0
shelf_router: 1.1.3
slang: 3.7.0
slang_flutter: 3.7.0
window_manager: 0.2.8
dev_dependencies:
build_runner: 2.3.3
flutter_gen_runner: 5.1.0+1
flutter_lints: 2.0.1
freezed: 2.3.2
json_serializable: 6.5.4
slang_build_runner: 3.7.0
flutter:
uses-material-design: true
assets:
- assets/img/
@@ -7,8 +7,14 @@
#include "generated_plugin_registrant.h"
#include <network_info_plus/network_info_plus_windows_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
NetworkInfoPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("NetworkInfoPlusWindowsPlugin"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
+2
View File
@@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
network_info_plus
screen_retriever
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.CreateAndShow(L"localsend_app", origin, size)) {
if (!window.CreateAndShow(L"LocalSend", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB