device predication inprogess

main_production_copilot
WaseemAbbasi22 3 weeks ago
parent 8cc76265b1
commit 2ae26cc363

@ -232,10 +232,6 @@ class ApiManager {
return streamedResponse;
}
/// POST multipart/form-data request WITH a file attachment that returns a StreamedResponse.
/// Additive-only method (does not change any existing method): needed for Copilot's
/// QueryStream(isAudio=true), since [postFormDataStreaming] only supports text fields,
/// not file attachments.
Future<http.StreamedResponse> postFormDataStreamingWithFile(
String url, {
Map<String, String>? headers,

@ -626,6 +626,7 @@ class URLs {
// Copilot APIs
static get copilotQueryStream => "$_baseUrl/Automation/QueryStream";
static get copilotGetQueryHistory => "$_baseUrl/Automation/GetQueryHistory";
static get predictDevice => "$_baseUrl/Automation/PredictDevice";
static copilotGetConversationByThreadId(String threadId) => "$_baseUrl/Automation/GetConversationByThreadId/$threadId";
static copilotDeleteQueryHistory(String threadId) => "$_baseUrl/Automation/DeleteQueryHistory/$threadId";
}

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:localization/localization.dart';
@ -257,4 +258,32 @@ class AssetProvider extends ChangeNotifier {
return false;
}
}
Future<bool> predictDevice(File imageFile) async {
late Response response;
try {
final files = <Future<MultipartFile>>[
MultipartFile.fromPath(
'file',
imageFile.path,
),
];
log('Uploading file: ${files.first.runtimeType}');
response = await ApiManager.instance.multiPart(
URLs.predictDevice,
body: {},
files: files,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
return true;
}
return false;
} catch (error) {
log('error: $error');
return false;
}
}
}

@ -0,0 +1,116 @@
class PredictDeviceResponse {
bool? success;
String? timestamp;
List<PredictedAsset>? predictedAsset;
PredictDeviceResponse({
this.success,
this.timestamp,
this.predictedAsset,
});
PredictDeviceResponse.fromJson(Map<String, dynamic> json) {
success = json['success'];
timestamp = json['timestamp'];
if (json['PredictedAsset'] != null) {
predictedAsset = <PredictedAsset>[];
for (var item in json['PredictedAsset']) {
predictedAsset!.add(
PredictedAsset.fromJson(item),
);
}
}
}
Map<String, dynamic> toJson() {
return {
'success': success,
'timestamp': timestamp,
'PredictedAsset':
predictedAsset?.map((e) => e.toJson()).toList(),
};
}
}
class PredictedAsset {
String? assetId;
String? assetName;
num? confidence;
String? confidencePercent;
PredictedAssetMetadata? metadata;
PredictedAsset({
this.assetId,
this.assetName,
this.confidence,
this.confidencePercent,
this.metadata,
});
PredictedAsset.fromJson(Map<String, dynamic> json) {
assetId = json['assetid'];
assetName = json['asset_name'];
confidence = json['confidence'];
confidencePercent = json['confidence_percent'];
if (json['metadata'] != null) {
metadata = PredictedAssetMetadata.fromJson(
json['metadata'],
);
}
}
Map<String, dynamic> toJson() {
return {
'assetid': assetId,
'asset_name': assetName,
'confidence': confidence,
'confidence_percent': confidencePercent,
'metadata': metadata?.toJson(),
};
}
}
class PredictedAssetMetadata {
String? siteId;
String? siteName;
String? assetName;
String? oracleCode;
String? assetNumber;
String? departmentId;
String? departmentName;
PredictedAssetMetadata({
this.siteId,
this.siteName,
this.assetName,
this.oracleCode,
this.assetNumber,
this.departmentId,
this.departmentName,
});
PredictedAssetMetadata.fromJson(Map<String, dynamic> json) {
siteId = json['site_id'];
siteName = json['site_name'];
assetName = json['asset_name'];
oracleCode = json['oracle_code'];
assetNumber = json['asset_number'];
departmentId = json['department_id'];
departmentName = json['department_name'];
}
Map<String, dynamic> toJson() {
return {
'site_id': siteId,
'site_name': siteName,
'asset_name': assetName,
'oracle_code': oracleCode,
'asset_number': assetNumber,
'department_id': departmentId,
'department_name': departmentName,
};
}
}

@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
@ -8,35 +9,76 @@ import 'package:test_sa/modules/copilot/model/copilot_message_model.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/app_filled_button.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/new_views/common_widgets/app_text_form_field.dart';
import 'package:test_sa/views/widgets/item_views/info_header_widget.dart';
import 'package:test_sa/views/widgets/item_views/info_text_widget.dart';
import '../controller/copilot_provider.dart';
import 'copilot_chat_page.dart' show CopilotChatPage;
class CopilotHistoryPage extends StatefulWidget {
static const String routeName = "/copilot-history";
const CopilotHistoryPage({Key? key}) : super(key: key);
@override
State<CopilotHistoryPage> createState() => _CopilotHistoryPageState();
}
class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
String _searchQuery = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<CopilotProvider>().loadHistory();
});
}
void _openConversation(String threadId) {
Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: false))).then((_) => context.read<CopilotProvider>().loadHistory());
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const CopilotChatPage(
isNewConversation: false,
),
),
).then(
(_) => context.read<CopilotProvider>().loadHistory(),
);
context.read<CopilotProvider>().loadConversation(threadId);
}
void _startNewConversation() {
context.read<CopilotProvider>().startNewConversation();
Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read<CopilotProvider>().loadHistory());
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const CopilotChatPage(
isNewConversation: true,
),
),
).then(
(_) => context.read<CopilotProvider>().loadHistory(),
);
}
List<CopilotHistoryItem> _filteredHistory(
List<CopilotHistoryItem> history,
) {
final query = _searchQuery.trim().toLowerCase();
if (query.isEmpty) {
return history;
}
return history.where((item) {
return item.firstHumanMessage.toLowerCase().contains(query);
}).toList();
}
@override
@ -54,7 +96,9 @@ class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
body: Consumer<CopilotProvider>(
builder: (context, provider, _) {
if (provider.historyLoading) {
return const Center(child: CircularProgressIndicator());
return const Center(
child: CircularProgressIndicator(),
);
}
if (provider.history.isEmpty) {
@ -62,11 +106,17 @@ class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.chat_bubble_outline, size: 56, color: AppColor.neutral120),
const Icon(
Icons.chat_bubble_outline,
size: 56,
color: AppColor.neutral120,
),
16.height,
Text(
"No conversations yet",
style: AppTextStyles.heading6.copyWith(color: AppColor.neutral120),
style: AppTextStyles.heading6.copyWith(
color: AppColor.neutral120,
),
),
24.height,
AppFilledButton(
@ -74,26 +124,104 @@ class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
maxWidth: true,
buttonColor: AppColor.primary10,
onPressed: _startNewConversation,
).paddingOnly(start: 48, end: 48),
).paddingOnly(
start: 48,
end: 48,
),
],
).center;
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: provider.history.length,
separatorBuilder: (czt, index) => 12.height,
itemBuilder: (context, index) {
final item = provider.history[index];
return messageHistoryCard(item: item, provider: provider);
},
final filteredHistory = _filteredHistory(provider.history);
return Column(
children: [
8.height,
_buildSearchBar(context),
8.height,
if (filteredHistory.isEmpty)
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.search_off,
size: 48,
color: AppColor.neutral120,
),
12.height,
Text(
"No conversations found",
style: AppTextStyles.heading6.copyWith(
color: AppColor.neutral120,
),
),
],
).center,
)
else
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemCount: filteredHistory.length,
separatorBuilder: (context, index) => 12.height,
itemBuilder: (context, index) {
final item = filteredHistory[index];
return messageHistoryCard(
item: item,
provider: provider,
);
},
),
),
],
);
},
),
);
}
Widget messageHistoryCard({required CopilotHistoryItem item, required CopilotProvider provider}) {
Widget _buildSearchBar(BuildContext context) {
return AppTextFormField(
labelText: "",
hintText: context.translation.search,
showBorder: false,
showShadow: false,
// backgroundColor: AppColor.fieldBgColor(context),
prefixIcon: Icon(
Icons.search,
color: context.isDark ? AppColor.neutral10 : AppColor.neutral20,
size: 19,
),
textInputType: TextInputType.text,
contentPadding: EdgeInsets.zero,
hintStyle: AppTextStyles.bodyText.copyWith(
color: context.isDark ? AppColor.neutral10 : AppColor.neutral20,
),
style: AppTextStyles.bodyText.copyWith(
color: AppColor.textStyleColor(context),
),
onChange: (value) {
setState(() {
_searchQuery = value;
});
},
)
.toShadowContainer(
context,
showShadow: false,
)
.paddingOnly(start: 16, end: 16);
}
Widget messageHistoryCard({
required CopilotHistoryItem item,
required CopilotProvider provider,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -108,22 +236,41 @@ class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoHeaderWidget(item.firstHumanMessage),
InfoHeaderWidget(
item.firstHumanMessage,
),
4.height,
InfoTextLabelWidget(label: _formatDate(item.updatedAt)),
InfoTextLabelWidget(
label: _formatDate(item.updatedAt),
),
],
),
],
),
"delete_icon".toSvgAsset(height: 20, width: 20).onPress(() => provider.deleteHistory(item.threadId)),
"delete_icon"
.toSvgAsset(
height: 20,
width: 20,
)
.onPress(
() => provider.deleteHistory(item.threadId),
),
],
).toShadowContainer(context, showShadow: false).onPress(() {
)
.toShadowContainer(
context,
showShadow: false,
)
.onPress(() {
_openConversation(item.threadId);
});
}
String _formatDate(DateTime date) {
return "${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} "
"${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}";
return "${date.day.toString().padLeft(2, '0')}/"
"${date.month.toString().padLeft(2, '0')}/"
"${date.year} "
"${date.hour.toString().padLeft(2, '0')}:"
"${date.minute.toString().padLeft(2, '0')}";
}
}

@ -2,6 +2,8 @@ import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/controllers/providers/api/devices_provider.dart';
import 'package:test_sa/controllers/providers/api/user_provider.dart';
@ -313,16 +315,29 @@ class _MyAssetsPageState extends State<MyAssetsPage> with SingleTickerProviderSt
),
),
),
floatingActionButton: FloatingActionButton(
heroTag: "some tag 2",
backgroundColor: AppColor.primary10,
child: Icon(Icons.qr_code_2_rounded, size: 40, color: AppColor.addItemButtonColor(context)),
onPressed: () async {
String? result = await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ScanQr()),
) as String?;
if (result != null) _getDevice(result, isQr: true);
},
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: "tag 1",
backgroundColor: AppColor.primary10,
onPressed: _pickImageFromCamera,
child: Icon(Icons.camera, size: 40, color: AppColor.addItemButtonColor(context)),
),
12.height,
FloatingActionButton(
heroTag: "some tag 2",
backgroundColor: AppColor.primary10,
child: Icon(Icons.qr_code_2_rounded, size: 40, color: AppColor.addItemButtonColor(context)),
onPressed: () async {
String? result = await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ScanQr()),
) as String?;
if (result != null) _getDevice(result, isQr: true);
},
),
],
),
);
}
@ -629,4 +644,51 @@ class _MyAssetsPageState extends State<MyAssetsPage> with SingleTickerProviderSt
_searchableList.clear();
_searchableList.addAll(_devicesProvider.devices);
}
Future<void> _pickImageFromCamera() async {
final status = await Permission.camera.status;
if (status.isGranted) {
await _openCamera();
return;
}
final permission = await Permission.camera.request();
if (permission.isGranted) {
await _openCamera();
return;
}
if (permission.isPermanentlyDenied) {
await openAppSettings();
return;
}
debugPrint('Camera permission denied');
}
Future<void> _openCamera() async {
final XFile? pickedFile = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 70,
);
if (pickedFile == null) {
return;
}
final File imageFile = File(pickedFile.path);
await _uploadImage(imageFile);
}
Future<void> _uploadImage(File imageFile) async {
try {
await _devicesProvider.predictDevice(imageFile);
//PredictDeviceView
} catch (e) {
debugPrint('Upload error: $e');
}
}
}

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/models/device/predict_device_model.dart';
import 'package:test_sa/views/widgets/item_views/info_text_widget.dart';
import '../../../new_views/app_style/app_color.dart';
class PredictDeviceItemGridView extends StatelessWidget {
final PredictedAsset device;
final Function(PredictedAsset) onPressed;
const PredictDeviceItemGridView({Key? key, required this.device, required this.onPressed}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.assetName!.cleanupWhitespace.capitalizeFirstOfEach,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodyText.copyWith(color: AppColor.textStyleColor(context)),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
InfoTextWidget(label: context.translation.assetNumber, value: device.metadata?.assetNumber),
InfoTextWidget(label: 'Matching Percentage', value: device.confidencePercent),
InfoTextWidget(label: context.translation.oracleCode, value: device.metadata?.oracleCode),
InfoTextWidget(label: context.translation.site, value: device.metadata?.siteName),
InfoTextWidget(label: context.translation.department, value: device.metadata?.departmentName),
],
).expanded,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'View Details',
style: AppTextStyles.bodyText2.copyWith(color: AppColor.primary10),
),
4.width,
const Icon(Icons.arrow_forward, color: AppColor.primary10, size: 14)
],
),
],
).expanded.onPress(() => onPressed(device)).toShadowContainer(context);
}
}

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/models/device/predict_device_model.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/views/widgets/item_views/info_header_widget.dart';
import 'package:test_sa/views/widgets/item_views/info_text_widget.dart';
class PredictDeviceItemView extends StatelessWidget {
final PredictedAsset device;
final Function(PredictedAsset) onPressed;
final Widget? selectButton;
final bool isSelected;
const PredictDeviceItemView({Key? key, required this.device, required this.onPressed, this.selectButton, this.isSelected = false}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
InfoHeaderWidget(device.assetName!.cleanupWhitespace),
4.height,
InfoTextWidget(label: context.translation.assetNumber, value: device.metadata?.assetNumber),
InfoTextWidget(label: 'Matching Percentage', value: device.confidencePercent),
InfoTextWidget(label: context.translation.oracleCode, value: device.metadata?.oracleCode),
InfoTextWidget(label: context.translation.site, value: device.metadata?.siteName),
InfoTextWidget(label: context.translation.department, value: device.metadata?.departmentName),
],
),
Align(
alignment: Alignment.bottomRight,
child: selectButton ??
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.translation.viewDetails,
style: AppTextStyles.bodyText2.copyWith(color: AppColor.primary10),
),
4.width,
const Icon(Icons.arrow_forward, color: AppColor.primary10, size: 14)
],
))
],
).expanded.toShadowContainer(context, borderColor: isSelected ? AppColor.primary10 : Colors.transparent).onPress(() => onPressed(device));
}
}

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/controllers/providers/api/devices_provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/models/device/predict_device_model.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/views/widgets/equipment/asset_detail_page.dart';
import 'package:test_sa/views/widgets/equipment/predict_device_item_grid_view.dart';
import 'package:test_sa/views/widgets/equipment/predict_device_item_view.dart';
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
class PredictDeviceView extends StatefulWidget {
static const String id = "/single-device-Picker";
List<PredictedAsset> predictedAsset;
PredictDeviceView({Key? key, required this.predictedAsset}) : super(key: key);
@override
_PredictDeviceViewState createState() => _PredictDeviceViewState();
}
class _PredictDeviceViewState extends State<PredictDeviceView> with SingleTickerProviderStateMixin {
late AssetProvider _devicesProvider;
bool showListView = true;
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
_devicesProvider = Provider.of<AssetProvider>(context);
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: DefaultAppBar(
title: 'Predicated Devices',
actions: [
IconButton(
icon: Icon(!showListView ? Icons.view_list_sharp : Icons.grid_view_outlined, color: AppColor.icon2Color(context)),
padding: EdgeInsets.zero,
iconSize: 20,
visualDensity: VisualDensity.compact,
tooltip: !showListView ? "View as List" : "View as Grid",
onPressed: () => setState(() {
showListView = !showListView;
}),
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: widget.predictedAsset.isEmpty
? NoDataFound(message: context.translation.noDeviceFound).center
: Stack(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: showListView
? ListView.separated(
key: const ValueKey('list_view'),
padding: const EdgeInsets.all(16),
itemCount: widget.predictedAsset.length,
separatorBuilder: (listContext, itemIndex) => 12.height,
itemBuilder: (listContext, itemIndex) {
// Calculate staggered animation delay
final delay = itemIndex * 0.08;
final animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Interval(
delay.clamp(0.0, 0.7),
(delay + 0.4).clamp(0.0, 1.0),
curve: Curves.easeOut,
),
),
);
final slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.15),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Interval(
delay.clamp(0.0, 0.7),
(delay + 0.4).clamp(0.0, 1.0),
curve: Curves.easeOutCubic,
),
),
);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slideAnimation,
child: PredictDeviceItemView(
device: widget.predictedAsset[itemIndex],
onPressed: (device) {
Navigator.of(context).pushNamed(AssetDetailPage.id, arguments: device.assetId);
},
),
),
);
},
)
: GridView.builder(
key: const ValueKey('grid_view'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, childAspectRatio: 188 / 261, crossAxisSpacing: 12, mainAxisSpacing: 12),
itemCount: widget.predictedAsset.length,
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
// Calculate staggered animation delay
final delay = index * 0.08;
final animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Interval(
delay.clamp(0.0, 0.8),
(delay + 0.3).clamp(0.0, 1.0),
curve: Curves.easeOut,
),
),
);
final slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Interval(
delay.clamp(0.0, 0.8),
(delay + 0.3).clamp(0.0, 1.0),
curve: Curves.easeOutCubic,
),
),
);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slideAnimation,
child: PredictDeviceItemGridView(
device: widget.predictedAsset[index],
onPressed: (device) {
Navigator.of(context).pushNamed(AssetDetailPage.id, arguments: device.assetId);
},
),
),
);
},
),
),
if (_devicesProvider.isLoading) Align(alignment: Alignment.bottomCenter, child: CircularProgressIndicator().paddingOnly(top: 16, bottom: 16)),
],
)),
],
),
),
);
}
}

@ -287,7 +287,6 @@ class _AttachmentPickerState extends State<AttachmentPicker> with SingleTickerPr
),
),
);
final slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.3),
end: Offset.zero,

Loading…
Cancel
Save