diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0476de..d71748c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,13 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true + - name: Install Tools + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: clang cmake git ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev + version: 1.0 + if: runner.os == 'Linux' + - name: Install dependencies id: install run: flutter pub get @@ -41,8 +48,9 @@ jobs: run: dart analyze --fatal-infos if: always() && steps.install.outcome == 'success' - - name: Run tests + - name: Run unit tests run: flutter test --coverage --reporter github + - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 with: diff --git a/.gitignore b/.gitignore index 2f63260..5033177 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # Release /release + +# Test Coverage +coverage/ diff --git a/README.md b/README.md index dd3b90e..7aa0c34 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ExifHelper - +[![CI](https://github.com/Samoy/exif_helper/actions/workflows/ci.yml/badge.svg)](https://github.com/Samoy/exif_helper/actions/workflows/ci.yml) +[![codecov](https://codecov.io/github/Samoy/exif_helper/graph/badge.svg?token=SCJGI01J89)](https://codecov.io/github/Samoy/exif_helper) Read or write image exif without internet - ## Features ### ๐Ÿ’ป Cross Platform Support Windows, Linux, Macos, Android and iOS diff --git a/lib/common/constant.dart b/lib/common/constant.dart index c0a6e26..281a843 100644 --- a/lib/common/constant.dart +++ b/lib/common/constant.dart @@ -33,3 +33,7 @@ const double normalButtonHeight = 40; const String keyDismissWarning = "dismissWarning"; /*--------------------Storage End--------------------*/ + +/*--------------------List Start--------------------*/ +const List allowedExtensions = ["jpg", "tif", "jpeg", "tiff"]; +/*--------------------List End--------------------*/ diff --git a/lib/common/utils.dart b/lib/common/utils.dart new file mode 100644 index 0000000..1eee41e --- /dev/null +++ b/lib/common/utils.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class SnackBarUtils { + static ScaffoldFeatureController showSnackBar( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 2), + }) { + return ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: duration, + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 224112f..b4dae66 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,7 @@ import 'package:exif_helper/models/system.dart'; import 'extensions/platform_extension.dart'; -void main() async { +main() async { WidgetsFlutterBinding.ensureInitialized(); if (PlatformExtension.isDesktop) { await initDesktop(); diff --git a/lib/models/exif.dart b/lib/models/image_exif.dart similarity index 61% rename from lib/models/exif.dart rename to lib/models/image_exif.dart index 8fccc28..1c09126 100644 --- a/lib/models/exif.dart +++ b/lib/models/image_exif.dart @@ -1,75 +1,73 @@ -import 'dart:async'; import 'dart:collection'; - import 'package:flutter/foundation.dart'; -import 'package:image/image.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; + +class ImageExifModel extends ChangeNotifier { + ImageExifModel({this.path = ""}); -class ExifModel extends ChangeNotifier { - String? _path; - Future? _image; - Image? _imageData; + final String path; + final GlobalKey _formKey = GlobalKey(); + img.Image? _imageData; + bool _loading = false; + img.Image? _image; List _exifItems = []; - String get path => _path ?? ""; + bool get loading => _loading; + + img.Image? get image => _image; - Future? get image => _image; + img.Image? get imageData => _imageData; - Image? get imageData => _imageData; + GlobalKey get formKey => _formKey; UnmodifiableListView get exifItems => UnmodifiableListView(_exifItems); - void setImagePath(String path) { - _path = path; - _image = _fetchExifData(_path!); + fetchImageExifInfo() async { + if (path.isEmpty) return; + _loading = true; + _image = await compute((message) => img.decodeImageFile(message), path); + _setImageData(_image?.clone()); + _setExifItems(_imageData); + _loading = false; notifyListeners(); } - void setImageData(Image? image) { + void _setImageData(img.Image? image) { if (image == null) return; _imageData = image; - } - - void clearImage() { - _path = null; - _image = null; - _exifItems.clear(); notifyListeners(); } - Future _fetchExifData(String path) { - return compute((path) { - return path.isEmpty ? null : decodeImageFile(path).then((value) => value); - }, path); - } - - void setExifItems(Image? image) { + void _setExifItems(img.Image? image) { if (image == null) { return; } + final List items = []; final directories = image.exif; final getTagName = directories.getTagName; - List items = []; for (final name in directories.keys) { - List> info = []; + List> info = []; final directory = directories[name]; for (final tag in directory.keys) { final value = directory[tag]; final tagName = getTagName(tag); - if (tagName != "" && value?.type != IfdValueType.undefined) { + if (tagName != "" && + value?.type != img.IfdValueType.undefined) { info.add({tagName: value}); } } items.add(ExifItem(name, info)); for (final subName in directory.sub.keys) { - List> subInfo = []; + List> subInfo = []; final subDirectory = directory.sub[subName]; for (final tag in subDirectory.keys) { final value = subDirectory[tag]; final subTagName = _getSubTagName(subName, tag); if (subTagName != "" && - value?.type != IfdValueType.undefined) { + value?.type != img.IfdValueType.undefined) { subInfo.add({subTagName: value}); } } @@ -77,11 +75,13 @@ class ExifModel extends ChangeNotifier { } } _exifItems = items; + notifyListeners(); } - void changeExifValue( - Map info, String tag, String key, String value) { - IfdValue? ifdValue = info[key]?.clone(); + void changeExifValue(Map info, ExifItem exifItem, + String key, String value) { + img.IfdValue? ifdValue = info[key]?.clone(); + String tag = exifItem.tag; if (ifdValue != null && value.isNotEmpty) { try { _setIfdValue(ifdValue, key, value); @@ -104,7 +104,14 @@ class ExifModel extends ChangeNotifier { } } - void _setIfdValue(IfdValue ifdValue, String key, String value) { + void resetExif() { + _formKey.currentState?.reset(); + _setImageData(_image?.clone()); + _setExifItems(_imageData); + notifyListeners(); + } + + void _setIfdValue(img.IfdValue ifdValue, String key, String value) { if (value.startsWith("[") && value.endsWith("]")) { List valueArray = value.substring(1, value.length - 1).split(","); for (int i = 0; i < valueArray.length; i++) { @@ -126,15 +133,15 @@ class ExifModel extends ChangeNotifier { } void _setSinglesValue( - {required IfdValue ifdValue, required String value, index = 0}) { + {required img.IfdValue ifdValue, required String value, index = 0}) { Type type = ifdValue.runtimeType; switch (type) { - case const (IfdByteValue): - case const (IfdValueShort): - case const (IfdValueLong): - case const (IfdValueSByte): - case const (IfdValueSShort): - case const (IfdValueSLong): + case const (img.IfdByteValue): + case const (img.IfdValueShort): + case const (img.IfdValueLong): + case const (img.IfdValueSByte): + case const (img.IfdValueSShort): + case const (img.IfdValueSLong): { int? result = int.tryParse(value); if (result != null) { @@ -142,8 +149,8 @@ class ExifModel extends ChangeNotifier { } } break; - case const (IfdValueSingle): - case const (IfdValueDouble): + case const (img.IfdValueSingle): + case const (img.IfdValueDouble): { double? result = double.tryParse(value); if (result != null) { @@ -151,8 +158,8 @@ class ExifModel extends ChangeNotifier { } } break; - case const (IfdValueRational): - case const (IfdValueSRational): + case const (img.IfdValueRational): + case const (img.IfdValueSRational): { final array = value.split("/"); if (array.length != 2) { @@ -168,7 +175,7 @@ class ExifModel extends ChangeNotifier { } } break; - case const (IfdValueAscii): + case const (img.IfdValueAscii): ifdValue.setString(value); break; default: @@ -180,24 +187,24 @@ class ExifModel extends ChangeNotifier { switch (subName) { case "gps": { - if (!exifGpsTags.containsKey(tag)) { + if (!img.exifGpsTags.containsKey(tag)) { return ""; } - return exifGpsTags[tag]!.name; + return img.exifGpsTags[tag]!.name; } case "interop": { - if (!exifInteropTags.containsKey(tag)) { + if (!img.exifInteropTags.containsKey(tag)) { return ""; } - return exifInteropTags[tag]!.name; + return img.exifInteropTags[tag]!.name; } default: { - if (!exifImageTags.containsKey(tag)) { + if (!img.exifImageTags.containsKey(tag)) { return ""; } - return exifImageTags[tag]!.name; + return img.exifImageTags[tag]!.name; } } } @@ -207,5 +214,5 @@ class ExifItem { ExifItem(this.tag, this.info); String tag; - List> info; + List> info; } diff --git a/lib/models/image_path.dart b/lib/models/image_path.dart new file mode 100644 index 0000000..4b676f6 --- /dev/null +++ b/lib/models/image_path.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class ImagePathModel extends ChangeNotifier { + String _imagePath = ""; + + String get imagePath => _imagePath; + + set imagePath(String value) { + _imagePath = value; + notifyListeners(); + } + + void clearImage() { + _imagePath = ""; + notifyListeners(); + } +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart deleted file mode 100644 index a38c7f5..0000000 --- a/lib/screens/home.dart +++ /dev/null @@ -1,451 +0,0 @@ -import 'dart:async'; - -import 'package:exif_helper/extensions/platform_extension.dart'; -import 'package:exif_helper/models/search.dart'; -import 'package:exif_helper/widgets/dashed_container.dart'; -import 'package:exif_helper/widgets/desktop_image_panel.dart'; -import 'package:exif_helper/widgets/image_panel.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:image/image.dart' as image; -import 'package:path/path.dart' as path; -import 'package:provider/provider.dart'; - -import '../common/constant.dart'; -import '../models/exif.dart'; - -enum _Menu { clear, reset } - -class HomePage extends StatefulWidget { - const HomePage({super.key}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - final ScrollController _scrollController = ScrollController(); - final List _allowedExtensions = ["jpg", "tif", "jpeg", "tiff"]; - final double fileIconSize = 64.0; - bool _isDragging = false; - - final GlobalKey _formKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, exifModel, child) { - return Consumer(builder: (ctx, searchModel, child) { - return Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - title: Text(AppLocalizations.of(context)!.exif), - actions: [ - AnimatedContainer( - width: searchModel.showSearch ? 200 : 0, - height: 40, - duration: const Duration(milliseconds: 100), - child: TextField( - focusNode: searchModel.searchFocusNode, - controller: searchModel.searchController, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - hintText: AppLocalizations.of(context)!.searchExif, - suffixIcon: searchModel.searchText.isNotEmpty && - searchModel.showSearch - ? IconButton( - onPressed: () { - searchModel.searchController.clear(); - searchModel.clearSearchText(); - }, - icon: const Icon(Icons.clear_outlined), - ) - : null, - ), - onEditingComplete: searchModel.searchExif, - onChanged: (text) { - searchModel.setSearchText(text); - }, - ), - ), - IconButton( - onPressed: searchModel.searchExif, - icon: const Icon(Icons.search_outlined), - ), - PopupMenuButton<_Menu>( - icon: const Icon(Icons.more_vert), - onSelected: _menuSelected, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem<_Menu>( - value: _Menu.reset, - child: ListTile( - leading: const Icon(Icons.refresh_outlined), - title: - Text(AppLocalizations.of(context)!.resetExif), - ), - ), - PopupMenuItem<_Menu>( - value: _Menu.clear, - child: ListTile( - leading: const Icon(Icons.clear_all_outlined), - title: Text( - AppLocalizations.of(context)!.clearImage), - ), - ), - ], - ), - ], - pinned: true, - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(normalPadding), - child: InkWell( - onTap: _selectImage, - child: DashedContainer( - width: double.infinity, - height: 300, - color: _isDragging - ? Colors.red.withOpacity(0.2) - : Colors.grey.withOpacity(0.2), - child: PlatformExtension.isDesktop - ? DesktopImagePanel( - imagePath: exifModel.path, - onDragEntered: (detail) { - setState(() { - _isDragging = true; - }); - }, - onDragExited: (detail) { - setState(() { - _isDragging = false; - }); - }, - onDragDone: (path) { - setState(() { - _isDragging = false; - }); - if (path != null) { - _setImagePath(path); - } - }, - ) - : ImagePanel(imagePath: exifModel.path), - ), - ), - ), - ), - exifModel.path.isEmpty - ? SliverFillRemaining( - hasScrollBody: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(AppLocalizations.of(context)! - .supportImageFormatBelow), - const SizedBox( - height: smallMargin, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var extension in _allowedExtensions) - Padding( - padding: const EdgeInsets.all( - normalPadding / 2), - child: SvgPicture.asset( - "assets/images/$extension.svg", - width: fileIconSize, - height: fileIconSize, - ), - ), - ], - ), - ], - ), - ) - : FutureBuilder( - future: exifModel.image, - builder: (context, snapshot) { - return snapshot.connectionState == - ConnectionState.done - ? (() { - image.Image? img = snapshot.data; - exifModel.setImageData(img); - exifModel.setExifItems(img); - return img == null - ? SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - AppLocalizations.of(context)! - .noExifData), - ), - ) - : _buildExifData( - exifModel.exifItems.toList()); - }()) - : const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - ), - ], - ), - if (exifModel.path.isNotEmpty) - Align( - alignment: Alignment.bottomCenter, - child: Container( - padding: const EdgeInsets.all( - normalPadding, - ), - width: normalButtonWidth, - child: FilledButton( - child: Text(AppLocalizations.of(context)!.save), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: - Text(AppLocalizations.of(context)!.saveImage), - content: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: normalButtonWidth, - ), - child: Text(AppLocalizations.of(context)! - .saveImageInfo), - ), - actions: [ - TextButton( - child: Text( - AppLocalizations.of(context)!.cancel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - FilledButton( - child: Text(AppLocalizations.of(context)!.ok), - onPressed: () { - Navigator.of(context).pop(); - _saveExifData(); - }, - ), - ], - ); - }, - ); - }, - ), - ), - ), - ], - ); - }); - }, - ); - } - - Widget _buildExifData(List exifModels) { - return Form( - key: _formKey, - child: SliverList.separated( - itemBuilder: (BuildContext context, int index) { - ExifItem exifModel = exifModels[index]; - return Container( - margin: index == exifModels.length - 1 - ? const EdgeInsets.only(bottom: 80) - : EdgeInsets.zero, - child: Card( - margin: const EdgeInsets.symmetric( - horizontal: normalMargin, - ), - child: Padding( - padding: const EdgeInsets.all(normalPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - exifModel.tag.toUpperCase(), - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - for (final info in exifModel.info) - Column( - children: _buildExifInfo(info, exifModel), - ) - ], - ), - ), - ), - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const SizedBox( - height: normalMargin, - ); - }, - itemCount: exifModels.length, - ), - ); - } - - List _buildExifInfo( - Map info, ExifItem exifItem) { - String query = context.watch().searchText; - return info.keys - .where( - (element) => element.contains(RegExp(query, caseSensitive: false))) - .map((key) => Padding( - padding: const EdgeInsets.symmetric(vertical: normalPadding), - child: Row( - children: [ - Expanded( - flex: 1, - child: Text(key), - ), - const SizedBox( - width: normalMargin, - ), - Expanded( - flex: 1, - child: TextFormField( - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - ), - initialValue: info[key]!.toString(), - onChanged: (value) { - Provider.of(context, listen: false) - .changeExifValue(info, exifItem.tag, key, value); - }, - ), - ), - ], - ), - )) - .toList(); - } - - void _menuSelected(_Menu item) { - switch (item) { - case _Menu.reset: - _resetExif(); - break; - case _Menu.clear: - _clearImage(); - break; - } - } - - void _resetExif() { - _formKey.currentState?.reset(); - } - - void _clearImage() { - Provider.of(context, listen: false).clearImage(); - } - - void _selectImage() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: _allowedExtensions, - allowMultiple: false, - ); - if (result != null) { - _setImagePath(result.files.single.path!); - } - } - - void _setImagePath(String path) { - Provider.of(context, listen: false).setImagePath(path); - } - - void _saveExifData() async { - final imagePath = Provider.of(context, listen: false).path; - String extension = path.extension(imagePath).substring(1); - String fileName = AppLocalizations.of(context)! - .fileCopy(path.basenameWithoutExtension(imagePath), extension); - if (PlatformExtension.isMobile) { - _saveFileToMobile(fileName, extension); - } else { - _saveFile(fileName, extension); - } - } - - void _saveFile(String fileName, String extension) { - final appContext = AppLocalizations.of(context)!; - FilePicker.platform.saveFile( - dialogTitle: appContext.saveImage, - type: FileType.custom, - fileName: fileName, - allowedExtensions: [extension], - ).then((path) async { - if (path != null) { - String lowerCaseExtension = extension.toLowerCase(); - Future? future; - if (lowerCaseExtension == "jpg" || lowerCaseExtension == "jpeg") { - future = image.encodeJpgFile( - path, Provider.of(context, listen: false).imageData!); - } else if (lowerCaseExtension == "tif" || - lowerCaseExtension == "tiff") { - future = image.encodeTiffFile( - path, Provider.of(context, listen: false).imageData!); - } - future?.then((success) { - _showTips(success ? appContext.saveSuccess : appContext.saveFailed); - }); - } - }); - } - - void _saveFileToMobile(String fileName, String extension) async { - final appContext = AppLocalizations.of(context)!; - String lowerCaseExtension = extension.toLowerCase(); - Uint8List? bytes; - if (lowerCaseExtension == "jpg" || lowerCaseExtension == "jpeg") { - bytes = image - .encodeJpg(Provider.of(context, listen: false).imageData!); - } else if (lowerCaseExtension == "tif" || lowerCaseExtension == "tiff") { - bytes = image.encodeTiff( - Provider.of(context, listen: false).imageData!); - } else { - _showTips(appContext.invalidImageType); - } - if (bytes != null) { - FilePicker.platform - .saveFile( - dialogTitle: appContext.saveImage, - type: FileType.custom, - fileName: fileName, - allowedExtensions: [extension], - bytes: bytes, - ) - .then((path) async { - _showTips( - path != null ? appContext.saveSuccess : appContext.saveFailed); - }); - } - } - - void _showTips(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 1), - content: Text(message), - ), - ); - } -} diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart new file mode 100644 index 0000000..54a6e3c --- /dev/null +++ b/lib/screens/home/home.dart @@ -0,0 +1,55 @@ +import 'package:exif_helper/models/search.dart'; +import 'package:exif_helper/screens/home/home_app_bar.dart'; +import 'package:exif_helper/screens/home/home_image_container.dart'; +import 'package:exif_helper/screens/home/home_image_exif.dart'; +import 'package:exif_helper/screens/home/home_save_button.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + CustomScrollView( + slivers: [ + const HomeAppBar( + key: ValueKey("home_app_bar"), + ), + const HomeImageContainer( + key: ValueKey("home_select_image_container"), + ), + Consumer( + builder: (context, searchModel, child) { + return HomeExifContainer( + key: const ValueKey("home_exif_container"), + query: searchModel.searchText, + ); + }, + ), + ], + ), + const HomeSaveButton( + key: ValueKey("home_save_button"), + ), + ], + ); + } +} diff --git a/lib/screens/home/home_app_bar.dart b/lib/screens/home/home_app_bar.dart new file mode 100644 index 0000000..9338eef --- /dev/null +++ b/lib/screens/home/home_app_bar.dart @@ -0,0 +1,91 @@ +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/models/search.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +enum Menu { clear, reset } + +typedef OnSearch = void Function(String text); + +class HomeAppBar extends StatefulWidget { + const HomeAppBar({super.key}); + + @override + State createState() => _HomeAppBarState(); +} + +class _HomeAppBarState extends State { + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, searchModel, child) { + return SliverAppBar( + title: Text(AppLocalizations.of(context)!.exif), + actions: [ + AnimatedContainer( + width: searchModel.showSearch ? 200 : 0, + height: 40, + duration: const Duration(milliseconds: 100), + child: TextField( + focusNode: searchModel.searchFocusNode, + controller: searchModel.searchController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + hintText: AppLocalizations.of(context)!.searchExif, + suffixIcon: + searchModel.searchText.isNotEmpty && searchModel.showSearch + ? IconButton( + onPressed: () { + searchModel.searchController.clear(); + searchModel.clearSearchText(); + }, + icon: const Icon(Icons.clear_outlined), + ) + : null, + ), + onEditingComplete: searchModel.searchExif, + onChanged: (text) { + searchModel.setSearchText(text); + }, + ), + ), + IconButton( + onPressed: searchModel.searchExif, + icon: const Icon(Icons.search_outlined), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: _onMenuSelected, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: Menu.reset, + child: ListTile( + leading: const Icon(Icons.refresh_outlined), + title: Text(AppLocalizations.of(context)!.resetExif), + ), + ), + PopupMenuItem( + value: Menu.clear, + child: ListTile( + leading: const Icon(Icons.clear_all_outlined), + title: Text(AppLocalizations.of(context)!.clearImage), + ), + ), + ], + ), + ], + pinned: true, + ); + }); + } + + void _onMenuSelected(Menu item) { + if (item == Menu.reset) { + Provider.of(context, listen: false).resetExif(); + } + if (item == Menu.clear) { + Provider.of(context, listen: false).clearImage(); + } + } +} diff --git a/lib/screens/home/home_image_container.dart b/lib/screens/home/home_image_container.dart new file mode 100644 index 0000000..2ad145b --- /dev/null +++ b/lib/screens/home/home_image_container.dart @@ -0,0 +1,84 @@ +import 'package:exif_helper/common/constant.dart'; +import 'package:exif_helper/extensions/platform_extension.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/widgets/dashed_container.dart'; +import 'package:exif_helper/widgets/desktop_image_panel.dart'; +import 'package:exif_helper/widgets/image_panel.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +typedef OnSelectImage = void Function(String path); + +class HomeImageContainer extends StatefulWidget { + const HomeImageContainer({super.key}); + + @override + State createState() => _HomeImageContainerState(); +} + +class _HomeImageContainerState extends State { + bool _isDragging = false; + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, imagePathModel, child) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(normalPadding), + child: InkWell( + onTap: _selectImage, + child: DashedContainer( + width: double.infinity, + height: 300, + color: _isDragging + ? Colors.red.withOpacity(0.2) + : Colors.grey.withOpacity(0.2), + child: PlatformExtension.isDesktop + ? DesktopImagePanel( + key: const ValueKey("home_image_panel"), + imagePath: imagePathModel.imagePath, + onDragEntered: (detail) { + setState(() { + _isDragging = true; + }); + }, + onDragExited: (detail) { + setState(() { + _isDragging = false; + }); + }, + onDragDone: (path) { + setState(() { + _isDragging = false; + }); + if (path != null) { + imagePathModel.imagePath = path; + } + }, + ) + : ImagePanel( + imagePath: imagePathModel.imagePath, + key: const ValueKey("home_image_panel"), + ), + ), + ), + ), + ); + }); + } + + void _selectImage() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: allowedExtensions, + allowMultiple: false, + ); + if (result != null) { + if (mounted) { + Provider.of(context, listen: false).imagePath = + result.files.single.path!; + } + } + } +} diff --git a/lib/screens/home/home_image_exif.dart b/lib/screens/home/home_image_exif.dart new file mode 100644 index 0000000..e23398b --- /dev/null +++ b/lib/screens/home/home_image_exif.dart @@ -0,0 +1,148 @@ +import 'package:exif_helper/models/image_exif.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; +import 'package:image/image.dart' as image; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:exif_helper/common/constant.dart'; + +class HomeExifContainer extends StatefulWidget { + const HomeExifContainer({super.key, this.query = ''}); + + final String query; + + @override + State createState() => _HomeExifContainerState(); +} + +class _HomeExifContainerState extends State { + final double fileIconSize = 64.0; + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, imageExifModel, child) { + if (imageExifModel.loading) { + return const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + if (imageExifModel.image != null) { + return _buildExifData(imageExifModel); + } + return SliverFillRemaining( + hasScrollBody: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context)!.supportImageFormatBelow), + const SizedBox( + height: smallMargin, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var extension in allowedExtensions) + Padding( + padding: const EdgeInsets.all(normalPadding / 2), + child: SvgPicture.asset( + "assets/images/$extension.svg", + width: fileIconSize, + height: fileIconSize, + ), + ), + ], + ), + ], + ), + ); + }); + } + + Widget _buildExifData(ImageExifModel imageExifModel) { + final exifItems = imageExifModel.exifItems; + return Form( + key: imageExifModel.formKey, + child: SliverList.separated( + itemBuilder: (BuildContext context, int index) { + ExifItem exif = exifItems[index]; + return Container( + margin: index == exifItems.length - 1 + ? const EdgeInsets.only(bottom: 80) + : EdgeInsets.zero, + child: Card( + margin: const EdgeInsets.symmetric( + horizontal: normalMargin, + ), + child: Padding( + padding: const EdgeInsets.all(normalPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exif.tag.toUpperCase(), + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + for (final info in exif.info) + Column( + children: _buildExifInfo(info, exif), + ) + ], + ), + ), + ), + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const SizedBox( + height: normalMargin, + ); + }, + itemCount: exifItems.length, + )); + } + + List _buildExifInfo( + Map info, ExifItem exifItem) { + return info.keys.map( + (key) { + final show = key.contains(RegExp(widget.query, caseSensitive: false)); + return SizedBox.fromSize( + size: show ? null : Size.zero, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: normalPadding), + child: Row( + children: [ + Expanded( + flex: 1, + child: Text(key), + ), + const SizedBox( + width: normalMargin, + ), + Expanded( + flex: 1, + child: TextFormField( + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + ), + initialValue: info[key]!.toString(), + onChanged: (value) { + Provider.of(context, listen: false) + .changeExifValue(info, exifItem, key, value); + }, + ), + ), + ], + ), + ), + ); + }, + ).toList(); + } +} diff --git a/lib/screens/home/home_save_button.dart b/lib/screens/home/home_save_button.dart new file mode 100644 index 0000000..f05ab7e --- /dev/null +++ b/lib/screens/home/home_save_button.dart @@ -0,0 +1,142 @@ +import 'package:exif_helper/common/constant.dart'; +import 'package:exif_helper/common/utils.dart'; +import 'package:exif_helper/extensions/platform_extension.dart'; +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as image; +import 'dart:typed_data'; + +class HomeSaveButton extends StatefulWidget { + const HomeSaveButton({super.key}); + + @override + State createState() => _HomeSaveButtonState(); +} + +class _HomeSaveButtonState extends State { + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, exifModel, child) { + if (exifModel.imageData != null) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.all( + normalPadding, + ), + width: normalButtonWidth, + child: FilledButton( + child: Text(AppLocalizations.of(context)!.save), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.saveImage), + content: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: normalButtonWidth, + ), + child: + Text(AppLocalizations.of(context)!.saveImageInfo), + ), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: Text(AppLocalizations.of(context)!.ok), + onPressed: () { + Navigator.of(context).pop(); + _saveExifData(); + }, + ), + ], + ); + }, + ); + }, + ), + ), + ); + } + return Container(); + }); + } + + void _saveExifData() async { + final imagePath = + Provider.of(context, listen: false).imagePath; + String extension = path.extension(imagePath).substring(1); + String fileName = AppLocalizations.of(context)! + .fileCopy(path.basenameWithoutExtension(imagePath), extension); + image.Image imageData = + Provider.of(context, listen: false).imageData!; + if (PlatformExtension.isMobile) { + _saveFileToMobile(fileName, imageData, extension); + } else { + _saveFile(fileName, imageData, extension); + } + } + + void _saveFile(String fileName, image.Image imageData, String extension) { + final appContext = AppLocalizations.of(context)!; + FilePicker.platform.saveFile( + dialogTitle: appContext.saveImage, + type: FileType.custom, + fileName: fileName, + allowedExtensions: [extension], + ).then((path) async { + if (path != null) { + String lowerCaseExtension = extension.toLowerCase(); + Future? future; + if (lowerCaseExtension == "jpg" || lowerCaseExtension == "jpeg") { + future = image.encodeJpgFile(path, imageData); + } else if (lowerCaseExtension == "tif" || + lowerCaseExtension == "tiff") { + future = image.encodeTiffFile(path, imageData); + } + future?.then((success) { + SnackBarUtils.showSnackBar(context, + success ? appContext.saveSuccess : appContext.saveFailed); + }); + } + }); + } + + void _saveFileToMobile( + String fileName, image.Image imageData, String extension) async { + final appContext = AppLocalizations.of(context)!; + String lowerCaseExtension = extension.toLowerCase(); + Uint8List? bytes; + if (lowerCaseExtension == "jpg" || lowerCaseExtension == "jpeg") { + bytes = image.encodeJpg(imageData); + } else if (lowerCaseExtension == "tif" || lowerCaseExtension == "tiff") { + bytes = image.encodeTiff(imageData); + } else { + SnackBarUtils.showSnackBar(context, appContext.invalidImageType); + } + if (bytes != null) { + FilePicker.platform + .saveFile( + dialogTitle: appContext.saveImage, + type: FileType.custom, + fileName: fileName, + allowedExtensions: [extension], + bytes: bytes, + ) + .then((path) async { + SnackBarUtils.showSnackBar(context, + path != null ? appContext.saveSuccess : appContext.saveFailed); + }); + } + } +} diff --git a/lib/screens/index.dart b/lib/screens/index.dart index fe31bc6..5ceb236 100644 --- a/lib/screens/index.dart +++ b/lib/screens/index.dart @@ -1,7 +1,8 @@ import 'package:exif_helper/common/constant.dart'; -import 'package:exif_helper/models/exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/models/image_exif.dart'; import 'package:exif_helper/models/search.dart'; -import 'package:exif_helper/screens/home.dart'; +import 'package:exif_helper/screens/home/home.dart'; import 'package:exif_helper/screens/settings.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -64,16 +65,24 @@ class _IndexPageState extends State with WindowListener { Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (context) => ExifModel()), ChangeNotifierProvider(create: (context) => SearchModel()), + ChangeNotifierProvider(create: (context) => ImagePathModel()), ], - child: OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - if (orientation == Orientation.landscape) { - return _buildLandscape(); - } - return _buildPortrait(); + child: ChangeNotifierProxyProvider( + create: (context) => ImageExifModel(), + update: (context, imagePathModel, previous) { + final exifModel = ImageExifModel(path: imagePathModel.imagePath); + exifModel.fetchImageExifInfo(); + return exifModel; }, + child: OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + if (orientation == Orientation.landscape) { + return _buildLandscape(); + } + return _buildPortrait(); + }, + ), ), ); } @@ -149,10 +158,12 @@ class _IndexPageState extends State with WindowListener { } Widget _buildIcon(_ScaffoldDestination destination) { - bool selected = _selectedIndex == _destinations.indexOf(destination); + int index = _destinations.indexOf(destination); + bool selected = _selectedIndex == index; const double selectedScale = 1.2; const double unselectedScale = 1.0; return AnimatedSwitcher( + key: ValueKey("navigation_$index"), duration: const Duration(milliseconds: 150), switchInCurve: Curves.easeIn, switchOutCurve: Curves.easeOut, @@ -168,7 +179,7 @@ class _IndexPageState extends State with WindowListener { }, child: Icon( selected ? destination.selectedIcon : destination.icon, - key: ValueKey(_selectedIndex), + key: ValueKey("navigation_icon_$_selectedIndex"), ), ); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index aac0f4c..b25fd34 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -19,33 +19,33 @@ class _SettingsPageState extends State { void _buildSettings() { _settings = [ _SettingItem( - const Key("theme"), + const ValueKey("theme"), icon: Icons.color_lens, title: AppLocalizations.of(context)!.theme, options: ThemeMode.values, value: context.watch().currentThemeMode, ), _SettingItem( - const Key("language"), + const ValueKey("language"), icon: Icons.language, title: AppLocalizations.of(context)!.language, options: Language.values, value: context.watch().currentLanguage, ), _SettingItem( - const Key("privacy"), + const ValueKey("privacy"), icon: Icons.gpp_good_outlined, title: AppLocalizations.of(context)!.privacy, url: Uri.parse(AppLocalizations.of(context)!.privacyUrl), ), _SettingItem( - const Key("terms"), + const ValueKey("terms"), icon: Icons.insert_drive_file_outlined, title: AppLocalizations.of(context)!.terms, url: Uri.parse(AppLocalizations.of(context)!.termsUrl), ), _SettingItem( - const Key("about"), + const ValueKey("about"), icon: Icons.info_outline, title: AppLocalizations.of(context)!.about, ), @@ -69,6 +69,7 @@ class _SettingsPageState extends State { itemBuilder: (context, index) { _SettingItem item = _settings[index]; return ListTile( + key: item.key, minVerticalPadding: normalPadding, leading: Icon( item.icon, diff --git a/pubspec.lock b/pubspec.lock index a21c535..cbe4fad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: "direct main" description: @@ -197,10 +197,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.0.6" + version: "1.0.8" dart_style: dependency: transitive description: @@ -245,10 +245,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 + sha256: "45c70b43df893027e441a6fa0aacc8f484fb9f9c60c746dc8f1dc4f774cf55cd" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "8.0.0+1" + version: "8.0.2" fixnum: dependency: transitive description: @@ -279,10 +279,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.0.17" + version: "2.0.19" flutter_svg: dependency: "direct main" description: @@ -313,10 +313,10 @@ packages: dependency: transitive description: name: get_it - sha256: "36524bfb3f0b4ec952c3202466fdd69ad1f7ac1dd9b0a7564177707e45bfaeb9" + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "7.6.8" + version: "7.7.0" glob: dependency: transitive description: @@ -569,10 +569,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" + sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.7.4" + version: "3.9.0" pool: dependency: transitive description: @@ -625,18 +625,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: @@ -782,18 +782,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "6.2.5" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: @@ -830,10 +830,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.3.0" + version: "2.3.1" url_launcher_windows: dependency: transitive description: @@ -910,10 +910,10 @@ packages: dependency: transitive description: name: win32 - sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "5.3.0" + version: "5.4.0" window_manager: dependency: "direct main" description: diff --git a/test/common/utils_test.dart b/test/common/utils_test.dart new file mode 100644 index 0000000..9ab9c3d --- /dev/null +++ b/test/common/utils_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:exif_helper/common/utils.dart'; + +void main() { + const String message = "Test message"; + late Widget widget; + + setUp(() { + widget = MaterialApp( + home: Scaffold( + body: Center( + child: Container(), + ), + ), + ); + }); + + group("test SnackBarUtils show correctly", () { + testWidgets( + 'SnackBarUtils.showSnackBar displays a SnackBar with the given message', + (WidgetTester tester) async { + await tester.pumpWidget(widget); + final context = tester.element(find.byType(Scaffold)); + SnackBarUtils.showSnackBar(context, message); + await tester.pump(); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.widgetWithText(SnackBar, message), findsOneWidget); + }); + + testWidgets( + 'SnackBarUtils.showSnackBar displays the SnackBar for the specified duration', + (WidgetTester tester) async { + await tester.pumpWidget(widget); + final context = tester.element(find.byType(Scaffold)); + const duration = Duration(seconds: 3); + SnackBarUtils.showSnackBar(context, message, duration: duration); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + }); + }); +} diff --git a/test/mock/mock.dart b/test/mock/mock.dart new file mode 100644 index 0000000..411ffd9 --- /dev/null +++ b/test/mock/mock.dart @@ -0,0 +1,5 @@ +import 'package:mockito/annotations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +@GenerateMocks([SharedPreferences]) +void main() {} diff --git a/test/models/system_test.mocks.dart b/test/mock/system_test.mocks.dart similarity index 100% rename from test/models/system_test.mocks.dart rename to test/mock/system_test.mocks.dart diff --git a/test/models/exif_test.dart b/test/models/exif_test.dart deleted file mode 100644 index 191ed54..0000000 --- a/test/models/exif_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:exif_helper/models/exif.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group("test ExifModel", () { - final exifModel = ExifModel(); - const imagePath = "test/test_image.jpg"; - - test('ExifModel should set image path correctly', () { - exifModel.setImagePath(imagePath); - expect(exifModel.path, imagePath); - }); - - test('ExifModel should fetch image data correctly', () async { - exifModel.setImagePath(imagePath); - final image = await exifModel.image; - expect(image, isNotNull); - }); - - test("ExifModel should set items correctly", () async { - exifModel.setImagePath(imagePath); - final image = await exifModel.image; - exifModel.setExifItems(image); - expect(exifModel.exifItems, isNotEmpty); - }); - - test('ExifModel should set image data correctly', () async { - exifModel.setImagePath(imagePath); - final image = await exifModel.image; - exifModel.setImageData(image); - expect(exifModel.imageData, isNotNull); - }); - - test('ExifModel should change exif value correctly', () async { - exifModel.setImagePath(imagePath); - final image = await exifModel.image; - exifModel.setImageData(image); - final value = exifModel.imageData!.clone(); - ExifItem exifItem = exifModel.exifItems.first; - final info = exifItem.info.first; - exifModel.changeExifValue(info, exifItem.tag, info.keys.first, "Test456"); - expect(exifModel.imageData!.exif, isNot(equals(value.exif))); - }); - - test('ExifModel should clear image correctly', () { - exifModel.setImagePath(imagePath); - exifModel.clearImage(); - expect(exifModel.path, ''); - expect(exifModel.image, isNull); - expect(exifModel.exifItems.isEmpty, isTrue); - }); - }); -} diff --git a/test/models/image_exif_test.dart b/test/models/image_exif_test.dart new file mode 100644 index 0000000..49938be --- /dev/null +++ b/test/models/image_exif_test.dart @@ -0,0 +1,62 @@ +import 'package:exif_helper/models/image_exif.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + group("test ImageExifModel", () { + const imagePath = "test/test_image.jpg"; + late ImageExifModel imageExifModel; + + setUp(() async { + imageExifModel = ImageExifModel(path: imagePath); + await imageExifModel.fetchImageExifInfo(); + }); + + test('ImageExifModel should set image path correctly', () { + expect(imageExifModel.path, imagePath); + }); + + test('ImageExifModel should fetch image data correctly', () async { + expect(imageExifModel.image, isNotNull); + }); + + test("ImageExifModel should set items correctly", () async { + expect(imageExifModel.exifItems, isNotEmpty); + }); + + test('ImageExifModel should set image data correctly', () async { + expect(imageExifModel.imageData, isNotNull); + }); + + testWidgets('ImageExifModel should change and reset exif value correctly', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ChangeNotifierProvider( + create: (context) => imageExifModel, + child: Consumer( + builder: (context, model, child) { + return Form( + key: model.formKey, + child: TextFormField(), + ); + }, + ), + ), + ), + )); + await tester.pumpAndSettle(); + final value = imageExifModel.imageData!.clone(); + ExifItem exifItem = imageExifModel.exifItems.first; + final info = exifItem.info.first; + imageExifModel.changeExifValue( + info, exifItem, info.keys.first, "Test456"); + expect(imageExifModel.imageData!.exif, isNot(equals(value.exif))); + imageExifModel.resetExif(); + final expected = imageExifModel.imageData!.exif.toString(); + final actual = value.exif.toString(); + expect(expected, equals(actual)); + }); + }); +} diff --git a/test/models/image_path_test.dart b/test/models/image_path_test.dart new file mode 100644 index 0000000..6de884e --- /dev/null +++ b/test/models/image_path_test.dart @@ -0,0 +1,28 @@ +import 'package:exif_helper/models/image_path.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ImagePathModel', () { + late ImagePathModel imagePathModel; + const String path = "test/test_image.jpg"; + + setUp(() { + imagePathModel = ImagePathModel(); + }); + + test('initial imagePath is empty', () { + expect(imagePathModel.imagePath, ''); + }); + + test('setting imagePath updates the value', () { + imagePathModel.imagePath = path; + expect(imagePathModel.imagePath, path); + }); + + test('clearImage sets imagePath to empty', () { + imagePathModel.imagePath = path; + imagePathModel.clearImage(); + expect(imagePathModel.imagePath, ''); + }); + }); +} diff --git a/test/models/search_test.dart b/test/models/search_test_test.dart similarity index 100% rename from test/models/search_test.dart rename to test/models/search_test_test.dart diff --git a/test/models/system_test.dart b/test/models/system_test.dart index 343458e..940f105 100644 --- a/test/models/system_test.dart +++ b/test/models/system_test.dart @@ -1,13 +1,10 @@ import 'package:exif_helper/models/system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'system_test.mocks.dart'; +import '../mock/system_test.mocks.dart'; -@GenerateMocks([SharedPreferences]) void main() { group('test SystemModel', () { late MockSharedPreferences mockPrefs; diff --git a/test/screens/home/home_app_bar_test.dart b/test/screens/home/home_app_bar_test.dart new file mode 100644 index 0000000..e58aa5e --- /dev/null +++ b/test/screens/home/home_app_bar_test.dart @@ -0,0 +1,124 @@ +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/models/search.dart'; +import 'package:exif_helper/screens/home/home_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void main() { + group("test HomeAppBar build correctly", () { + const imagePath = "test/test_image.jpg"; + late Widget homeAppBarScreen; + late HomeAppBar appBar; + late SearchModel searchModel; + late ImagePathModel imagePathModel; + late ImageExifModel exifModel; + setUp(() { + appBar = const HomeAppBar(); + homeAppBarScreen = MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) { + searchModel = SearchModel(); + return searchModel; + }), + ChangeNotifierProvider(create: (context) { + imagePathModel = ImagePathModel(); + return imagePathModel; + }), + ], + child: ChangeNotifierProxyProvider( + create: (context) => ImageExifModel(), + update: (context, imagePathModel, previous) { + exifModel = ImageExifModel(path: imagePathModel.imagePath); + return exifModel; + }, + child: MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: CustomScrollView( + slivers: [ + appBar, + SliverToBoxAdapter( + child: Consumer( + builder: (context, exifModel, child) { + return Form( + key: exifModel.formKey, + child: Container(), + ); + }, + ), + ) + ], + ), + ), + ), + ), + ); + }); + testWidgets("Testing if views shows up", (WidgetTester tester) async { + await tester.pumpWidget(homeAppBarScreen); + await tester.pumpAndSettle(); + expect(find.byWidget(appBar), findsOneWidget); + BuildContext context = tester.element(find.byWidget(appBar)); + expect(find.text(AppLocalizations.of(context)!.exif), findsOneWidget); + expect(find.byIcon(Icons.search_outlined), findsOneWidget); + expect(find.byIcon(Icons.more_vert), findsOneWidget); + }); + testWidgets("Testing if search works", (WidgetTester tester) async { + await tester.pumpWidget(homeAppBarScreen); + await tester.pumpAndSettle(); + final searchButtonFinder = find.byIcon(Icons.search_outlined); + final textFieldFinder = find.byType(TextField); + const String text = "DateTime"; + await tester.tap(searchButtonFinder); + await tester.pumpAndSettle(); + await tester.enterText(textFieldFinder, text); + await tester.pumpAndSettle(); + expect(find.text(text), findsOneWidget); + expect(searchModel.searchText, text); + await tester.tap(searchButtonFinder); + await tester.pumpAndSettle(); + expect(searchModel.searchFocusNode.hasFocus, isTrue); + await tester.tap(find.byIcon(Icons.clear_outlined)); + await tester.pumpAndSettle(); + expect(find.text(text), findsNothing); + await tester.tap(searchButtonFinder); + await tester.pumpAndSettle(); + expect(searchModel.searchFocusNode.hasFocus, isFalse); + }); + testWidgets("Testing if reset works", (WidgetTester tester) async { + await tester.pumpWidget(homeAppBarScreen); + await tester.pumpAndSettle(); + imagePathModel.imagePath = imagePath; + await tester.pumpAndSettle(); + await tester.runAsync(() => exifModel.fetchImageExifInfo()); + final menuButtonFinder = find.byIcon(Icons.more_vert); + await tester.tap(menuButtonFinder); + await tester.pumpAndSettle(); + // Change Image Exif + final value = exifModel.imageData!.clone(); + ExifItem exifItem = exifModel.exifItems.first; + final info = exifItem.info.first; + exifModel.changeExifValue(info, exifItem, info.keys.first, "Test456"); + await tester.pumpAndSettle(); + // Reset Exif + final resetExifFinder = find.text( + AppLocalizations.of(tester.element(menuButtonFinder))!.resetExif); + await tester.tap(resetExifFinder); + await tester.pumpAndSettle(); + expect(exifModel.imageData!.exif.toString(), value.exif.toString()); + await tester.tap(menuButtonFinder); + await tester.pumpAndSettle(); + // Clear Image + final clearImageFinder = find.text( + AppLocalizations.of(tester.element(menuButtonFinder))!.clearImage); + await tester.tap(clearImageFinder); + await tester.pumpAndSettle(); + expect(imagePathModel.imagePath, ""); + }); + }); +} diff --git a/test/screens/home/home_image_container_test.dart b/test/screens/home/home_image_container_test.dart new file mode 100644 index 0000000..be302e0 --- /dev/null +++ b/test/screens/home/home_image_container_test.dart @@ -0,0 +1,71 @@ +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/screens/home/home_image_container.dart'; +import 'package:exif_helper/widgets/dashed_container.dart'; +import 'package:exif_helper/widgets/desktop_image_panel.dart'; +import 'package:exif_helper/widgets/image_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void main() { + group("test HomeImageContainer build correctly", () { + const imagePath = "test/test_image.jpg"; + late Widget homeImageContainerScreen; + late ImagePathModel imagePathModel; + + setUp(() { + homeImageContainerScreen = ChangeNotifierProvider( + create: (context) { + imagePathModel = ImagePathModel(); + return imagePathModel; + }, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: CustomScrollView( + slivers: [ + HomeImageContainer(), + ], + ), + ), + ), + ); + }); + + testWidgets("Test if views shows up", (WidgetTester tester) async { + await tester.pumpWidget(homeImageContainerScreen); + await tester.pumpAndSettle(); + expect(find.byType(SliverToBoxAdapter), findsOneWidget); + expect(find.byType(InkWell), findsOneWidget); + expect(find.byType(DashedContainer), findsOneWidget); + expect(find.byType(ImagePanel), findsOneWidget); + }); + testWidgets("Test if select image works", (WidgetTester tester) async { + await tester.pumpWidget(homeImageContainerScreen); + await tester.pumpAndSettle(); + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + // ่ฟ™้‡Œ้œ€่ฆๆจกๆ‹Ÿ้€‰ๆ‹ฉๅ›พ็‰‡ + imagePathModel.imagePath = imagePath; + await tester.pumpAndSettle(); + expect(imagePathModel.imagePath, imagePath); + final finder = find.byKey(const ValueKey("home_image_panel")); + expect(finder, findsOneWidget); + Widget widget = tester.widget(finder); + if (widget is DesktopImagePanel) { + final dropEventDetails = DropEventDetails( + localPosition: const Offset(10, 10), + globalPosition: const Offset(10, 10), + ); + widget.onDragEntered?.call(dropEventDetails); + widget.onDragUpdated?.call(dropEventDetails); + widget.onDragDone?.call(imagePath); + widget.onDragExited?.call(dropEventDetails); + } + expect(find.byType(Image), findsOneWidget); + }); + }); +} diff --git a/test/screens/home/home_image_exif_test.dart b/test/screens/home/home_image_exif_test.dart new file mode 100644 index 0000000..c3c343f --- /dev/null +++ b/test/screens/home/home_image_exif_test.dart @@ -0,0 +1,57 @@ +import 'package:exif_helper/common/constant.dart'; +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/screens/home/home_image_exif.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void main() { + group("test HomeExifContainer build correctly", () { + const imagePath = "test/test_image.jpg"; + late Widget homeExifContainerScreen; + late ImageExifModel imageExifModel; + + setUp(() { + homeExifContainerScreen = ChangeNotifierProvider( + create: (context) { + imageExifModel = ImageExifModel(path: imagePath); + return imageExifModel; + }, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: CustomScrollView( + slivers: [ + HomeExifContainer(), + ], + ), + ), + ), + ); + }); + + testWidgets("Test if views shows up", (WidgetTester tester) async { + await tester.pumpWidget(homeExifContainerScreen); + await tester.pumpAndSettle(); + expect( + find.text(AppLocalizations.of( + tester.element(find.byType(HomeExifContainer)))! + .supportImageFormatBelow), + findsOneWidget); + expect(find.byType(SvgPicture), findsNWidgets(allowedExtensions.length)); + }); + testWidgets("Test if fetch image exif info works", + (WidgetTester tester) async { + await tester.pumpWidget(homeExifContainerScreen); + await tester.pumpAndSettle(); + await tester.runAsync(() => imageExifModel.fetchImageExifInfo()); + await tester.pumpAndSettle(); + expect(find.byType(Form), findsOneWidget); + expect(find.byType(SliverList), findsOneWidget); + expect(find.byType(TextFormField), findsAtLeastNWidgets(1)); + }); + }); +} diff --git a/test/screens/home/home_save_button_test.dart b/test/screens/home/home_save_button_test.dart new file mode 100644 index 0000000..0d898c7 --- /dev/null +++ b/test/screens/home/home_save_button_test.dart @@ -0,0 +1,66 @@ +import 'package:exif_helper/screens/home/home_save_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void main() { + group('HomeSaveButton', () { + const imagePath = "test/test_image.jpg"; + late Widget homeSaveButtonScreen; + late ImageExifModel imageExifModel; + setUp(() { + homeSaveButtonScreen = MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) { + ImagePathModel imagePathModel = ImagePathModel(); + imagePathModel.imagePath = imagePath; + return imagePathModel; + }), + ChangeNotifierProvider(create: (context) { + imageExifModel = ImageExifModel(path: imagePath); + return imageExifModel; + }), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: HomeSaveButton(), + ), + ), + ); + }); + + testWidgets('Renders Save Button when imageData is null', (tester) async { + await tester.pumpWidget(homeSaveButtonScreen); + await tester.pumpAndSettle(); + expect(find.byType(FilledButton), findsNothing); + }); + + testWidgets('Does not render Save Button when imageData is not null', + (tester) async { + await tester.pumpWidget(homeSaveButtonScreen); + await tester.runAsync(() => imageExifModel.fetchImageExifInfo()); + await tester.pumpAndSettle(); + final saveButtonFinder = find.byType(FilledButton); + expect(saveButtonFinder, findsOneWidget); + await tester.tap(saveButtonFinder); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.widgetWithText( + TextButton, + AppLocalizations.of(tester.element(find.byType(AlertDialog)))! + .cancel)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + await tester.tap(saveButtonFinder); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, + AppLocalizations.of(tester.element(find.byType(AlertDialog)))!.ok)); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/test/screens/home/home_test.dart b/test/screens/home/home_test.dart new file mode 100644 index 0000000..5ed5b95 --- /dev/null +++ b/test/screens/home/home_test.dart @@ -0,0 +1,62 @@ +import 'package:exif_helper/models/image_exif.dart'; +import 'package:exif_helper/models/image_path.dart'; +import 'package:exif_helper/models/search.dart'; +import 'package:exif_helper/screens/home/home.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void main() { + group("test home screen build correctly", () { + late Widget homePage; + late ImageExifModel exifModel; + const imagePath = "test/test_image.jpg"; + setUp(() { + homePage = MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => SearchModel()), + ChangeNotifierProvider(create: (context) => ImagePathModel()), + ], + child: ChangeNotifierProxyProvider( + create: (context) => ImageExifModel(), + update: (context, imagePathModel, previous) { + exifModel = ImageExifModel(path: imagePathModel.imagePath); + return exifModel; + }, + child: const MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: HomePage(), + ), + ), + ), + ); + }); + testWidgets("Testing if views shows up", (WidgetTester tester) async { + await tester.pumpWidget(homePage); + await tester.pumpAndSettle(); + expect(find.byType(Stack), findsAtLeastNWidgets(1)); + expect(find.byType(CustomScrollView), findsOneWidget); + expect(find.byKey(const ValueKey("home_app_bar")), findsOneWidget); + expect(find.byKey(const ValueKey("home_select_image_container")), + findsOneWidget); + expect(find.byKey(const ValueKey("home_exif_container")), findsOneWidget); + }); + + testWidgets("Testing if image and exif data shows up", + (WidgetTester tester) async { + await tester.pumpWidget(homePage); + await tester.pumpAndSettle(); + BuildContext context = tester.element(find.byType(HomePage)); + Provider.of(context, listen: false).imagePath = imagePath; + await tester.pumpAndSettle(); + expect(find.byType(Image), findsOneWidget); + await tester.runAsync(() => exifModel.fetchImageExifInfo()); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey("home_save_button")), findsOneWidget); + }); + }); +} diff --git a/test/screens/index_test.dart b/test/screens/index_test.dart new file mode 100644 index 0000000..16c6cc7 --- /dev/null +++ b/test/screens/index_test.dart @@ -0,0 +1,99 @@ +import 'dart:ui'; + +import 'package:exif_helper/models/system.dart'; +import 'package:exif_helper/screens/home/home.dart'; +import 'package:exif_helper/screens/index.dart'; +import 'package:exif_helper/screens/settings.dart'; +import 'package:exif_helper/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../mock/system_test.mocks.dart'; + +void main() { + group("test index screen build correctly", () { + late Widget indexPage; + late MockSharedPreferences mockPrefs; + const homeKey = ValueKey("navigation_0"); + const settingKey = ValueKey("navigation_1"); + setUp(() { + mockPrefs = MockSharedPreferences(); + when(mockPrefs.getString('language')).thenReturn('system'); + when(mockPrefs.getString('themeMode')).thenReturn('system'); + when(mockPrefs.containsKey('language')).thenReturn(true); + when(mockPrefs.containsKey('themeMode')).thenReturn(true); + indexPage = ChangeNotifierProvider( + create: (context) => SystemModel(prefs: mockPrefs), + child: Consumer( + builder: (context, SystemModel system, child) { + Language language = system.currentLanguage; + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: system.currentThemeMode, + locale: language == Language.system + ? PlatformDispatcher.instance.locale + : language.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const IndexPage()); + }, + ), + ); + }); + + testWidgets("Testing if Scaffold shows up", (WidgetTester tester) async { + await tester.pumpWidget(indexPage); + await tester.pumpAndSettle(); + expect(find.byType(Scaffold), findsOneWidget); + }); + + testWidgets("Testing landscape show and operate correctly", + (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + await tester.pumpWidget(indexPage); + await tester.pumpAndSettle(); + // Test show + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.byType(VerticalDivider), findsOneWidget); + expect(find.byType(IndexedStack), findsOneWidget); + expect(find.byType(HomePage), findsOneWidget); + expect(find.byKey(homeKey), findsOneWidget); + expect(find.byKey(settingKey), findsOneWidget); + // Test operate + await tester.tap(find.byKey(settingKey)); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsNothing); + expect(find.byType(SettingsPage), findsOneWidget); + await tester.tap(find.byKey(homeKey)); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(SettingsPage), findsNothing); + }); + + testWidgets('Testing portrait show and operate correctly', + (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(375, 750)); + await tester.pumpWidget(indexPage); + await tester.pumpAndSettle(); + // Test show + expect(find.byType(AppBar), findsOneWidget); + expect(find.byType(IndexedStack), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(NavigationDestination), findsNWidgets(2)); + expect(find.byType(HomePage), findsOneWidget); + // Test operate + await tester.tap(find.byType(NavigationDestination).last); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsNothing); + expect(find.byType(SettingsPage), findsOneWidget); + await tester.tap(find.byType(NavigationDestination).first); + await tester.pumpAndSettle(); + expect(find.byType(SettingsPage), findsNothing); + expect(find.byType(HomePage), findsOneWidget); + }); + }); +} diff --git a/test/screens/settings_test.dart b/test/screens/settings_test.dart new file mode 100644 index 0000000..0825923 --- /dev/null +++ b/test/screens/settings_test.dart @@ -0,0 +1,131 @@ +import 'dart:ui'; + +import 'package:exif_helper/models/system.dart'; +import 'package:exif_helper/screens/settings.dart'; +import 'package:exif_helper/theme/theme.dart'; +import 'package:exif_helper/widgets/my_sliver_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../mock/system_test.mocks.dart'; + +void main() { + group("test settings screen build correctly", () { + late Widget settingsPage; + late MockSharedPreferences mockPrefs; + setUp(() { + mockPrefs = MockSharedPreferences(); + when(mockPrefs.getString('language')).thenReturn('system'); + when(mockPrefs.getString('themeMode')).thenReturn('system'); + when(mockPrefs.containsKey('language')).thenReturn(true); + when(mockPrefs.containsKey('themeMode')).thenReturn(true); + settingsPage = ChangeNotifierProvider( + create: (context) => SystemModel(prefs: mockPrefs), + child: Consumer( + builder: (context, SystemModel system, child) { + Language language = system.currentLanguage; + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: system.currentThemeMode, + locale: language == Language.system + ? PlatformDispatcher.instance.locale + : language.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SettingsPage(), + ), + ); + }, + ), + ); + }); + testWidgets("Testing if views shows up", (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.byType(CustomScrollView), findsOneWidget); + expect(find.byType(MySliverAppBar), findsOneWidget); + expect(find.byType(SliverList), findsOneWidget); + expect(find.byType(ListTile), findsNWidgets(5)); + }); + + testWidgets('Testing should change theme', (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + BuildContext context = tester.element(find.byType(SettingsPage)); + changeThemeMode(ThemeMode mode, int index) async { + await tester.tap(find.byKey(const ValueKey("theme"))); + await tester.pumpAndSettle(); + expect(find.byType(SimpleDialog), findsNWidgets(1)); + when(mockPrefs.setString("themeMode", mode.name)) + .thenAnswer((realInvocation) async => true); + await tester.tap(find.byType(RadioListTile).at(index)); + await tester.pumpAndSettle(); + verify(mockPrefs.setString("themeMode", mode.name)); + expect( + Provider.of(context, listen: false).currentThemeMode, + equals(mode)); + } + + await changeThemeMode(ThemeMode.light, 1); + await changeThemeMode(ThemeMode.dark, 2); + await changeThemeMode(ThemeMode.system, 0); + }); + + testWidgets("Testing should change language", (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + BuildContext context = tester.element(find.byType(SettingsPage)); + changeLanguage(Language language, int index) async { + await tester.tap(find.byKey(const ValueKey("language"))); + await tester.pumpAndSettle(); + expect(find.byType(SimpleDialog), findsNWidgets(1)); + when(mockPrefs.setString("language", language.name)) + .thenAnswer((realInvocation) async => true); + await tester.tap(find.byType(RadioListTile).at(index)); + await tester.pumpAndSettle(); + verify(mockPrefs.setString("language", language.name)); + expect(Provider.of(context, listen: false).currentLanguage, + equals(language)); + } + + await changeLanguage(Language.en, 1); + await changeLanguage(Language.zh, 2); + await changeLanguage(Language.system, 0); + }); + + testWidgets("Testing should can open privacy page", + (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey("privacy"))); + await tester.pumpAndSettle(); + }); + + testWidgets("Testing should can open privacy page", + (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey("terms"))); + await tester.pumpAndSettle(); + }); + + testWidgets("Testing should show about page", (WidgetTester tester) async { + await tester.pumpWidget(settingsPage); + await tester.pumpAndSettle(); + BuildContext context = tester.element(find.byType(SettingsPage)); + await tester.tap(find.byKey(const ValueKey("about"))); + await tester.pumpAndSettle(); + expect(find.byType(AboutDialog), findsOneWidget); + await tester.tap(find.widgetWithText( + TextButton, MaterialLocalizations.of(context).closeButtonLabel)); + await tester.pumpAndSettle(); + expect(find.byType(AboutDialog), findsNothing); + }); + }); +} diff --git a/test/widgets/dashed_container_test.dart b/test/widgets/dashed_container_test.dart new file mode 100644 index 0000000..1b3e540 --- /dev/null +++ b/test/widgets/dashed_container_test.dart @@ -0,0 +1,50 @@ +import 'package:exif_helper/widgets/dashed_container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'DashedContainer should paint dashed border with gap greater than 0', + (WidgetTester tester) async { + final testWidget = MaterialApp( + home: Scaffold( + body: Center( + child: DashedContainer( + width: 100, + height: 100, + borderColor: Colors.black, + strokeWidth: 2, + gap: 5, + ), + ), + ), + ); + await tester.pumpWidget(testWidget); + await expectLater( + find.byType(DashedContainer), + matchesGoldenFile( + 'goldens/dashed_container_expected_result_greater_than_0.png')); + }); + + testWidgets("DashedContainer should paint dashed border with gap less than 0", + (WidgetTester tester) async { + final testWidget = MaterialApp( + home: Scaffold( + body: Center( + child: DashedContainer( + width: 100, + height: 100, + borderColor: Colors.black, + strokeWidth: 2, + gap: -5, + ), + ), + ), + ); + await tester.pumpWidget(testWidget); + await expectLater( + find.byType(DashedContainer), + matchesGoldenFile( + 'goldens/dashed_container_expected_result_less_than_0.png')); + }); +} diff --git a/test/widgets/desktop_image_panel_test.dart b/test/widgets/desktop_image_panel_test.dart new file mode 100644 index 0000000..59c971d --- /dev/null +++ b/test/widgets/desktop_image_panel_test.dart @@ -0,0 +1,40 @@ +import 'package:exif_helper/widgets/desktop_image_panel.dart'; +import 'package:exif_helper/widgets/image_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:desktop_drop/desktop_drop.dart'; + +void main() { + testWidgets('test DesktopImagePanel builds correctly', + (WidgetTester tester) async { + const imagePath = 'test/test_image.jpg'; + const widget = DesktopImagePanel( + imagePath: imagePath, + ); + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: widget, + ), + )); + expect(find.byType(DropTarget), findsOneWidget); + widget.onDragEntered?.call(DropEventDetails( + localPosition: const Offset(0, 0), + globalPosition: const Offset(0, 0), + )); + widget.onDragUpdated?.call( + DropEventDetails( + localPosition: const Offset(10, 10), + globalPosition: const Offset(10, 10), + ), + ); + widget.onDragDone?.call(imagePath); + await tester.pumpAndSettle(); + expect(find.byType(ImagePanel), findsOneWidget); + widget.onDragExited?.call(DropEventDetails( + localPosition: const Offset(10, 10), + globalPosition: const Offset(10, 10), + )); + await tester.pumpAndSettle(); + expect(find.byType(ImagePanel), findsOneWidget); + }); +} diff --git a/test/widgets/goldens/dashed_container_expected_result_greater_than_0.png b/test/widgets/goldens/dashed_container_expected_result_greater_than_0.png new file mode 100644 index 0000000..a182123 Binary files /dev/null and b/test/widgets/goldens/dashed_container_expected_result_greater_than_0.png differ diff --git a/test/widgets/goldens/dashed_container_expected_result_less_than_0.png b/test/widgets/goldens/dashed_container_expected_result_less_than_0.png new file mode 100644 index 0000000..a182123 Binary files /dev/null and b/test/widgets/goldens/dashed_container_expected_result_less_than_0.png differ diff --git a/test/widgets/image_panel_test.dart b/test/widgets/image_panel_test.dart new file mode 100644 index 0000000..4c4d9ec --- /dev/null +++ b/test/widgets/image_panel_test.dart @@ -0,0 +1,72 @@ +import 'dart:ui'; + +import 'package:exif_helper/models/system.dart'; +import 'package:exif_helper/theme/theme.dart'; +import 'package:exif_helper/widgets/image_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../mock/system_test.mocks.dart'; + +void main() { + const String imagePath = "test/test_image.jpg"; + MockSharedPreferences mockPrefs = MockSharedPreferences(); + when(mockPrefs.getString('language')).thenReturn('system'); + when(mockPrefs.getString('themeMode')).thenReturn('system'); + when(mockPrefs.containsKey('language')).thenReturn(true); + when(mockPrefs.containsKey('themeMode')).thenReturn(true); + Widget createImagePanelScreen(ImagePanel imagePanel) { + return ChangeNotifierProvider( + create: (context) => SystemModel(prefs: mockPrefs), + child: Consumer( + builder: (context, SystemModel system, child) { + Language language = system.currentLanguage; + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: system.currentThemeMode, + locale: language == Language.system + ? PlatformDispatcher.instance.locale + : language.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: imagePanel); + }, + ), + ); + } + + group('test ImagePanel build correctly', () { + testWidgets('should display upload SVG when imagePath is empty', + (WidgetTester tester) async { + const widget = ImagePanel(imagePath: ''); + await tester.pumpWidget(createImagePanelScreen(widget)); + expect(find.byType(Column), findsOneWidget); + expect(find.byType(SvgPicture), findsOneWidget); + expect( + find.textContaining( + AppLocalizations.of(tester.element(find.byType(ImagePanel)))! + .selectImage), + findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('should display image file when imagePath is not empty', + (WidgetTester tester) async { + const widget = ImagePanel(imagePath: imagePath); + await tester.pumpWidget(createImagePanelScreen(widget)); + expect(find.byType(Image), findsOneWidget); + expect(find.byType(SvgPicture), findsNothing); + expect(find.byType(Column), findsNothing); + expect( + find.textContaining( + AppLocalizations.of(tester.element(find.byType(ImagePanel)))! + .selectImage), + findsNothing); + }); + }); +} diff --git a/test/widgets/my_sliver_app_bar_test.dart b/test/widgets/my_sliver_app_bar_test.dart new file mode 100644 index 0000000..77e94b1 --- /dev/null +++ b/test/widgets/my_sliver_app_bar_test.dart @@ -0,0 +1,32 @@ +import 'package:exif_helper/widgets/my_sliver_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createMySliverAppbarScreen({String? title}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + MySliverAppBar(title: title), + ], + ), + ), + ); + } + + group('MySliverAppBar', () { + String title = "Test My Sliver App Bar"; + testWidgets('builds a sliver app bar with a title', + (WidgetTester tester) async { + await tester.pumpWidget(createMySliverAppbarScreen(title: title)); + expect(find.text(title), findsOneWidget); + }); + + testWidgets('builds a sliver app bar without a title when title is null', + (WidgetTester tester) async { + await tester.pumpWidget(createMySliverAppbarScreen()); + expect(find.text(title), findsNothing); + }); + }); +} diff --git a/web/.vitepress/config.mts b/web/.vitepress/config.mts index 2a6e918..135862d 100644 --- a/web/.vitepress/config.mts +++ b/web/.vitepress/config.mts @@ -1,14 +1,16 @@ -import { defineConfig } from "vitepress"; +import { DefaultTheme, UserConfig, defineConfig } from "vitepress"; import { zh } from "./zh"; import pkg from "../package.json"; // https://vitepress.dev/reference/site-config +let baseDir = "/exif_helper/"; export default defineConfig({ - base: "/exif_helper/", + base: baseDir, cleanUrls: true, title: "ExifHelper", outDir: "dist", description: "Read or write image exif without internet", + head: [["link", { rel: "icon", href: `${baseDir}favicon.ico` }]], themeConfig: { logo: "/logo.svg", langMenuLabel: "Languages", diff --git a/web/.vitepress/zh.ts b/web/.vitepress/zh.ts index 87c0a09..a103fa0 100644 --- a/web/.vitepress/zh.ts +++ b/web/.vitepress/zh.ts @@ -18,6 +18,9 @@ export const zh = defineConfig({ ], }, ], + outline: { + label: "็›ฎๅฝ•", + }, footer: { message: 'ๆœๅŠกๅ่ฎฎ    ้š็งๆ”ฟ็ญ–',