feat(lib, android, ios): permissions clean up, add id to files pack

This commit is contained in:
ksenia312
2024-02-07 13:30:34 +01:00
parent 2541aa57d2
commit f18065db49
20 changed files with 313 additions and 220 deletions
@@ -1,38 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- to write files to storage after receiving them -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:label="nearby_service_example_full"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="nearby_service_example_full">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
+4
View File
@@ -22,12 +22,16 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Permission to use the photo library is required to send the photos</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
+26 -4
View File
@@ -24,6 +24,8 @@ class AppService extends ChangeNotifier {
StreamSubscription? _connectedDeviceSubscription;
StreamSubscription? _connectionInfoSubscription;
final filesLoadings = <String, int>{};
@override
void dispose() {
stopListeningAll();
@@ -85,7 +87,6 @@ class AppService extends ChangeNotifier {
if (result ?? false) {
updateState(AppState.readyToDiscover);
startListeningConnectionInfo();
await getCurrentDeviceInfo();
return true;
}
return false;
@@ -102,6 +103,7 @@ class AppService extends ChangeNotifier {
Future<void> discover() async {
try {
await getCurrentDeviceInfo();
final result = await _nearbyService.discover();
if (result) {
updateState(AppState.discoveringPeers);
@@ -287,7 +289,11 @@ extension CommunicationChannelExtension on AppService {
onData: (event) async {
final files = await FilesSaver.savePack(event);
onFilesSaved?.call(
ReceivedNearbyFilesPack(sender: event.sender, files: files),
ReceivedNearbyFilesPack(
id: event.id,
sender: event.sender,
files: files,
),
);
},
);
@@ -349,16 +355,32 @@ extension MessagingExtension on AppService {
);
}
void sendFilesResponse(String requestId, {required bool isAccepted}) {
void sendFilesResponse(
NearbyMessageFilesRequest request, {
required bool isAccepted,
}) {
if (connectedDevice == null) return;
_nearbyService.send(
OutgoingNearbyMessage(
receiver: connectedDevice!.info,
content: NearbyMessageFilesResponse(
id: requestId,
id: request.id,
isAccepted: isAccepted,
),
),
);
if (isAccepted) {
setFilesLoading(request);
}
}
void setFilesLoading(NearbyMessageFilesRequest request) {
filesLoadings[request.id] = request.files.length;
_notify();
}
void endFilesLoading(ReceivedNearbyFilesPack pack) {
filesLoadings.remove(pack.id);
_notify();
}
}
+1
View File
@@ -8,6 +8,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final service = AppService();
await service.getPlatformInfo();
runApp(
App(service: service),
);
+20 -8
View File
@@ -12,20 +12,28 @@ const kWhiteColor = Color(0xFFFFFFFF);
const kGreyColor = Color(0xFF607D8B);
const kGreenColor = Color(0xFF07B988);
class App extends StatelessWidget {
class App extends StatefulWidget {
const App({super.key, required this.service});
final AppService service;
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
final scaffoldKey = GlobalKey<ScaffoldState>();
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: service,
value: widget.service,
child: MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kBlueColor),
),
home: Scaffold(
key: scaffoldKey,
appBar: AppBar(
title: const Text('Nearby service example app'),
actions: [
@@ -37,7 +45,7 @@ class App extends StatelessWidget {
}),
],
),
body: const _AppBody(),
body: _AppBody(scaffoldKey),
),
),
);
@@ -45,7 +53,9 @@ class App extends StatelessWidget {
}
class _AppBody extends StatelessWidget {
const _AppBody();
const _AppBody(this.scaffoldKey);
final GlobalKey<ScaffoldState> scaffoldKey;
@override
Widget build(BuildContext context) {
@@ -72,15 +82,17 @@ class _AppBody extends StatelessWidget {
final builder = AppStepViewBuilder(state: e);
final isActive = e == state;
return Step(
isActive: isActive,
title: builder.buildTitle(),
subtitle: builder.buildSubtitle(),
content: builder.buildContent(
scaffoldKey: scaffoldKey,
),
state: isActive
? StepState.indexed
: e.step < state.step
? StepState.complete
: StepState.disabled,
title: builder.buildTitle(),
subtitle: builder.buildSubtitle(),
content: builder.buildContent(),
isActive: isActive,
);
},
),
@@ -7,7 +7,9 @@ class AppStepViewBuilder {
final AppState state;
Widget buildContent() {
Widget buildContent({
required GlobalKey<ScaffoldState> scaffoldKey,
}) {
return switch (state) {
(AppState.idle) => const IdleView(),
(AppState.permissions) => const PermissionsView(),
@@ -19,7 +21,9 @@ class AppStepViewBuilder {
(AppState.loadingConnection) => const Center(
child: CircularProgressIndicator.adaptive(),
),
(AppState.connected) => const ConnectedView(),
(AppState.connected) => ConnectedView(
scaffoldKey: scaffoldKey,
),
(AppState.communicationChannelCreated) => const CommunicationView(),
};
}
@@ -0,0 +1,46 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:nearby_service_example_full/presentation/app.dart';
import 'package:nearby_service_example_full/utils/files_saver.dart';
class FilePreview extends StatelessWidget {
const FilePreview({super.key, required this.file});
final PlatformFile file;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: kGreenColor,
width: 2,
strokeAlign: 1,
),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: FilesSaver.isImage(file.extension)
? Image.file(
File(file.path!),
fit: BoxFit.cover,
)
: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
style: const TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: kGreenColor,
),
),
),
),
);
}
}
@@ -1,15 +1,21 @@
import 'package:flutter/material.dart';
import 'package:nearby_service/nearby_service.dart';
import 'package:nearby_service_example_full/domain/app_service.dart';
import 'package:nearby_service_example_full/uikit/uikit.dart';
class FilesListener {
FilesListener._();
static void call(BuildContext context, ReceivedNearbyFilesPack pack) {
static void call(
BuildContext context, {
required AppService service,
required ReceivedNearbyFilesPack pack,
}) {
service.endFilesLoading(pack);
final senderSubtitle = 'From ${pack.sender.displayName} '
'(ID: ${pack.sender.id})';
AppShackBar.show(
Scaffold.of(context).context,
context,
'${pack.files.length} files saved! \n${pack.files.map((e) => e.name).join('\n')}',
subtitle: senderSubtitle,
);
@@ -2,28 +2,31 @@ import 'package:flutter/material.dart';
import 'package:nearby_service/nearby_service.dart';
import 'package:nearby_service_example_full/domain/app_service.dart';
import 'package:nearby_service_example_full/uikit/uikit.dart';
import 'package:provider/provider.dart';
class MessagesListener {
MessagesListener._();
static void call(BuildContext context, ReceivedNearbyMessage message) {
static void call(
BuildContext context, {
required AppService service,
required ReceivedNearbyMessage message,
}) {
final senderSubtitle = 'From ${message.sender.displayName} '
'(ID: ${message.sender.id})';
message.content.byType(
onTextRequest: (textRequest) {
AppShackBar.show(
Scaffold.of(context).context,
context,
'${textRequest.value} (Message ID=${textRequest.id})',
subtitle: senderSubtitle,
)?.closed.whenComplete(() {
// mark as read
context.read<AppService>().sendTextResponse(textRequest.id);
service.sendTextResponse(textRequest.id);
});
},
onTextResponse: (textResponse) {
AppShackBar.show(
Scaffold.of(context).context,
context,
'Your message with ID=${textResponse.id} was delivered to ${message.sender.displayName}',
);
},
@@ -34,15 +37,15 @@ class MessagesListener {
subtitle: senderSubtitle,
).then(
// accept or dismiss the files request
(isAccepted) => context.read<AppService>().sendFilesResponse(
filesRequest.id,
isAccepted: isAccepted ?? false,
),
(isAccepted) => service.sendFilesResponse(
filesRequest,
isAccepted: isAccepted ?? false,
),
);
},
onFilesResponse: (filesResponse) {
AppShackBar.show(
Scaffold.of(context).context,
context,
filesResponse.isAccepted
? 'Request is accepted!'
: 'Request was denied :(',
@@ -1,12 +1,9 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:nearby_service/nearby_service.dart';
import 'package:nearby_service_example_full/domain/app_service.dart';
import 'package:nearby_service_example_full/presentation/app.dart';
import 'package:nearby_service_example_full/presentation/components/file_preview.dart';
import 'package:nearby_service_example_full/uikit/uikit.dart';
import 'package:nearby_service_example_full/utils/files_saver.dart';
import 'package:provider/provider.dart';
import '../components/device_preview.dart';
@@ -24,143 +21,126 @@ class _CommunicationViewState extends State<CommunicationView> {
@override
Widget build(BuildContext context) {
return Selector<AppService, NearbyDevice?>(
selector: (context, service) => service.connectedDevice,
builder: (context, device, _) {
if (device == null) {
return Center(
child: ActionButton(
onTap: context.read<AppService>().stopListeningAll,
title: 'Restart',
),
);
}
final inputBorder = OutlineInputBorder(
borderSide: const BorderSide(color: kGreenColor),
borderRadius: BorderRadius.circular(32),
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DevicePreview(device: device, largeView: true),
const SizedBox(height: 10),
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: TextField(
onChanged: (value) => setState(() {
message = value;
}),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
enabledBorder: inputBorder,
border: inputBorder,
focusedBorder: inputBorder,
hintStyle: const TextStyle(color: kGreenColor),
hintText: 'Enter a message',
final service = context.watch<AppService>();
final filesLoadings = service.filesLoadings;
final device = service.connectedDevice;
if (device == null) {
return Center(
child: ActionButton(
onTap: context.read<AppService>().stopListeningAll,
title: 'Restart',
),
);
}
final inputBorder = OutlineInputBorder(
borderSide: const BorderSide(color: kGreenColor),
borderRadius: BorderRadius.circular(32),
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DevicePreview(device: device, largeView: true),
const SizedBox(height: 10),
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: TextField(
onChanged: (value) => setState(() {
message = value;
}),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
enabledBorder: inputBorder,
border: inputBorder,
focusedBorder: inputBorder,
hintStyle: const TextStyle(color: kGreenColor),
hintText: 'Enter a message',
),
),
),
Flexible(
child: ActionButton(
title: 'Send',
onTap: () => context.read<AppService>().sendTextRequest(
message,
),
),
),
Flexible(
child: ActionButton(
title: 'Send',
onTap: () => context.read<AppService>().sendTextRequest(
message,
),
),
),
],
),
),
),
const SizedBox(height: 10),
const Text('OR'),
const SizedBox(height: 10),
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: ActionButton(
type: ActionType.success,
title: 'Choose files',
onTap: () async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
);
setState(() {
files = [...?result?.files];
});
},
),
),
Flexible(
child: ActionButton(
title: 'Send',
onTap: () => context.read<AppService>().sendFilesRequest([
...files
.map((e) => e.path)
.where((element) => element != null)
.cast<String>(),
]),
),
),
],
],
),
),
const SizedBox(height: 10),
const Text('OR'),
const SizedBox(height: 10),
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: ActionButton(
type: ActionType.success,
title: 'Choose files',
onTap: () async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
);
setState(() {
files = [...?result?.files];
});
},
),
),
),
const SizedBox(height: 10),
const Text('Selected files:', style: TextStyle(fontSize: 18)),
Flexible(
child: GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
physics: const NeverScrollableScrollPhysics(),
children: [
...files.where((element) => element.path != null).map(
(e) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: kGreenColor),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(11),
child: FilesSaver.isImage(e.extension)
? Image.file(
File(e.path!),
fit: BoxFit.cover,
)
: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(8.0),
child: Text(
e.name,
style: const TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: kGreenColor,
),
),
),
),
);
},
),
],
Flexible(
child: ActionButton(
title: 'Send',
onTap: () => context.read<AppService>().sendFilesRequest([
...files
.map((e) => e.path)
.where((element) => element != null)
.cast<String>(),
]),
),
),
),
],
),
),
const SizedBox(height: 10),
Column(
children: [
...filesLoadings.entries.map((entry) {
return Text(
'Files pack ${entry.key} with ${entry.value} files is loading.',
);
}),
],
);
},
),
const SizedBox(height: 10),
const Text('Selected files:', style: TextStyle(fontSize: 18)),
Flexible(
child: GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
physics: const NeverScrollableScrollPhysics(),
children: [
...files.where((element) => element.path != null).map(
(e) {
return FilePreview(file: e);
},
),
],
),
),
],
);
}
}
@@ -9,7 +9,9 @@ import 'package:provider/provider.dart';
import '../components/device_preview.dart';
class ConnectedView extends StatelessWidget {
const ConnectedView({super.key});
const ConnectedView({super.key, required this.scaffoldKey});
final GlobalKey<ScaffoldState> scaffoldKey;
@override
Widget build(BuildContext context) {
@@ -23,6 +25,8 @@ class ConnectedView extends StatelessWidget {
context.select<AppService, CommunicationChannelState>(
(service) => service.communicationChannelState,
);
final service = context.read<AppService>();
return device != null
? Center(
child: Column(
@@ -43,7 +47,7 @@ class ConnectedView extends StatelessWidget {
const Text('Connection lost'),
ActionButton(
onTap: () {
context.read<AppService>().connect(device);
service.connect(device);
},
title: 'Reconnect',
),
@@ -55,17 +59,24 @@ class ConnectedView extends StatelessWidget {
if (!communicationChannelState.isLoading)
ActionButton(
title: 'Start communicate',
onTap: () =>
context.read<AppService>().startCommunicationChannel(
listener: (event) => MessagesListener.call(
context,
event,
),
onFilesSaved: (files) => FilesListener.call(
context,
files,
),
),
onTap: () {
service.startCommunicationChannel(
listener: (event) {
MessagesListener.call(
scaffoldKey.currentState!.context,
service: service,
message: event,
);
},
onFilesSaved: (pack) {
FilesListener.call(
scaffoldKey.currentState!.context,
service: service,
pack: pack,
);
},
);
},
)
else
Text(