diff --git a/lib/controllers/api_routes/api_manager.dart b/lib/controllers/api_routes/api_manager.dart index cea9ea3a..a230c2e5 100644 --- a/lib/controllers/api_routes/api_manager.dart +++ b/lib/controllers/api_routes/api_manager.dart @@ -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 postFormDataStreamingWithFile( String url, { Map? headers, diff --git a/lib/controllers/api_routes/urls.dart b/lib/controllers/api_routes/urls.dart index 58a70336..01c9ae7a 100644 --- a/lib/controllers/api_routes/urls.dart +++ b/lib/controllers/api_routes/urls.dart @@ -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"; } diff --git a/lib/controllers/providers/api/devices_provider.dart b/lib/controllers/providers/api/devices_provider.dart index 66c95d7b..55db8d07 100644 --- a/lib/controllers/providers/api/devices_provider.dart +++ b/lib/controllers/providers/api/devices_provider.dart @@ -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 predictDevice(File imageFile) async { + late Response response; + + try { + final files = >[ + 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; + } + } } diff --git a/lib/models/device/predict_device_model.dart b/lib/models/device/predict_device_model.dart new file mode 100644 index 00000000..64d412ed --- /dev/null +++ b/lib/models/device/predict_device_model.dart @@ -0,0 +1,116 @@ +class PredictDeviceResponse { + bool? success; + String? timestamp; + List? predictedAsset; + + PredictDeviceResponse({ + this.success, + this.timestamp, + this.predictedAsset, + }); + + PredictDeviceResponse.fromJson(Map json) { + success = json['success']; + timestamp = json['timestamp']; + + if (json['PredictedAsset'] != null) { + predictedAsset = []; + + for (var item in json['PredictedAsset']) { + predictedAsset!.add( + PredictedAsset.fromJson(item), + ); + } + } + } + + Map 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 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 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 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 toJson() { + return { + 'site_id': siteId, + 'site_name': siteName, + 'asset_name': assetName, + 'oracle_code': oracleCode, + 'asset_number': assetNumber, + 'department_id': departmentId, + 'department_name': departmentName, + }; + } +} \ No newline at end of file diff --git a/lib/modules/copilot/screens/copilot_history_page.dart b/lib/modules/copilot/screens/copilot_history_page.dart index 67afcfd2..4b3b9364 100644 --- a/lib/modules/copilot/screens/copilot_history_page.dart +++ b/lib/modules/copilot/screens/copilot_history_page.dart @@ -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 createState() => _CopilotHistoryPageState(); } + class _CopilotHistoryPageState extends State { + String _searchQuery = ''; + @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { context.read().loadHistory(); }); } void _openConversation(String threadId) { - Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: false))).then((_) => context.read().loadHistory()); + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const CopilotChatPage( + isNewConversation: false, + ), + ), + ).then( + (_) => context.read().loadHistory(), + ); + context.read().loadConversation(threadId); } void _startNewConversation() { context.read().startNewConversation(); - Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read().loadHistory()); + + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const CopilotChatPage( + isNewConversation: true, + ), + ), + ).then( + (_) => context.read().loadHistory(), + ); + } + + List _filteredHistory( + List 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 { body: Consumer( 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 { 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 { 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 { 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')}"; } } diff --git a/lib/views/widgets/equipment/my_assets_page.dart b/lib/views/widgets/equipment/my_assets_page.dart index 37df435b..0cf049fc 100644 --- a/lib/views/widgets/equipment/my_assets_page.dart +++ b/lib/views/widgets/equipment/my_assets_page.dart @@ -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 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 with SingleTickerProviderSt _searchableList.clear(); _searchableList.addAll(_devicesProvider.devices); } + + Future _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 _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 _uploadImage(File imageFile) async { + try { + await _devicesProvider.predictDevice(imageFile); + //PredictDeviceView + } catch (e) { + debugPrint('Upload error: $e'); + } + } } diff --git a/lib/views/widgets/equipment/predict_device_item_grid_view.dart b/lib/views/widgets/equipment/predict_device_item_grid_view.dart new file mode 100644 index 00000000..2d9982d8 --- /dev/null +++ b/lib/views/widgets/equipment/predict_device_item_grid_view.dart @@ -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); + } +} diff --git a/lib/views/widgets/equipment/predict_device_item_view.dart b/lib/views/widgets/equipment/predict_device_item_view.dart new file mode 100644 index 00000000..b8546ce5 --- /dev/null +++ b/lib/views/widgets/equipment/predict_device_item_view.dart @@ -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)); + } +} diff --git a/lib/views/widgets/equipment/predict_device_list.dart b/lib/views/widgets/equipment/predict_device_list.dart new file mode 100644 index 00000000..dc5155cd --- /dev/null +++ b/lib/views/widgets/equipment/predict_device_list.dart @@ -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; + + PredictDeviceView({Key? key, required this.predictedAsset}) : super(key: key); + + @override + _PredictDeviceViewState createState() => _PredictDeviceViewState(); +} + +class _PredictDeviceViewState extends State 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(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(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( + 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(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( + 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)), + ], + )), + ], + ), + ), + ); + } +} diff --git a/lib/views/widgets/images/multi_image_picker.dart b/lib/views/widgets/images/multi_image_picker.dart index a0533210..f4769bf4 100644 --- a/lib/views/widgets/images/multi_image_picker.dart +++ b/lib/views/widgets/images/multi_image_picker.dart @@ -287,7 +287,6 @@ class _AttachmentPickerState extends State with SingleTickerPr ), ), ); - final slideAnimation = Tween( begin: const Offset(0.0, 0.3), end: Offset.zero,