Fix some auth and attachment issues

This commit is contained in:
Nutcake 2023-05-25 15:50:38 +02:00
parent 358e8490bc
commit 730de37b78
18 changed files with 211 additions and 343 deletions

View file

@ -6,7 +6,7 @@ import 'package:contacts_plus_plus/models/friend.dart';
class FriendApi { class FriendApi {
static Future<List<Friend>> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async { static Future<List<Friend>> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async {
final response = await client.get("/users/${client.userId}/friends${lastStatusUpdate != null ? "?lastStatusUpdate=${lastStatusUpdate.toUtc().toIso8601String()}" : ""}"); final response = await client.get("/users/${client.userId}/friends${lastStatusUpdate != null ? "?lastStatusUpdate=${lastStatusUpdate.toUtc().toIso8601String()}" : ""}");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => Friend.fromMap(e)).toList(); return data.map((e) => Friend.fromMap(e)).toList();
} }

View file

@ -13,7 +13,7 @@ class MessageApi {
"${userId.isEmpty ? "" : "&user=$userId"}" "${userId.isEmpty ? "" : "&user=$userId"}"
"&unread=$unreadOnly" "&unread=$unreadOnly"
); );
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => Message.fromMap(e)).toList(); return data.map((e) => Message.fromMap(e)).toList();
} }

View file

@ -19,7 +19,7 @@ import 'package:path/path.dart';
class RecordApi { class RecordApi {
static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async { static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async {
final response = await client.get("/users/${client.userId}/records?path=$path"); final response = await client.get("/users/${client.userId}/records?path=$path");
ApiClient.checkResponse(response); client.checkResponse(response);
final body = jsonDecode(response.body) as List; final body = jsonDecode(response.body) as List;
return body.map((e) => Record.fromMap(e)).toList(); return body.map((e) => Record.fromMap(e)).toList();
} }
@ -28,7 +28,7 @@ class RecordApi {
final body = jsonEncode(record.toMap()); final body = jsonEncode(record.toMap());
final response = await client.post( final response = await client.post(
"/users/${record.ownerId}/records/${record.id}/preprocess", body: body); "/users/${record.ownerId}/records/${record.id}/preprocess", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
final resultBody = jsonDecode(response.body); final resultBody = jsonDecode(response.body);
return PreprocessStatus.fromMap(resultBody); return PreprocessStatus.fromMap(resultBody);
} }
@ -38,7 +38,7 @@ class RecordApi {
final response = await client.get( final response = await client.get(
"/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}" "/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}"
); );
ApiClient.checkResponse(response); client.checkResponse(response);
final body = jsonDecode(response.body); final body = jsonDecode(response.body);
return PreprocessStatus.fromMap(body); return PreprocessStatus.fromMap(body);
} }
@ -58,7 +58,7 @@ class RecordApi {
static Future<AssetUploadData> beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { static Future<AssetUploadData> beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async {
final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks"); final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks");
ApiClient.checkResponse(response); client.checkResponse(response);
final body = jsonDecode(response.body); final body = jsonDecode(response.body);
final res = AssetUploadData.fromMap(body); final res = AssetUploadData.fromMap(body);
if (res.uploadState == UploadState.failed) throw body; if (res.uploadState == UploadState.failed) throw body;
@ -68,7 +68,7 @@ class RecordApi {
static Future<void> upsertRecord(ApiClient client, {required Record record}) async { static Future<void> upsertRecord(ApiClient client, {required Record record}) async {
final body = jsonEncode(record.toMap()); final body = jsonEncode(record.toMap());
final response = await client.put("/users/${client.userId}/records/${record.id}", body: body); final response = await client.put("/users/${client.userId}/records/${record.id}", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> uploadAsset(ApiClient client, static Future<void> uploadAsset(ApiClient client,
@ -87,14 +87,14 @@ class RecordApi {
..headers.addAll(client.authorizationHeader); ..headers.addAll(client.authorizationHeader);
final response = await request.send(); final response = await request.send();
final bodyBytes = await response.stream.toBytes(); final bodyBytes = await response.stream.toBytes();
ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); client.checkResponse(http.Response.bytes(bodyBytes, response.statusCode));
progressCallback?.call(1); progressCallback?.call(1);
} }
} }
static Future<void> finishUpload(ApiClient client, {required NeosDBAsset asset}) async { static Future<void> finishUpload(ApiClient client, {required NeosDBAsset asset}) async {
final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks"); final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks");
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> uploadAssets(ApiClient client, {required List<AssetDigest> assets, void Function(double progress)? progressCallback}) async { static Future<void> uploadAssets(ApiClient client, {required List<AssetDigest> assets, void Function(double progress)? progressCallback}) async {
@ -123,14 +123,14 @@ class RecordApi {
progressCallback?.call(0); progressCallback?.call(0);
final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path)); final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path));
final imageData = await decodeImageFromList(imageDigest.data); final imageData = await decodeImageFromList(imageDigest.data);
final filename = basenameWithoutExtension(image.path);
final objectJson = jsonEncode( final objectJson = jsonEncode(
JsonTemplate.image(imageUri: imageDigest.dbUri, width: imageData.width, height: imageData.height).data); JsonTemplate.image(imageUri: imageDigest.dbUri, filename: filename, width: imageData.width, height: imageData.height).data);
final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); final objectBytes = Uint8List.fromList(utf8.encode(objectJson));
final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json"); final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json");
final filename = basenameWithoutExtension(image.path);
final digests = [imageDigest, objectDigest]; final digests = [imageDigest, objectDigest];
final record = Record.fromRequiredData( final record = Record.fromRequiredData(

View file

@ -10,28 +10,28 @@ import 'package:package_info_plus/package_info_plus.dart';
class UserApi { class UserApi {
static Future<Iterable<User>> searchUsers(ApiClient client, {required String needle}) async { static Future<Iterable<User>> searchUsers(ApiClient client, {required String needle}) async {
final response = await client.get("/users?name=$needle"); final response = await client.get("/users?name=$needle");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => User.fromMap(e)); return data.map((e) => User.fromMap(e));
} }
static Future<User> getUser(ApiClient client, {required String userId}) async { static Future<User> getUser(ApiClient client, {required String userId}) async {
final response = await client.get("/users/$userId/"); final response = await client.get("/users/$userId/");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return User.fromMap(data); return User.fromMap(data);
} }
static Future<UserStatus> getUserStatus(ApiClient client, {required String userId}) async { static Future<UserStatus> getUserStatus(ApiClient client, {required String userId}) async {
final response = await client.get("/users/$userId/status"); final response = await client.get("/users/$userId/status");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return UserStatus.fromMap(data); return UserStatus.fromMap(data);
} }
static Future<void> notifyOnlineInstance(ApiClient client) async { static Future<void> notifyOnlineInstance(ApiClient client) async {
final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}"); final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}");
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> setStatus(ApiClient client, {required UserStatus status}) async { static Future<void> setStatus(ApiClient client, {required UserStatus status}) async {
@ -42,12 +42,12 @@ class UserApi {
); );
final body = jsonEncode(status.toMap(shallow: true)); final body = jsonEncode(status.toMap(shallow: true));
final response = await client.put("/users/${client.userId}/status", body: body); final response = await client.put("/users/${client.userId}/status", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<PersonalProfile> getPersonalProfile(ApiClient client) async { static Future<PersonalProfile> getPersonalProfile(ApiClient client) async {
final response = await client.get("/users/${client.userId}"); final response = await client.get("/users/${client.userId}");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return PersonalProfile.fromMap(data); return PersonalProfile.fromMap(data);
} }
@ -64,11 +64,11 @@ class UserApi {
); );
final body = jsonEncode(friend.toMap(shallow: true)); final body = jsonEncode(friend.toMap(shallow: true));
final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body); final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> removeUserAsFriend(ApiClient client, {required User user}) async { static Future<void> removeUserAsFriend(ApiClient client, {required User user}) async {
final response = await client.delete("/users/${client.userId}/friends/${user.id}"); final response = await client.delete("/users/${client.userId}/friends/${user.id}");
ApiClient.checkResponse(response); client.checkResponse(response);
} }
} }

View file

@ -30,5 +30,6 @@ class ClientHolder extends InheritedWidget {
@override @override
bool updateShouldNotify(covariant ClientHolder oldWidget) => bool updateShouldNotify(covariant ClientHolder oldWidget) =>
oldWidget.apiClient != apiClient oldWidget.apiClient != apiClient
|| oldWidget.settingsClient != settingsClient; || oldWidget.settingsClient != settingsClient
|| oldWidget.notificationClient != notificationClient;
} }

View file

@ -31,7 +31,7 @@ class ApiClient {
required String username, required String username,
required String password, required String password,
bool rememberMe=true, bool rememberMe=true,
bool rememberPass=false, bool rememberPass=true,
String? oneTimePad, String? oneTimePad,
}) async { }) async {
final body = { final body = {
@ -54,11 +54,13 @@ class ApiClient {
if (response.statusCode == 400) { if (response.statusCode == 400) {
throw "Invalid Credentials"; throw "Invalid Credentials";
} }
checkResponse(response); checkResponseCode(response);
final authData = AuthenticationData.fromMap(jsonDecode(response.body)); final authData = AuthenticationData.fromMap(jsonDecode(response.body));
if (authData.isAuthenticated) { if (authData.isAuthenticated) {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: userIdKey, value: authData.userId); await storage.write(key: userIdKey, value: authData.userId);
await storage.write(key: machineIdKey, value: authData.secretMachineId); await storage.write(key: machineIdKey, value: authData.secretMachineId);
await storage.write(key: tokenKey, value: authData.token); await storage.write(key: tokenKey, value: authData.token);
@ -68,7 +70,9 @@ class ApiClient {
} }
static Future<AuthenticationData> tryCachedLogin() async { static Future<AuthenticationData> tryCachedLogin() async {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
String? userId = await storage.read(key: userIdKey); String? userId = await storage.read(key: userIdKey);
String? machineId = await storage.read(key: machineIdKey); String? machineId = await storage.read(key: machineIdKey);
String? token = await storage.read(key: tokenKey); String? token = await storage.read(key: tokenKey);
@ -79,7 +83,7 @@ class ApiClient {
} }
if (token != null) { if (token != null) {
final response = await http.get(buildFullUri("/users/$userId"), headers: { final response = await http.patch(buildFullUri("/userSessions"), headers: {
"Authorization": "neos $userId:$token" "Authorization": "neos $userId:$token"
}); });
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -100,7 +104,9 @@ class ApiClient {
} }
Future<void> logout(BuildContext context) async { Future<void> logout(BuildContext context) async {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.delete(key: userIdKey); await storage.delete(key: userIdKey);
await storage.delete(key: machineIdKey); await storage.delete(key: machineIdKey);
await storage.delete(key: tokenKey); await storage.delete(key: tokenKey);
@ -117,28 +123,30 @@ class ApiClient {
} }
} }
static void checkResponse(http.Response response) { void checkResponse(http.Response response) {
final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
if (response.statusCode >= 300) {
FlutterError.reportError(FlutterErrorDetails(exception: error));
}
if (response.statusCode == 429) {
throw "Sorry, you are being rate limited. $error";
}
if (response.statusCode == 403) { if (response.statusCode == 403) {
tryCachedLogin(); tryCachedLogin().then((value) {
// TODO: Show the login screen again if cached login was unsuccessful. if (!value.isAuthenticated) {
throw "You are not authorized to do that. $error"; // TODO: Turn api-client into a change notifier to present login screen when logged out
} }
if (response.statusCode == 404) { });
throw "Resource not found. $error";
}
if (response.statusCode == 500) {
throw "Internal server error. $error";
}
if (response.statusCode >= 300) {
throw "Unknown Error. $error";
} }
checkResponseCode(response);
}
static void checkResponseCode(http.Response response) {
if (response.statusCode < 300) return;
final error = "${switch (response.statusCode) {
429 => "Sorry, you are being rate limited.",
403 => "You are not authorized to do that.",
404 => "Resource not found.",
500 => "Internal server error.",
_ => "Unknown Error."
}} (${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
FlutterError.reportError(FlutterErrorDetails(exception: error));
throw error;
} }
Map<String, String> get authorizationHeader => _authenticationData.authorizationHeader; Map<String, String> get authorizationHeader => _authenticationData.authorizationHeader;

View file

@ -16,7 +16,7 @@ class AudioCacheClient {
if (!await file.exists()) { if (!await file.exists()) {
await file.create(recursive: true); await file.create(recursive: true);
final response = await http.get(Uri.parse(Aux.neosDbToHttp(clip.assetUri))); final response = await http.get(Uri.parse(Aux.neosDbToHttp(clip.assetUri)));
ApiClient.checkResponse(response); ApiClient.checkResponseCode(response);
await file.writeAsBytes(response.bodyBytes); await file.writeAsBytes(response.bodyBytes);
} }
return file; return file;

View file

@ -286,7 +286,7 @@ class MessagingClient extends ChangeNotifier {
Uri.parse("${Config.neosHubUrl}/negotiate"), Uri.parse("${Config.neosHubUrl}/negotiate"),
headers: _apiClient.authorizationHeader, headers: _apiClient.authorizationHeader,
); );
ApiClient.checkResponse(response); _apiClient.checkResponse(response);
} catch (e) { } catch (e) {
throw "Failed to acquire connection info from Neos API: $e"; throw "Failed to acquire connection info from Neos API: $e";
} }

View file

@ -7,12 +7,13 @@ class JsonTemplate {
JsonTemplate({required this.data}); JsonTemplate({required this.data});
factory JsonTemplate.image({required String imageUri, required int width, required int height}) { factory JsonTemplate.image({required String imageUri, required String filename, required int width, required int height}) {
final texture2dUid = const Uuid().v4(); final texture2dUid = const Uuid().v4();
final quadMeshUid = const Uuid().v4(); final quadMeshUid = const Uuid().v4();
final quadMeshSizeUid = const Uuid().v4(); final quadMeshSizeUid = const Uuid().v4();
final materialId = const Uuid().v4(); final materialId = const Uuid().v4();
final boxColliderSizeUid = const Uuid().v4(); final boxColliderSizeUid = const Uuid().v4();
final ratio = height/width;
final data = { final data = {
"Object": { "Object": {
"ID": const Uuid().v4(), "ID": const Uuid().v4(),
@ -508,8 +509,8 @@ class JsonTemplate {
"Size": { "Size": {
"ID": quadMeshSizeUid, "ID": quadMeshSizeUid,
"Data": [ "Data": [
1, ratio > 1 ? ratio : 1,
height/width ratio > 1 ? 1 : ratio
] ]
}, },
"UVScale": { "UVScale": {
@ -706,7 +707,7 @@ class JsonTemplate {
}, },
"Name": { "Name": {
"ID": const Uuid().v4(), "ID": const Uuid().v4(),
"Data": "alice" "Data": filename
}, },
"Tag": { "Tag": {
"ID": const Uuid().v4(), "ID": const Uuid().v4(),

View file

@ -0,0 +1,63 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class CameraImageView extends StatelessWidget {
const CameraImageView({required this.file, super.key});
final File file;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
PhotoView(
imageProvider: FileImage(
file,
),
initialScale: PhotoViewComputedScale.covered,
minScale: PhotoViewComputedScale.contained,
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(false);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
backgroundColor: Theme.of(context).colorScheme.surface,
side: BorderSide(width: 1, color: Theme.of(context).colorScheme.error)
),
icon: const Icon(Icons.close),
label: const Text("Cancel",),
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(true);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
backgroundColor: Theme.of(context).colorScheme.surface,
side: BorderSide(width: 1, color: Theme.of(context).colorScheme.primary)
),
icon: const Icon(Icons.check),
label: const Text("Okay"),
)
],
),
),
),
],
),
);
}
}

View file

@ -1,9 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
class MessageAttachmentList extends StatefulWidget { class MessageAttachmentList extends StatefulWidget {
@ -22,7 +22,6 @@ class _MessageAttachmentListState extends State<MessageAttachmentList> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _showShadow = true; bool _showShadow = true;
bool _popupIsOpen = false; bool _popupIsOpen = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -88,7 +87,9 @@ class _MessageAttachmentListState extends State<MessageAttachmentList> {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
_loadedFiles.remove(file); setState(() {
_loadedFiles.remove(file);
});
await widget.onChange(_loadedFiles); await widget.onChange(_loadedFiles);
}, },
child: const Text("Yes"), child: const Text("Yes"),
@ -191,11 +192,19 @@ class _MessageAttachmentListState extends State<MessageAttachmentList> {
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () async { onPressed: () async {
final picture = await Navigator.of(context).push( final picture = await ImagePicker().pickImage(source: ImageSource.camera);
MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?;
if (picture != null) { if (picture != null) {
_loadedFiles.add((FileType.image, picture)); final file = File(picture.path);
await widget.onChange(_loadedFiles); if (await file.exists()) {
setState(() {
_loadedFiles.add((FileType.image, file));
});
await widget.onChange(_loadedFiles);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file")));
}
}
} }
}, },
icon: const Icon(Icons.camera,), icon: const Icon(Icons.camera,),

View file

@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget {
State<MessageAudioPlayer> createState() => _MessageAudioPlayerState(); State<MessageAudioPlayer> createState() => _MessageAudioPlayerState();
} }
class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver { class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
Future? _audioFileFuture; Future? _audioFileFuture;
double _sliderValue = 0; double _sliderValue = 0;
@ -82,6 +82,7 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform.");
} }
@ -220,4 +221,8 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
} }
); );
} }
@override
// TODO: implement wantKeepAlive
bool get wantKeepAlive => true;
} }

View file

@ -1,184 +0,0 @@
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
import 'package:flutter/material.dart';
class MessageCameraView extends StatefulWidget {
const MessageCameraView({super.key});
@override
State<StatefulWidget> createState() => _MessageCameraViewState();
}
class _MessageCameraViewState extends State<MessageCameraView> {
final List<CameraDescription> _cameras = [];
late final CameraController _cameraController;
int _cameraIndex = 0;
FlashMode _flashMode = FlashMode.off;
Future? _initializeControllerFuture;
@override
void initState() {
super.initState();
availableCameras().then((List<CameraDescription> cameras) {
_cameras.clear();
_cameras.addAll(cameras);
if (cameras.isEmpty) {
_initializeControllerFuture = Future.error("Failed to initialize camera");
} else {
_cameraController = CameraController(cameras.first, ResolutionPreset.high);
_cameraIndex = 0;
_initializeControllerFuture = _cameraController.initialize().whenComplete(() => _cameraController.setFlashMode(_flashMode));
}
setState(() {});
});
}
@override
void dispose() {
_cameraController.setFlashMode(FlashMode.off).whenComplete(() => _cameraController.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Take a picture"),
),
body: FutureBuilder(
future: _initializeControllerFuture,
builder: (context, snapshot) {
// Can't use hasData since the future returns void.
if (snapshot.connectionState == ConnectionState.done) {
return Stack(
children: [
Column(
children: [
Expanded(child: CameraPreview(_cameraController)),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
onPressed: _cameras.isEmpty ? null : () async {
setState(() {
_cameraIndex = (_cameraIndex+1) % _cameras.length;
});
_cameraController.setDescription(_cameras[_cameraIndex]);
},
iconSize: 32,
icon: const Icon(Icons.switch_camera),
),
const SizedBox(width: 64, height: 72,),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: RotationTransition(
turns: Tween<double>(begin: 0.6, end: 1).animate(animation),
child: child,
),
),
child: switch (_flashMode) {
FlashMode.off =>
IconButton(
key: const ValueKey("button-flash-off"),
iconSize: 32,
onPressed: () async {
_flashMode = FlashMode.auto;
await _cameraController.setFlashMode(_flashMode);
setState(() {});
},
icon: const Icon(Icons.flash_off),
),
FlashMode.auto =>
IconButton(
key: const ValueKey("button-flash-auto"),
iconSize: 32,
onPressed: () async {
_flashMode = FlashMode.always;
await _cameraController.setFlashMode(_flashMode);
setState(() {});
},
icon: const Icon(Icons.flash_auto),
),
FlashMode.always =>
IconButton(
key: const ValueKey("button-flash-always"),
iconSize: 32,
onPressed: () async {
_flashMode = FlashMode.torch;
await _cameraController.setFlashMode(_flashMode);
setState(() {});
},
icon: const Icon(Icons.flash_on),
),
FlashMode.torch =>
IconButton(
key: const ValueKey("button-flash-torch"),
iconSize: 32,
onPressed: () async {
_flashMode = FlashMode.off;
await _cameraController.setFlashMode(_flashMode);
setState(() {});
},
icon: const Icon(Icons.flashlight_on),
),
},
),
],
)
],
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
color: Theme
.of(context)
.colorScheme
.surface,
borderRadius: BorderRadius.circular(64),
),
margin: const EdgeInsets.all(16),
child: IconButton(
onPressed: () async {
final sMsgr = ScaffoldMessenger.of(context);
final nav = Navigator.of(context);
try {
await _initializeControllerFuture;
final image = await _cameraController.takePicture();
nav.pop(File(image.path));
} catch (e) {
sMsgr.showSnackBar(SnackBar(content: Text("Failed to capture image: $e")));
}
},
style: IconButton.styleFrom(
foregroundColor: Theme
.of(context)
.colorScheme
.primary,
),
icon: const Icon(Icons.camera),
iconSize: 64,
),
),
),
],
);
} else if (snapshot.hasError) {
return DefaultErrorWidget(
message: snapshot.error.toString(),
);
} else {
return const Center(child: CircularProgressIndicator(),);
}
},
),
);
}
}

View file

@ -10,10 +10,10 @@ import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart'; import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart';
import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
@ -21,9 +21,8 @@ import 'package:uuid/uuid.dart';
class MessageInputBar extends StatefulWidget { class MessageInputBar extends StatefulWidget {
const MessageInputBar({this.showShadow=true, this.disabled=false, required this.recipient, this.onMessageSent, super.key}); const MessageInputBar({this.disabled=false, required this.recipient, this.onMessageSent, super.key});
final bool showShadow;
final bool disabled; final bool disabled;
final Friend recipient; final Friend recipient;
final Function()? onMessageSent; final Function()? onMessageSent;
@ -36,6 +35,7 @@ class _MessageInputBarState extends State<MessageInputBar> {
final TextEditingController _messageTextController = TextEditingController(); final TextEditingController _messageTextController = TextEditingController();
final List<(FileType, File)> _loadedFiles = []; final List<(FileType, File)> _loadedFiles = [];
final Record _recorder = Record(); final Record _recorder = Record();
final ImagePicker _imagePicker = ImagePicker();
DateTime? _recordingStartTime; DateTime? _recordingStartTime;
@ -47,7 +47,6 @@ class _MessageInputBarState extends State<MessageInputBar> {
set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null; set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null;
bool _recordingCancelled = false; bool _recordingCancelled = false;
Future<void> sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { Future<void> sendTextMessage(ApiClient client, MessagingClient mClient, String content) async {
if (content.isEmpty) return; if (content.isEmpty) return;
final message = Message( final message = Message(
@ -204,24 +203,15 @@ class _MessageInputBarState extends State<MessageInputBar> {
} }
} }
}, },
child: AnimatedContainer( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
boxShadow: [ border: const Border(top: BorderSide(width: 1, color: Colors.black38)),
BoxShadow(
blurRadius: widget.showShadow ? 8 : 0,
color: Theme
.of(context)
.shadowColor,
offset: const Offset(0, 4),
),
],
color: Theme color: Theme
.of(context) .of(context)
.colorScheme .colorScheme
.background, .background,
), ),
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
duration: const Duration(milliseconds: 250),
child: Column( child: Column(
children: [ children: [
if (_isSending && _sendProgress != null) if (_isSending && _sendProgress != null)
@ -262,13 +252,24 @@ class _MessageInputBarState extends State<MessageInputBar> {
), ),
TextButton.icon( TextButton.icon(
onPressed: _isSending ? null : () async { onPressed: _isSending ? null : () async {
final picture = await Navigator.of(context).push( final picture = await _imagePicker.pickImage(source: ImageSource.camera);
MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; if (picture == null) {
if (picture != null) { if (context.mounted) {
setState(() { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to get image path")));
_loadedFiles.add((FileType.image, picture)); }
}); return;
} }
final file = File(picture.path);
if (await file.exists()) {
setState(() {
_loadedFiles.add((FileType.image, file));
});
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file")));
}
}
}, },
icon: const Icon(Icons.camera), icon: const Icon(Icons.camera),
label: const Text("Camera"), label: const Text("Camera"),

View file

@ -1,60 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:record/record.dart';
class MessageRecordButton extends StatefulWidget {
const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key});
final bool disabled;
final Function()? onRecordStart;
final Function(File? recording)? onRecordEnd;
@override
State<MessageRecordButton> createState() => _MessageRecordButtonState();
}
class _MessageRecordButtonState extends State<MessageRecordButton> {
final Record _recorder = Record();
@override
void dispose() {
super.dispose();
Future.delayed(Duration.zero, _recorder.stop);
Future.delayed(Duration.zero, _recorder.dispose);
}
@override
Widget build(BuildContext context) {
return Material(
child: GestureDetector(
onTapDown: widget.disabled ? null : (_) async {
HapticFeedback.vibrate();
/*
widget.onRecordStart?.call();
final dir = await getTemporaryDirectory();
await _recorder.start(
path: "${dir.path}/A-${const Uuid().v4()}.ogg",
encoder: AudioEncoder.opus,
samplingRate: 44100,
);
*/
},
onLongPressUp: () async {
/*
if (await _recorder.isRecording()) {
final recording = await _recorder.stop();
widget.onRecordEnd?.call(recording == null ? null : File(recording));
}
*/
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(Icons.mic_outlined, size: 28, color: Theme.of(context).colorScheme.onSurface,),
),
),
);
}
}

View file

@ -21,9 +21,7 @@ class MessagesList extends StatefulWidget {
class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin { class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin {
final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _sessionListScrollController = ScrollController();
final ScrollController _messageScrollController = ScrollController();
bool _showBottomBarShadow = false;
bool _showSessionListScrollChevron = false; bool _showSessionListScrollChevron = false;
double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0;
@ -50,19 +48,6 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
}); });
} }
}); });
_messageScrollController.addListener(() {
if (!_messageScrollController.hasClients) return;
if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 &&
_showBottomBarShadow) {
setState(() {
_showBottomBarShadow = false;
});
} else if (!_showBottomBarShadow) {
setState(() {
_showBottomBarShadow = true;
});
}
});
} }
@ -189,7 +174,6 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
return Provider( return Provider(
create: (BuildContext context) => AudioCacheClient(), create: (BuildContext context) => AudioCacheClient(),
child: ListView.builder( child: ListView.builder(
controller: _messageScrollController,
reverse: true, reverse: true,
itemCount: cache.messages.length, itemCount: cache.messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -212,7 +196,6 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
MessageInputBar( MessageInputBar(
recipient: widget.friend, recipient: widget.friend,
disabled: cache == null || cache.error != null, disabled: cache == null || cache.error != null,
showShadow: _showBottomBarShadow,
onMessageSent: () { onMessageSent: () {
setState(() {}); setState(() {});
}, },

View file

@ -376,6 +376,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "9978d3510af4e6a902e545ce19229b926e6de6a1828d6134d3aab2e129a4d270"
url: "https://pub.dev"
source: hosted
version: "0.8.7+5"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: c2f3c66400649bd132f721c88218945d6406f693092b2f741b79ae9cdb046e59
url: "https://pub.dev"
source: hosted
version: "0.8.6+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "98f50d6b9f294c8ba35e25cc0d13b04bfddd25dbc8d32fa9d566a6572f2c081c"
url: "https://pub.dev"
source: hosted
version: "2.1.12"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: d779210bda268a03b57e923fb1e410f32f5c5e708ad256348bcbf1f44f558fd0
url: "https://pub.dev"
source: hosted
version: "0.8.7+4"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "1991219d9dbc42a99aff77e663af8ca51ced592cd6685c9485e3458302d3d4f8"
url: "https://pub.dev"
source: hosted
version: "2.6.3"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -61,6 +61,7 @@ dependencies:
camera: ^0.10.5 camera: ^0.10.5
path_provider: ^2.0.15 path_provider: ^2.0.15
crypto: ^3.0.3 crypto: ^3.0.3
image_picker: ^0.8.7+5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: