diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..3192442c Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index ecdab274..3692e40b 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..3272d7b5 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 34e930b4..46ae07a9 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 00000000..4e0f4cf0 Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 00000000..f88598cf --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 00000000..4e0f4cf0 Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 00000000..f88598cf --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 00000000..195c52e1 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f88598cf --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..dc4bbd90 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index fcbab0db..01e4e8e5 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..d7768ea7 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 1a18294c..99150e82 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..64b817d5 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 6e8223c8..c6107b11 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 00000000..195c52e1 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f8..f88598cf 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,6 @@ - - - - - + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml index 5f349f7f..7e91a578 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -1,5 +1,5 @@ - + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png index 700a0b05..6393a24a 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png index 5ba9a3e5..700c5ea3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png index 165a9bce..9637619a 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png index 254231b7..2b3a8145 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png index 4663a1e9..763e5499 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..42dbbfa9 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..dbc9ea9f --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..b96aa1a6 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/assets/adaptive_foreground.png b/assets/adaptive_foreground.png deleted file mode 100644 index ef040700..00000000 Binary files a/assets/adaptive_foreground.png and /dev/null differ diff --git a/assets/i18n/en.json b/assets/i18n/en.json index c48246d4..d479b2d0 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1,10 +1,18 @@ { "app_bar": { - "search": "Search", - "refresh": "Refresh", "logout": "Logout", "clear_all": "Clear all" }, + "alert": { + "discard": "Discard", + "clear": "Clear" + }, + "form": { + "validators": { + "invalid_number": "not a valid number", + "required": "required" + } + }, "login": { "title": "Login", "server_url": { @@ -46,6 +54,12 @@ "categories": { "title": "Cookbook", "all_categories": "All", + "uncategorized": "Uncategorized", + "items": { + "0": "no items", + "1": "1 item", + "else": "{{value}} items" + }, "drawer": { "import": "Import Recipe", "settings": "Settings" @@ -71,18 +85,21 @@ "cook": "Cooking time", "total": "Total time", "fields": { - "name": "Recipe Name:", - "description": "Recipe Description:", - "keywords": "Keywords:", - "category": "Category:", - "servings": "Servings:", - "source": "Source:", + "name": "Recipe Name", + "description": "Recipe Description", + "keywords": "Keywords", + "category": "Category", + "servings": { + "1": "Serving", + "else": "Servings" + }, + "source": "Source", "source_button": "Source", - "image": "Image:", + "image": "Image", "time": { - "prep": "Preparation time:", - "cook": "Cooking time:", - "total": "Total time:", + "prep": "Preparation time", + "cook": "Cooking time", + "total": "Total time", "hours": "Hours", "minutes": "Minutes" }, @@ -103,9 +120,11 @@ "unsaturatedFatContent": "Unsaturated-fat content" } }, - "tools": "Tools:", - "ingredients": "Ingredients:", - "instructions": "Instructions:" + "general": "General", + "times": "Times", + "tools": "Tools", + "ingredients": "Ingredients", + "instructions": "Instructions" }, "errors": { "load_failed": "Failed to load Recipe!" @@ -116,26 +135,42 @@ "button": "Save", "errors": { "update_failed": "Update Failed {error_msg}" + }, + "delete": { + "title": "Delete Recipe", + "dialog": "Are you sure you want to permanently delete {recipe}?" } }, "recipe_create": { "title": "Create Recipe", "button": "Create", + "add_field": "add", + "remove_field": "remove", "errors": { "update_failed": "Create Failed {error_msg}" } }, + "recipe_form": { + "dismiss_edit": { + "title": "Cancel edit", + "dialog": "Are you sure you want to discard your changes to {recipe}?" + }, + "dismiss_create": { + "title": "Cancel create", + "dialog": "Are you sure you want to discard the creation?" + } + }, "recipe_import": { "title": "Import Recipe", "button": "Import", "field": "URL to Recipe", - "clipboard": "Paste Clipboard", "errors": { "import_failed": "Import Failed {error_msg}" } }, "search": { "title": "Search Recipe", + "description": "Search for recipes by name or keyword", "nothing_found": "No recipe found!", "errors": { "search_failed": "Unable to load all Recipes!\n{error_msg}" @@ -145,8 +180,13 @@ "title": "Your timers", "started": "Timer started.", "finished": "is finished.", + "button": { + "start": "start timer", + "cancel": "cancel timer" + }, "done": "Timer is done.", - "missing": "You need to set the cooking time to use a timer." + "missing": "You need to set the cooking time to use a timer.", + "empty_list": "Your running timers will be shown here." }, "settings": { "title": "App Settings", @@ -172,4 +212,4 @@ "subtitle": "Changes the font size of category title. This can be useful if you have long category names." } } -} +} \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png deleted file mode 100644 index 77c3844f..00000000 Binary files a/assets/icon.png and /dev/null differ diff --git a/assets/launcher/adaptive_background.png b/assets/launcher/adaptive_background.png new file mode 100644 index 00000000..bbd297c9 Binary files /dev/null and b/assets/launcher/adaptive_background.png differ diff --git a/assets/launcher/adaptive_foreground.png b/assets/launcher/adaptive_foreground.png new file mode 100644 index 00000000..f1ab9a16 Binary files /dev/null and b/assets/launcher/adaptive_foreground.png differ diff --git a/assets/launcher/icon.png b/assets/launcher/icon.png new file mode 100644 index 00000000..31d664ce Binary files /dev/null and b/assets/launcher/icon.png differ diff --git a/docker-compose.yaml b/docker-compose.yaml index 21386cff..11d1a04a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,18 +1,19 @@ version: '3.8' services: nextcloud: - image: lscr.io/linuxserver/nextcloud:22.2.3 + image: lscr.io/linuxserver/nextcloud:latest container_name: plugin_development environment: - PUID=1000 - PGID=1000 - TZ=Europe/London - - COOKBOOK=0.9.8 + - COOKBOOK=0.10.2 volumes: - /config - /data - ./docker/data:/config/www/nextcloud/data/preset - - ./docker/80-setup:/etc/cont-init.d/80-setup + - ./docker/80-setup:/custom-cont-init.d/80-setup:ro + - ./docker/setup_library:/etc/setup_library:ro ports: - 443:443 - 80:80 \ No newline at end of file diff --git a/docker/80-setup b/docker/80-setup index dbd03da7..dc1eea18 100644 --- a/docker/80-setup +++ b/docker/80-setup @@ -12,8 +12,4 @@ tar -zxf /config/www/nextcloud/apps/Cookbook-"${COOKBOOK}".tar.gz -C /config/www rm /config/www/nextcloud/apps/Cookbook-"${COOKBOOK}".tar.gz occ app:enable cookbook -# Setup Cookbook Library -rm -R /config/www/nextcloud/data/admin/files -cp -R /config/www/nextcloud/data/preset /config/www/nextcloud/data/admin/files -chown abc:users -R /config/www/nextcloud/data/admin/files/ -occ files:scan admin \ No newline at end of file +bash /etc/setup_library \ No newline at end of file diff --git a/docker/setup_library b/docker/setup_library new file mode 100644 index 00000000..83833eff --- /dev/null +++ b/docker/setup_library @@ -0,0 +1,7 @@ +#!/usr/bin/with-contenv bash + +# Setup Cookbook Library +rm -R /config/www/nextcloud/data/admin/files +cp -R /config/www/nextcloud/data/preset /config/www/nextcloud/data/admin/files +chown abc:users -R /config/www/nextcloud/data/admin/files/ +occ files:scan admin \ No newline at end of file diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 00000000..7a2c5f5f --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,7 @@ +flutter_icons: + android: "launcher_icon" + ios: true + remove_alpha_ios: true + image_path: "assets/launcher/icon.png" + adaptive_icon_background: "assets/launcher/adaptive_background.png" + adaptive_icon_foreground: "assets/launcher/adaptive_foreground.png" diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml new file mode 100644 index 00000000..50849fc2 --- /dev/null +++ b/flutter_native_splash.yaml @@ -0,0 +1,10 @@ +flutter_native_splash: + color: "#fcfcff" + color_dark: "#1a1c1e" + #image: assets/splash_icon.png + android_12: + #image: assets/splash_icon_android_12.png + icon_background_color: "#fcfcff" + icon_background_color_dark: "#1a1c1e" + ios: false + web: false diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 6da96660..46d0d23f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 713d8537..0385784b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 47bd6fb9..10f9e8af 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index a8b34f66..fd9d6d9a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index e9a5bd41..3d57ddc0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index c5450e6c..95d1069c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index d3caaf3a..95d5c8d8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 47bd6fb9..10f9e8af 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index bb6ed50f..292cdfee 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 9c66327f..5052e130 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..494708de Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..2507739c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..ffcbfd6d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..355558f3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 9c66327f..5052e130 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 85268c64..151cf2ad 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..6393a24a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..2b3a8145 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 98962751..7ebc22d1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index fca796d6..d008bf40 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index df59f355..74ddf64e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/main.dart b/lib/main.dart index 1fcf4baa..865a05a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/categories/categories_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/recipes_short/recipes_short_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/simple_bloc_delegatae.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/category/category_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/category_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/loading_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/login_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/splash_screen.dart'; @@ -14,6 +16,7 @@ import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; import 'package:nextcloud_cookbook_flutter/src/util/lifecycle_event_handler.dart'; import 'package:nextcloud_cookbook_flutter/src/util/setting_keys.dart'; import 'package:nextcloud_cookbook_flutter/src/util/supported_locales.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; import 'package:nextcloud_cookbook_flutter/src/util/theme_mode_manager.dart'; import 'package:nextcloud_cookbook_flutter/src/util/translate_preferences.dart'; import 'package:theme_mode_handler/theme_mode_handler.dart'; @@ -30,6 +33,9 @@ void main() async { await Settings.init(); // Wait for Notifications to be ready await NotificationService().init(); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + runApp( LocalizedApp( delegate, @@ -65,8 +71,6 @@ class App extends StatefulWidget { } class _AppState extends State { - final UserRepository userRepository = UserRepository(); - @override void initState() { super.initState(); @@ -93,63 +97,22 @@ class _AppState extends State { builder: (ThemeMode themeMode) => MaterialApp( navigatorKey: IntentRepository().getNavigationKey(), themeMode: themeMode, - theme: ThemeData( - brightness: Brightness.light, - hintColor: Colors.grey, - ), - darkTheme: ThemeData( - brightness: Brightness.dark, - hintColor: Colors.grey, - ), - home: BlocBuilder( + theme: AppTheme.lightThemeData, + darkTheme: AppTheme.darkThemeData, + home: BlocConsumer( builder: (context, state) { + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + systemNavigationBarColor: + Theme.of(context).scaffoldBackgroundColor, + ), + ); + switch (state.status) { case AuthenticationStatus.loading: return const SplashPage(); case AuthenticationStatus.authenticated: - return FutureBuilder( - future: UserRepository().fetchApiVersion(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Container(); - } - - if (snapshot.hasError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - "categories.errors.api_version_check_failed", - args: {"error_msg": snapshot.error}, - ), - ), - backgroundColor: Colors.red, - ), - ); - } - - if (!UserRepository().isVersionSupported(snapshot.data!)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - "categories.errors.api_version_above_confirmed", - args: { - "version": - "${snapshot.data!.major}.${snapshot.data!.minor}" - }, - ), - ), - backgroundColor: Colors.orange, - ), - ); - } - - IntentRepository().handleIntent(); - return const CategoryScreen(); - }, - ); - + return const CategoryScreen(); case AuthenticationStatus.unauthenticated: return const LoginScreen(); case AuthenticationStatus.invalid: @@ -157,7 +120,14 @@ class _AppState extends State { invalidCredentials: true, ); case AuthenticationStatus.error: - return const LoadingErrorScreen(); + return LoadingErrorScreen(message: state.error!); + } + }, + listener: (context, state) async { + if (state.status != AuthenticationStatus.loading) { + FlutterNativeSplash.remove(); + } else if (state.status == AuthenticationStatus.authenticated) { + await IntentRepository().handleIntent(); } }, ), diff --git a/lib/src/blocs/recipe/recipe_bloc.dart b/lib/src/blocs/recipe/recipe_bloc.dart index b080f24c..63df7aab 100644 --- a/lib/src/blocs/recipe/recipe_bloc.dart +++ b/lib/src/blocs/recipe/recipe_bloc.dart @@ -14,6 +14,7 @@ class RecipeBloc extends Bloc { on(_mapRecipeUpdatedToState); on(_mapRecipeImportedToState); on(_mapRecipeCreatedToState); + on(_mapRecipeDeletedToState); } Future _mapRecipeLoadedToState( @@ -81,4 +82,22 @@ class RecipeBloc extends Bloc { ); } } + + Future _mapRecipeDeletedToState( + RecipeDeleted recipeDeleted, + Emitter emit, + ) async { + try { + emit(RecipeState(status: RecipeStatus.deleteInProgress)); + await dataRepository.deleteRecipe(recipeDeleted.recipe); + emit(RecipeState(status: RecipeStatus.delteSuccess)); + } catch (e) { + emit( + RecipeState( + status: RecipeStatus.deleteFailure, + error: e.toString(), + ), + ); + } + } } diff --git a/lib/src/blocs/recipe/recipe_event.dart b/lib/src/blocs/recipe/recipe_event.dart index a6ae5189..4a6c4260 100644 --- a/lib/src/blocs/recipe/recipe_event.dart +++ b/lib/src/blocs/recipe/recipe_event.dart @@ -42,3 +42,12 @@ class RecipeImported extends RecipeEvent { @override List get props => [url]; } + +class RecipeDeleted extends RecipeEvent { + final Recipe recipe; + + const RecipeDeleted(this.recipe); + + @override + List get props => [recipe]; +} diff --git a/lib/src/blocs/recipe/recipe_state.dart b/lib/src/blocs/recipe/recipe_state.dart index 3294da75..45f77027 100644 --- a/lib/src/blocs/recipe/recipe_state.dart +++ b/lib/src/blocs/recipe/recipe_state.dart @@ -1,8 +1,6 @@ part of 'recipe_bloc.dart'; enum RecipeStatus { - failure, - success, loadSuccess, loadFailure, loadInProgress, @@ -12,6 +10,9 @@ enum RecipeStatus { createFailure, createSuccess, createInProgress, + deleteInProgress, + deleteFailure, + delteSuccess, importSuccess, importFailure, importInProgress; @@ -34,18 +35,19 @@ class RecipeState extends Equatable { case RecipeStatus.updateInProgress: case RecipeStatus.createInProgress: case RecipeStatus.importInProgress: + case RecipeStatus.deleteInProgress: + case RecipeStatus.delteSuccess: assert(error == null && recipe == null && recipeId == null); break; case RecipeStatus.createSuccess: case RecipeStatus.updateSuccess: assert(error == null && recipe == null && recipeId != null); break; - case RecipeStatus.success: case RecipeStatus.loadSuccess: case RecipeStatus.importSuccess: assert(error == null && recipe != null && recipeId == null); break; - case RecipeStatus.failure: + case RecipeStatus.deleteFailure: case RecipeStatus.loadFailure: case RecipeStatus.updateFailure: case RecipeStatus.createFailure: diff --git a/lib/src/models/animated_list.dart b/lib/src/models/animated_list.dart new file mode 100644 index 00000000..7c4e9a58 --- /dev/null +++ b/lib/src/models/animated_list.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; + +typedef RemovedItemBuilder = Widget Function( + T item, + BuildContext context, + Animation animation, +); + +abstract class AnimatedListModel { + AnimatedListModel({ + required this.listKey, + required this.removedItemBuilder, + Iterable? initialItems, + }) : _items = List.from(initialItems ?? []); + + final GlobalKey listKey; + final RemovedItemBuilder removedItemBuilder; + final List _items; + + SliverAnimatedListState get _animatedList => listKey.currentState!; + + void insert(int index, E item) { + _items.insert(index, item); + _animatedList.insertItem(index); + } + + E removeAt(int index) { + final E removedItem = _items.removeAt(index); + if (removedItem != null) { + _animatedList.removeItem( + index, + (BuildContext context, Animation animation) { + return removedItemBuilder(removedItem, context, animation); + }, + ); + } + return removedItem; + } + + Future removeAll() async { + while (_items.isNotEmpty) { + removeAt(0); + } + } + + void add(E item) { + insert(_items.length, item); + } + + void insertAll(int index, Iterable items) { + for (var i = 0; i < items.length; i++) { + insert(index + i, items.elementAt(i)); + } + } + + bool get isNotEmpty => _items.isNotEmpty; + + int get length => _items.length; + + E operator [](int index) => _items[index]; + + int indexOf(E item) => _items.indexOf(item); +} + +class AnimatedTimerList extends AnimatedListModel { + AnimatedTimerList({ + required super.listKey, + required super.removedItemBuilder, + }) : super(initialItems: TimerList().timers); + + AnimatedTimerList.forId({ + required String recipeId, + required super.listKey, + required super.removedItemBuilder, + }) : super( + initialItems: TimerList() + .timers + .where((element) => element.recipeId == recipeId), + ); + + @override + Timer removeAt(int index) { + TimerList().remove(_items[index]); + return super.removeAt(index); + } + + @override + void insert(int index, Timer item) { + TimerList().add(item); + super.insert(index, item); + } +} diff --git a/lib/src/screens/category/category_screen.dart b/lib/src/screens/category/category_screen.dart deleted file mode 100644 index 50fea4ca..00000000 --- a/lib/src/screens/category/category_screen.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/categories/categories_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipes_short/recipes_short_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/my_settings_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/recipe_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe_create_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe_import_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipes_list_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/timer_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/category_card.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/user_image.dart'; -import 'package:search_page/search_page.dart'; - -class CategoryScreen extends StatefulWidget { - const CategoryScreen({super.key}); - - @override - _CategoryScreenState createState() => _CategoryScreenState(); -} - -class _CategoryScreenState extends State { - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, categoriesState) { - return Scaffold( - floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const RecipeCreateScreen(); - }, - ), - ); - }, - child: const Icon(Icons.add), - ), - drawer: Drawer( - child: ListView( - // Important: Remove any padding from the ListView. - padding: EdgeInsets.zero, - children: [ - DrawerHeader( - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - ), - child: const Center( - child: UserImage(), - ), - ), - ListTile( - trailing: Icon( - Icons.alarm_add_outlined, - semanticLabel: translate('timer.title'), - ), - title: Text(translate('timer.title')), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const TimerScreen(); - }, - ), - ); - }, - ), - ListTile( - trailing: Icon( - Icons.cloud_download_outlined, - semanticLabel: translate('categories.drawer.import'), - ), - title: Text(translate('categories.drawer.import')), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const RecipeImportScreen(); - }, - ), - ); - }, - ), - ListTile( - trailing: Icon( - Icons.settings, - semanticLabel: translate('categories.drawer.settings'), - ), - title: Text(translate('categories.drawer.settings')), - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const MySettingsScreen(); - }, - ), - ); - setState(() {}); - }, - ), - ListTile( - trailing: Icon( - Icons.exit_to_app, - semanticLabel: translate('app_bar.logout'), - ), - title: Text(translate('app_bar.logout')), - onTap: () { - BlocProvider.of(context) - .add(const LoggedOut()); - }, - ), - ], - ), - ), - appBar: AppBar( - title: Text(translate('categories.title')), - actions: [ - BlocBuilder( - builder: (context, state) { - return BlocListener( - listener: (context, state) { - if (state.status == RecipesShortStatus.loadAllSuccess) { - showSearch( - context: context, - delegate: SearchPage( - items: state.recipesShort!.toList(), - searchLabel: translate('search.title'), - suggestion: const Center( - // child: Text('Filter people by name, surname or age'), - ), - failure: Center( - child: Text(translate('search.nothing_found')), - ), - filter: (recipe) => [ - recipe.name, - ], - builder: (recipe) => ListTile( - title: Text(recipe.name), - trailing: RecipeImage( - id: recipe.recipeId, - size: const Size.square(50), - ), - onTap: () => - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => - RecipeScreen(recipeId: recipe.recipeId), - ), - ), - ), - ), - ); - } else if (state.status == - RecipesShortStatus.loadAllFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - 'search.errors.search_failed', - args: {"error_msg": state.error}, - ), - ), - backgroundColor: Colors.red, - ), - ); - } - }, - child: IconButton( - icon: Icon( - () { - switch (state.status) { - case RecipesShortStatus.loadAllInProgress: - return Icons.downloading; - case RecipesShortStatus.loadAllFailure: - return Icons.report_problem; - default: - return Icons.search; - } - }(), - semanticLabel: translate('app_bar.search'), - ), - onPressed: () async { - BlocProvider.of(context) - .add(RecipesShortLoadedAll()); - }, - ), - ); - }, - ), - IconButton( - icon: Icon( - Icons.refresh, - semanticLabel: translate('app_bar.refresh'), - ), - onPressed: () { - DefaultCacheManager().emptyCache(); - BlocProvider.of(context) - .add(const CategoriesLoaded()); - }, - ), - ], - ), - body: RefreshIndicator( - onRefresh: () { - DefaultCacheManager().emptyCache(); - BlocProvider.of(context) - .add(const CategoriesLoaded()); - return Future.value(); - }, - child: () { - switch (categoriesState.status) { - case CategoriesStatus.loadSuccess: - return _buildCategoriesScreen(categoriesState.categories!); - case CategoriesStatus.imageLoadSuccess: - return _buildCategoriesScreen( - categoriesState.categories!, - categoriesState.recipes, - ); - case CategoriesStatus.loadInProgress: - BlocProvider.of(context) - .add(const CategoriesLoaded()); - - return Center( - child: SpinKitWave( - color: Theme.of(context).primaryColor, - ), - ); - case CategoriesStatus.loadFailure: - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Text( - translate('categories.errors.plugin_missing'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const Divider(), - Text( - translate( - 'categories.errors.load_failed', - args: {'error_msg': categoriesState.error}, - ), - ), - ], - ), - ); - default: - return Text(translate('categories.errors.unknown')); - } - }(), - ), - ); - }, - ); - } - - Widget _buildCategoriesScreen( - Iterable categories, [ - Iterable? recipe, - ]) { - final double screenWidth = MediaQuery.of(context).size.width; - final int axisRatio = (screenWidth / 150).round(); - final int axisCount = axisRatio < 1 ? 1 : axisRatio; - - return Padding( - padding: const EdgeInsets.all(10.0), - child: GridView.count( - crossAxisCount: axisCount, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - padding: const EdgeInsets.only(top: 10), - semanticChildCount: categories.length, - children: [ - for (int i = 0; i < categories.length; i++) - GestureDetector( - child: CategoryCard( - categories.elementAt(i), - recipe?.elementAt(i)?.recipeId, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return RecipesListScreen( - category: categories.elementAt(i).name, - ); - }, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/screens/category_screen.dart b/lib/src/screens/category_screen.dart new file mode 100644 index 00000000..fb90b541 --- /dev/null +++ b/lib/src/screens/category_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/categories/categories_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/recipes_short/recipes_short_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_edit_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/category_grid_delegate.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/category_card.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/drawer.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe_list_item.dart'; +import 'package:search_page/search_page.dart'; + +class CategoryScreen extends StatefulWidget { + const CategoryScreen({super.key}); + + @override + State createState() => _CategoryScreenState(); +} + +class _CategoryScreenState extends State { + @override + void initState() { + super.initState(); + + SchedulerBinding.instance.addPostFrameCallback(checkApiCallback); + } + + Future checkApiCallback(Duration _) async { + final theme = Theme.of(context).extension()!; + + try { + final APIVersion apiVersion = await UserRepository().fetchApiVersion(); + + if (!UserRepository().isVersionSupported(apiVersion)) { + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + "categories.errors.api_version_above_confirmed", + args: { + "version": + "${apiVersion.epoch}.${apiVersion.major}.${apiVersion.minor}" + }, + ), + ), + backgroundColor: theme.warningSnackBar.backgroundColor, + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + "categories.errors.api_version_check_failed", + args: {"error_msg": e}, + ), + style: theme.errorSnackBar.contentTextStyle, + ), + backgroundColor: theme.errorSnackBar.backgroundColor, + ), + ); + } + } + + Future refresh() async { + await DefaultCacheManager().emptyCache(); + // ignore: use_build_context_synchronously + BlocProvider.of(context).add(const CategoriesLoaded()); + } + + Widget bodyBuilder(BuildContext context, CategoriesState state) { + return RefreshIndicator( + onRefresh: refresh, + child: Builder( + builder: (context) { + switch (state.status) { + case CategoriesStatus.loadSuccess: + case CategoriesStatus.imageLoadSuccess: + final categories = state.categories!.toList(); + final recipe = state.recipes?.toList(); + + final extent = CategoryCard.hightExtend(context); + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 75), + gridDelegate: CategoryGridDelegate(extent: extent), + semanticChildCount: categories.length, + itemCount: categories.length, + itemBuilder: (context, index) => CategoryCard( + categories[index], + recipe?[index]?.recipeId, + ), + ); + case CategoriesStatus.loadInProgress: + BlocProvider.of(context) + .add(const CategoriesLoaded()); + + return Center( + child: SpinKitWave( + color: Theme.of(context).colorScheme.primary, + ), + ); + case CategoriesStatus.loadFailure: + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + translate('categories.errors.plugin_missing'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Divider(), + Text( + translate( + 'categories.errors.load_failed', + args: {'error_msg': state.error}, + ), + ), + ], + ), + ); + default: + throw StateError('invalid CategoryState'); + } + }, + ), + ); + } + + Widget iconBuilder(BuildContext context, RecipesShortState state) { + return IconButton( + icon: Builder( + builder: (context) { + switch (state.status) { + case RecipesShortStatus.loadAllInProgress: + return const Icon(Icons.downloading_outlined); + case RecipesShortStatus.loadAllFailure: + return const Icon(Icons.report_problem_outlined); + default: + return const Icon(Icons.search_outlined); + } + }, + ), + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + onPressed: () async { + BlocProvider.of(context).add(RecipesShortLoadedAll()); + }, + ); + } + + void recipeListener(BuildContext context, RecipesShortState state) { + if (state.status == RecipesShortStatus.loadAllSuccess) { + showSearch( + context: context, + delegate: SearchPage( + items: state.recipesShort!.toList(), + searchLabel: translate('search.title'), + suggestion: Center( + child: Text(translate('search.description')), + ), + failure: Center( + child: Text(translate('search.nothing_found')), + ), + filter: (recipe) => [ + recipe.name, + recipe.keywords, + ], + builder: (recipe) => RecipeListItem(recipe: recipe), + ), + ); + } else if (state.status == RecipesShortStatus.loadAllFailure) { + final theme = + Theme.of(context).extension()!.errorSnackBar; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + 'search.errors.search_failed', + args: {"error_msg": state.error}, + ), + style: theme.contentTextStyle, + ), + backgroundColor: theme.backgroundColor, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(translate('categories.title')), + actions: [ + BlocConsumer( + builder: iconBuilder, + listener: recipeListener, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + tooltip: + MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, + onPressed: refresh, + ), + ], + ), + drawer: const MainDrawer(), + body: BlocBuilder( + builder: bodyBuilder, + ), + floatingActionButton: FloatingActionButton( + tooltip: translate("recipe_edit.title").toLowerCase(), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => RecipeBloc(), + child: const RecipeEditScreen(), + ), + ), + ); + }, + child: const Icon(Icons.add_outlined), + ), + ); + } +} diff --git a/lib/src/screens/form/login_form.dart b/lib/src/screens/form/login_form.dart index 437cd853..6a9cfda3 100644 --- a/lib/src/screens/form/login_form.dart +++ b/lib/src/screens/form/login_form.dart @@ -5,6 +5,7 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; import 'package:nextcloud_cookbook_flutter/src/widget/checkbox_form_field.dart'; @@ -47,6 +48,9 @@ class _LoginFormState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _serverUrl.dispose(); + _username.dispose(); + _password.dispose(); super.dispose(); } @@ -81,10 +85,15 @@ class _LoginFormState extends State with WidgetsBindingObserver { return BlocListener( listener: (context, state) { if (state.status == LoginStatus.failure) { + final theme = + Theme.of(context).extension()!.errorSnackBar; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.error!), - backgroundColor: Colors.red, + content: Text( + state.error!, + style: theme.contentTextStyle, + ), + backgroundColor: theme.backgroundColor, ), ); } @@ -221,7 +230,7 @@ class _LoginFormState extends State with WidgetsBindingObserver { ), if (state.status == LoginStatus.loading) SpinKitWave( - color: Theme.of(context).primaryColor, + color: Theme.of(context).colorScheme.primary, ), ], ), diff --git a/lib/src/screens/form/recipe_form.dart b/lib/src/screens/form/recipe_form.dart deleted file mode 100644 index f401f522..00000000 --- a/lib/src/screens/form/recipe_form.dart +++ /dev/null @@ -1,289 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/input/duration_form_field.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/input/integer_text_form_field.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/input/reorderable_list_form_field.dart'; - -class RecipeForm extends StatefulWidget { - final Recipe? recipe; - - const RecipeForm({ - super.key, - this.recipe, - }); - - @override - _RecipeFormState createState() => _RecipeFormState(); -} - -class _RecipeFormState extends State { - final _formKey = GlobalKey(); - late RecipeBuilder _mutableRecipe; - late TextEditingController categoryController; - - @override - void initState() { - _mutableRecipe = RecipeBuilder(); - - if (widget.recipe != null) { - _mutableRecipe.replace(widget.recipe!); - } - categoryController = - TextEditingController(text: _mutableRecipe.recipeCategory); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final enabled = state.status != RecipeStatus.updateInProgress; - return Form( - key: _formKey, - child: Padding( - padding: const EdgeInsets.all(15.0), - child: SingleChildScrollView( - child: Wrap( - runSpacing: 20, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.name'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextFormField( - enabled: enabled, - initialValue: _mutableRecipe.name, - onChanged: (value) { - _mutableRecipe.name = value; - }, - ), - ], - ), // Name - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.description'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextFormField( - enabled: enabled, - initialValue: _mutableRecipe.description, - maxLines: 100, - minLines: 1, - onChanged: (value) { - _mutableRecipe.description = value; - }, - ), - ], - ), // Description - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.category'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TypeAheadFormField( - getImmediateSuggestions: true, - textFieldConfiguration: TextFieldConfiguration( - controller: categoryController, - ), - suggestionsCallback: - DataRepository().getMatchingCategoryNames, - itemBuilder: (context, String suggestion) { - return ListTile( - title: Text(suggestion), - ); - }, - onSuggestionSelected: (String suggestion) { - categoryController.text = suggestion; - }, - onSaved: (value) { - if (value == null) return; - _mutableRecipe.recipeCategory = value; - }, - ) - ], - ), // Category - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.keywords'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextFormField( - enabled: enabled, - initialValue: _mutableRecipe.keywords, - onSaved: (value) { - _mutableRecipe.keywords = value; - }, - ), - ], - ), // Keywords - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.source'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextFormField( - enabled: enabled, - initialValue: _mutableRecipe.url, - onSaved: (value) { - _mutableRecipe.url = value; - }, - ), - ], - ), // URL - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.image'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - TextFormField( - enabled: false, - style: const TextStyle(color: Colors.grey), - initialValue: _mutableRecipe.imageUrl, - onSaved: (value) { - _mutableRecipe.imageUrl = value; - }, - ), - ], - ), // Image - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - translate('recipe.fields.servings'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - IntegerTextFormField( - enabled: enabled, - initialValue: _mutableRecipe.recipeYield, - onSaved: (value) => _mutableRecipe.recipeYield = value, - ), - ], - ), // Servings - DurationFormField( - title: translate('recipe.fields.time.prep'), - state: state, - duration: _mutableRecipe.prepTime, - onChanged: (value) => {_mutableRecipe.prepTime = value}, - ), - DurationFormField( - title: translate('recipe.fields.time.cook'), - state: state, - duration: _mutableRecipe.cookTime, - onChanged: (value) => {_mutableRecipe.cookTime = value}, - ), - DurationFormField( - title: translate('recipe.fields.time.total'), - state: state, - duration: _mutableRecipe.totalTime, - onChanged: (value) => {_mutableRecipe.totalTime = value}, - ), - ReorderableListFormField( - title: translate('recipe.fields.tools'), - items: _mutableRecipe.tool, - state: state, - onSave: (value) => {_mutableRecipe.tool = value}, - ), - ReorderableListFormField( - title: translate('recipe.fields.ingredients'), - items: _mutableRecipe.recipeIngredient, - state: state, - onSave: (value) => - {_mutableRecipe.recipeIngredient = value}, - ), - ReorderableListFormField( - title: translate('recipe.fields.instructions'), - items: _mutableRecipe.recipeInstructions, - state: state, - onSave: (value) => - {_mutableRecipe.recipeInstructions = value}, - ), - SizedBox( - width: 150, - child: ElevatedButton( - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - _formKey.currentState?.save(); - if (widget.recipe == null) { - _mutableRecipe.dateCreated = DateTime.now().toUtc(); - BlocProvider.of(context) - .add(RecipeCreated(_mutableRecipe.build())); - } else { - _mutableRecipe.dateModified = - DateTime.now().toUtc(); - BlocProvider.of(context) - .add(RecipeUpdated(_mutableRecipe.build())); - } - } - }, - child: () { - switch (state.status) { - case RecipeStatus.updateInProgress: - return const SpinKitWave( - color: Colors.white, - size: 30.0, - ); - case RecipeStatus.updateFailure: - case RecipeStatus.updateSuccess: - case RecipeStatus.loadSuccess: - case RecipeStatus.createSuccess: - case RecipeStatus.createFailure: - default: - return Text( - widget.recipe == null - ? translate('recipe_create.button') - : translate('recipe_edit.button'), - ); - } - }(), - ), - ), // Update Button - ], - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/src/screens/form/recipe_import_form.dart b/lib/src/screens/form/recipe_import_form.dart deleted file mode 100644 index eed6f266..00000000 --- a/lib/src/screens/form/recipe_import_form.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; - -class RecipeImportForm extends StatefulWidget { - final String importUrl; - - const RecipeImportForm([this.importUrl = '']); - - @override - _RecipeImportFormState createState() => _RecipeImportFormState(); -} - -class _RecipeImportFormState extends State { - final _importUrlController = TextEditingController(); - - @override - void initState() { - super.initState(); - - _importUrlController.text = widget.importUrl; - if (widget.importUrl.isNotEmpty) { - SchedulerBinding.instance.addPostFrameCallback((_) { - BlocProvider.of(context) - .add(RecipeImported(_importUrlController.text)); - }); - } - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (BuildContext context, RecipeState state) { - final enabled = state.status != RecipeStatus.updateInProgress; - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Form( - child: Column( - children: [ - TextField( - enabled: enabled, - controller: _importUrlController, - decoration: InputDecoration( - hintText: translate("recipe_import.field"), - suffixIcon: IconButton( - tooltip: translate("recipe_import.clipboard"), - onPressed: () async { - final clipboard = - await Clipboard.getData('text/plain'); - final text = clipboard?.text; - if (text != null) _importUrlController.text = text; - }, - icon: const Icon(Icons.content_copy), - ), - ), - ), - Center( - child: TextButton( - onPressed: () { - if (enabled) { - BlocProvider.of(context) - .add(RecipeImported(_importUrlController.text)); - } - }, - child: enabled - ? Row( - children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.only(right: 9.0), - child: - Text(translate("recipe_import.button")), - ), - const Icon(Icons.cloud_download_outlined), - const Spacer(), - ], - ) - : SpinKitWave( - color: Theme.of(context).primaryColor, - size: 30.0, - ), - ), - ) - ], - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/src/screens/loading_screen.dart b/lib/src/screens/loading_screen.dart index 12f3d33d..cd49a401 100644 --- a/lib/src/screens/loading_screen.dart +++ b/lib/src/screens/loading_screen.dart @@ -4,39 +4,39 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; class LoadingErrorScreen extends StatelessWidget { - const LoadingErrorScreen({super.key}); + const LoadingErrorScreen({required this.message, super.key}); + + final String message; @override Widget build(BuildContext context) { return Scaffold( - body: BlocBuilder( - builder: (context, authenticationState) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(authenticationState.error!), - const SizedBox(height: 10), - ElevatedButton( - onPressed: () { - BlocProvider.of(context) - .add(const AppStarted()); - }, - child: Text(translate("login.retry")), - ), - const SizedBox(height: 10), - ElevatedButton( - onPressed: () { - BlocProvider.of(context) - .add(const LoggedOut()); - }, - child: Text(translate("login.reset")), - ), - ], - ), - ); - }, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + BlocProvider.of(context) + .add(const AppStarted()); + }, + child: Text(translate("login.retry")), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + BlocProvider.of(context) + .add(const LoggedOut()); + }, + child: Text(translate("login.reset")), + ), + ], + ), + ), ), ); } diff --git a/lib/src/screens/login_screen.dart b/lib/src/screens/login_screen.dart index 27cf57ce..f1447272 100644 --- a/lib/src/screens/login_screen.dart +++ b/lib/src/screens/login_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/login/login_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/form/login_form.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; class LoginScreen extends StatelessWidget { final bool invalidCredentials; @@ -35,10 +36,15 @@ class LoginScreen extends StatelessWidget { void notifyIfInvalidCredentials(BuildContext context) { if (invalidCredentials) { + final theme = + Theme.of(context).extension()!.errorSnackBar; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(translate('login.errors.credentials_invalid')), - backgroundColor: Colors.red, + content: Text( + translate('login.errors.credentials_invalid'), + style: theme.contentTextStyle, + ), + backgroundColor: theme.backgroundColor, ), ); } diff --git a/lib/src/screens/my_settings_screen.dart b/lib/src/screens/my_settings_screen.dart index 8a389c49..2bba58f5 100644 --- a/lib/src/screens/my_settings_screen.dart +++ b/lib/src/screens/my_settings_screen.dart @@ -7,14 +7,9 @@ import 'package:nextcloud_cookbook_flutter/src/util/setting_keys.dart'; import 'package:nextcloud_cookbook_flutter/src/util/supported_locales.dart'; import 'package:theme_mode_handler/theme_mode_handler.dart'; -class MySettingsScreen extends StatefulWidget { +class MySettingsScreen extends StatelessWidget { const MySettingsScreen({super.key}); - @override - State createState() => _MySettingsScreenState(); -} - -class _MySettingsScreenState extends State { @override Widget build(BuildContext context) { return SettingsScreen( @@ -25,24 +20,6 @@ class _MySettingsScreenState extends State { settingKey: SettingKeys.stay_awake.name, subtitle: translate("settings.stay_awake.subtitle"), ), - SliderSettingsTile( - title: translate("settings.recipe_font_size.title"), - settingKey: SettingKeys.recipe_font_size.name, - defaultValue: Theme.of(context).textTheme.bodyMedium!.fontSize!, - min: 10, - max: 25, - eagerUpdate: false, - subtitle: translate("settings.recipe_font_size.subtitle"), - ), - SliderSettingsTile( - title: translate("settings.category_font_size.title"), - settingKey: SettingKeys.category_font_size.name, - defaultValue: 16, - min: 10, - max: 25, - eagerUpdate: false, - subtitle: translate("settings.category_font_size.subtitle"), - ), DropDownSettingsTile( title: translate("settings.dark_mode.title"), settingKey: SettingKeys.dark_mode.name, @@ -78,7 +55,6 @@ class _MySettingsScreenState extends State { } else { changeLocale(context, value as String?); } - setState(() {}); }, ) ], diff --git a/lib/src/screens/recipe/recipe_screen.dart b/lib/src/screens/recipe/recipe_screen.dart deleted file mode 100644 index 3f983a5c..00000000 --- a/lib/src/screens/recipe/recipe_screen.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter_settings_screens/flutter_settings_screens.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; -import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/widget/ingredient_list.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/widget/instruction_list.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/widget/nutrition_list.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe_edit_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/setting_keys.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/animated_time_progress_bar.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/duration_indicator.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:wakelock/wakelock.dart'; - -class RecipeScreen extends StatefulWidget { - final String recipeId; - - const RecipeScreen({ - super.key, - required this.recipeId, - }); - - @override - State createState() => RecipeScreenState(); -} - -class RecipeScreenState extends State { - bool isLargeScreen = false; - - Future _disableWakelock() async { - final bool wakelockEnabled = await Wakelock.enabled; - if (wakelockEnabled) { - Wakelock.disable(); - } - return Future.value(true); - } - - void _enableWakelock() { - if (Settings.getValue( - SettingKeys.stay_awake.name, - defaultValue: false, - )!) { - Wakelock.enable(); - } - } - - @override - void initState() { - _enableWakelock(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - isLargeScreen = MediaQuery.of(context).size.width > 600; - return BlocProvider( - create: (context) => RecipeBloc()..add(RecipeLoaded(widget.recipeId)), - child: BlocBuilder( - builder: (BuildContext context, RecipeState state) { - final recipeBloc = BlocProvider.of(context); - return WillPopScope( - onWillPop: () => _disableWakelock(), - child: Scaffold( - appBar: AppBar( - title: Text(translate('recipe.title')), - actions: [ - // action button - IconButton( - icon: const Icon( - Icons.edit, - ), - onPressed: () async { - if (state.status == RecipeStatus.loadSuccess) { - _disableWakelock(); - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return BlocProvider.value( - value: recipeBloc, - child: RecipeEditScreen(state.recipe!), - ); - }, - ), - ); - _enableWakelock(); - } - }, - ), - ], - ), - floatingActionButton: state.status == RecipeStatus.loadSuccess - ? _buildFabButton(state.recipe!) - : null, - body: () { - switch (state.status) { - case RecipeStatus.loadSuccess: - return _buildRecipeScreen(state.recipe!); - case RecipeStatus.loadInProgress: - return const Center( - child: CircularProgressIndicator(), - ); - case RecipeStatus.failure: - return Center( - child: Text(state.error!), - ); - default: - return const Center( - child: Text("FAILED"), - ); - } - }(), - ), - ); - }, - ), - ); - } - - FloatingActionButton _buildFabButton(Recipe recipe) { - final enabled = recipe.cookTime != null; - return FloatingActionButton( - onPressed: () { - { - if (enabled) { - TimerList().timers.add(Timer(recipe)); - setState(() {}); - final snackBar = - SnackBar(content: Text(translate('timer.started'))); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } else { - final snackBar = - SnackBar(content: Text(translate('timer.missing'))); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } - } - }, - backgroundColor: enabled - ? Theme.of(context).colorScheme.secondary - : Theme.of(context).disabledColor, - child: const Icon(Icons.access_alarm), - ); - } - - Widget _buildRecipeScreen(Recipe recipe) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - final TextStyle settingsBasedTextStyle = TextStyle( - fontSize: Settings.getValue( - SettingKeys.recipe_font_size.name, - defaultValue: Theme.of(context).textTheme.bodyMedium?.fontSize, - ), - ); - - return ListView( - children: [ - SizedBox( - width: double.infinity, - height: 200, - child: Center( - child: RecipeImage( - id: recipe.id, - size: const Size(double.infinity, 200), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: ListView( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 15.0), - child: Text( - recipe.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: MarkdownBody( - data: recipe.description, - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Row( - children: [ - RichText( - text: TextSpan( - text: translate('recipe.fields.servings'), - style: Theme.of(context) - .textTheme - .bodyMedium! - .apply(fontWeightDelta: 3), - children: [ - TextSpan( - text: " ${recipe.recipeYield}", - style: Theme.of(context) - .textTheme - .bodyMedium! - .apply(fontWeightDelta: 3), - ) - ], - ), - ), - const Spacer(), - if (recipe.url.isNotEmpty) - ElevatedButton( - style: const ButtonStyle(), - onPressed: () async { - if (await launchUrlString(recipe.url)) { - await launchUrlString(recipe.url); - } - }, - child: - Text(translate('recipe.fields.source_button')), - ) - ], - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 10, - spacing: 10, - children: [ - if (recipe.prepTime != null) - DurationIndicator( - duration: recipe.prepTime!, - name: translate('recipe.prep'), - ), - if (recipe.cookTime != null) - DurationIndicator( - duration: recipe.cookTime!, - name: translate('recipe.cook'), - ), - if (recipe.totalTime != null) - DurationIndicator( - duration: recipe.totalTime!, - name: translate('recipe.total'), - ), - ], - ), - ), - _showTimers(recipe), - if (recipe.tool.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Theme( - data: Theme.of(context) - .copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text(translate('recipe.fields.tools')), - children: [ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 15.0), - child: Text( - recipe.tool.fold( - "", - (p, e) => "$p- ${e.trim()}\n", - ), - style: settingsBasedTextStyle, - ), - ), - ), - ], - ), - ), - ), - if (isLargeScreen && - recipe.recipeIngredient.isNotEmpty && - recipe.nutritionList.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 5, - child: NutritionList(recipe.nutritionList), - ), - Expanded( - flex: 5, - child: IngredientList( - recipe, - settingsBasedTextStyle, - ), - ), - Expanded( - flex: 5, - child: InstructionList( - recipe, - settingsBasedTextStyle, - ), - ) - ], - ), - ) - else - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Column( - children: [ - if (recipe.nutritionList.isNotEmpty) - NutritionList(recipe.nutritionList), - if (recipe.recipeIngredient.isNotEmpty) - IngredientList(recipe, settingsBasedTextStyle), - InstructionList(recipe, settingsBasedTextStyle) - ], - ), - ) - ], - ), - ), - ], - ); - }, - ); - } - - Widget _showTimers(Recipe recipe) { - final timers = TimerList().timers; - if (timers.isNotEmpty) { - return Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Column( - children: [ - ListView.builder( - shrinkWrap: true, - itemCount: timers.length, - itemBuilder: (context, index) { - return _buildTimerListItem(timers[index]); - }, - ) - ], - ), - ); - } - return const SizedBox.shrink(); - } - - ListTile _buildTimerListItem(Timer timer) { - return ListTile( - key: UniqueKey(), - title: AnimatedTimeProgressBar( - timer: timer, - ), - trailing: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - TimerList().remove(timer); - setState(() {}); - }, - ), - ); - } -} diff --git a/lib/src/screens/recipe/widget/ingredient_list.dart b/lib/src/screens/recipe/widget/ingredient_list.dart deleted file mode 100644 index bc86a7b0..00000000 --- a/lib/src/screens/recipe/widget/ingredient_list.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; - -class IngredientList extends StatefulWidget { - final Recipe _recipe; - final TextStyle _textStyle; - - const IngredientList( - this._recipe, - this._textStyle, { - super.key, - }); - - @override - _IngredientListState createState() => _IngredientListState(); -} - -class _IngredientListState extends State { - late List _ingredientsDone; - - @override - void initState() { - _ingredientsDone = - List.filled(widget._recipe.recipeIngredient.length, false); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text(translate('recipe.fields.ingredients')), - initiallyExpanded: true, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - itemBuilder: (context, index) { - return widget._recipe.recipeIngredient[index].startsWith('##') - ? Text( - widget._recipe.recipeIngredient[index].replaceFirst( - RegExp(r'##\s*'), - '', - ), - style: widget._textStyle.copyWith( - fontFeatures: [const FontFeature.enable('smcp')], - ), - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: widget._textStyle.fontSize! * 1.5, - height: widget._textStyle.fontSize, - alignment: Alignment.center, - child: _ingredientsDone[index] - ? Icon( - Icons.check_circle, - size: widget._textStyle.fontSize, - color: Colors.green, - ) - : Icon( - Icons.circle, - size: widget._textStyle.fontSize! * 0.5, - ), - ), - Expanded( - child: GestureDetector( - onTap: () { - setState(() { - _ingredientsDone[index] = - !_ingredientsDone[index]; - }); - }, - child: Text( - widget._recipe.recipeIngredient[index], - style: widget._textStyle, - ), - ), - ), - ], - ); - }, - separatorBuilder: (c, i) => const SizedBox(height: 5), - itemCount: widget._recipe.recipeIngredient.length, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/screens/recipe/widget/instruction_list.dart b/lib/src/screens/recipe/widget/instruction_list.dart deleted file mode 100644 index 1bed4ee2..00000000 --- a/lib/src/screens/recipe/widget/instruction_list.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; - -class InstructionList extends StatefulWidget { - final Recipe _recipe; - final TextStyle _textStyle; - - const InstructionList( - this._recipe, - this._textStyle, { - super.key, - }); - - @override - _InstructionListState createState() => _InstructionListState(); -} - -class _InstructionListState extends State { - late List _instructionsDone; - - @override - void initState() { - _instructionsDone = - List.filled(widget._recipe.recipeInstructions.length, false); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 40.0), - child: Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text(translate('recipe.fields.instructions')), - initiallyExpanded: true, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - setState(() { - _instructionsDone[index] = !_instructionsDone[index]; - }); - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 40, - height: 40, - margin: const EdgeInsets.only(right: 15, top: 10), - decoration: ShapeDecoration( - shape: const CircleBorder( - side: BorderSide(color: Colors.grey), - ), - color: _instructionsDone[index] - ? Colors.green - : Theme.of(context).colorScheme.background, - ), - child: _instructionsDone[index] - ? const Icon(Icons.check) - : Center(child: Text("${index + 1}")), - ), - Expanded( - child: Text( - widget._recipe.recipeInstructions[index], - style: widget._textStyle, - ), - ), - ], - ), - ); - }, - separatorBuilder: (c, i) => const SizedBox(height: 10), - itemCount: widget._recipe.recipeInstructions.length, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/screens/recipe/widget/nutrition_list.dart b/lib/src/screens/recipe/widget/nutrition_list.dart deleted file mode 100644 index 13bfb0c0..00000000 --- a/lib/src/screens/recipe/widget/nutrition_list.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/widget/nutrition_list_item.dart'; - -class NutritionList extends StatelessWidget { - final Map _nutrition; - - const NutritionList( - this._nutrition, { - super.key, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text(translate('recipe.fields.nutrition.title')), - children: [ - Wrap( - spacing: 10, - runSpacing: 10, - children: _nutrition.entries - .map( - (e) => NutritionListItem( - translate('recipe.fields.nutrition.items.${e.key}'), - e.value, - ), - ) - .toList(), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/screens/recipe/widget/nutrition_list_item.dart b/lib/src/screens/recipe/widget/nutrition_list_item.dart deleted file mode 100644 index 5e939238..00000000 --- a/lib/src/screens/recipe/widget/nutrition_list_item.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -class NutritionListItem extends StatelessWidget { - final String name; - final String value; - const NutritionListItem( - this.name, - this.value, { - super.key, - }); - - @override - Widget build(BuildContext context) { - return IntrinsicWidth( - child: Column( - children: [ - Container( - height: 30, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(3), - topRight: Radius.circular(3), - ), - border: Border.all( - color: Theme.of(context).hintColor, - ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: Text( - name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - ), - Container( - height: 30, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(3), - bottomRight: Radius.circular(3), - ), - border: Border.all( - color: Theme.of(context).hintColor, - ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: Text( - value, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/screens/recipe_create_screen.dart b/lib/src/screens/recipe_create_screen.dart deleted file mode 100644 index 81209f63..00000000 --- a/lib/src/screens/recipe_create_screen.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/form/recipe_form.dart'; - -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/recipe_screen.dart'; - -class RecipeCreateScreen extends StatelessWidget { - const RecipeCreateScreen({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => RecipeBloc(), - child: Scaffold( - appBar: AppBar( - title: BlocListener( - listener: (BuildContext context, RecipeState state) { - if (state.status == RecipeStatus.createFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - 'recipe_create.errors.update_failed', - args: {"error_msg": state.error}, - ), - ), - backgroundColor: Colors.red, - ), - ); - } else if (state.status == RecipeStatus.createSuccess) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - RecipeScreen(recipeId: state.recipeId!), - ), - ); - } - }, - child: Text(translate('recipe_create.title')), - ), - ), - body: const RecipeForm(), - ), - ); - } -} diff --git a/lib/src/screens/recipe_edit_screen.dart b/lib/src/screens/recipe_edit_screen.dart index 96f130b8..1e85016a 100644 --- a/lib/src/screens/recipe_edit_screen.dart +++ b/lib/src/screens/recipe_edit_screen.dart @@ -1,55 +1,371 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/categories/categories_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/form/recipe_form.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/alerts/recipe_delete_alert.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/alerts/recipe_edit_alert.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/input/duration_form_field.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/input/integer_text_form_field.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/input/reorderable_list_form_field.dart'; -class RecipeEditScreen extends StatelessWidget { - final Recipe recipe; +class RecipeEditScreen extends StatefulWidget { + final Recipe? recipe; - const RecipeEditScreen( - this.recipe, { + const RecipeEditScreen({ + this.recipe, super.key, }); + @override + State createState() => _RecipeEditScreenState(); +} + +class _RecipeEditScreenState extends State { + final _formKey = GlobalKey(); + late RecipeBuilder _mutableRecipe; + late TextEditingController categoryController; + + @override + void initState() { + super.initState(); + + _mutableRecipe = RecipeBuilder(); + + if (widget.recipe != null) { + _mutableRecipe.replace(widget.recipe!); + } + categoryController = + TextEditingController(text: _mutableRecipe.recipeCategory); + } + + @override + void dispose() { + categoryController.dispose(); + + super.dispose(); + } + + String get translationKey => + widget.recipe != null ? 'recipe_edit' : 'recipe_create'; + + Future onDelete() async { + final decision = await showDialog( + context: context, + builder: (context) => DeleteRecipeAlert(recipe: widget.recipe!), + ); + + if (decision ?? false) { + FocusManager.instance.primaryFocus?.unfocus(); + // ignore: use_build_context_synchronously + BlocProvider.of(context).add(RecipeDeleted(widget.recipe!)); + } + } + + void onSubmit() { + if (_formKey.currentState?.validate() ?? false) { + _formKey.currentState?.save(); + if (widget.recipe == null) { + _mutableRecipe.dateCreated = DateTime.now().toUtc(); + BlocProvider.of(context) + .add(RecipeCreated(_mutableRecipe.build())); + } else { + _mutableRecipe.dateModified = DateTime.now().toUtc(); + BlocProvider.of(context) + .add(RecipeUpdated(_mutableRecipe.build())); + } + } + } + + Future onWillPop() async { + if (widget.recipe != null) { + final RecipeBloc recipeBloc = BlocProvider.of(context); + if (recipeBloc.state.status == RecipeStatus.updateFailure) { + recipeBloc.add(RecipeLoaded(widget.recipe!.id!)); + } + } + + final decision = await showDialog( + context: context, + builder: (context) => CancelEditAlert(recipe: widget.recipe), + ); + + if (decision == true) { + FocusManager.instance.primaryFocus?.unfocus(); + } + + return decision ?? false; + } + @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () { - final RecipeBloc recipeBloc = BlocProvider.of(context); - if (recipeBloc.state.status == RecipeStatus.updateFailure) { - recipeBloc.add(RecipeLoaded(recipe.id!)); - } - return Future(() => true); - }, - child: Scaffold( - appBar: AppBar( - title: BlocListener( - listener: (BuildContext context, RecipeState state) { - if (state.status == RecipeStatus.updateFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - 'recipe_edit.errors.update_failed', - args: {"error_msg": state.error}, - ), - ), - backgroundColor: Colors.red, - ), - ); - } else if (state.status == RecipeStatus.updateSuccess) { - BlocProvider.of(context) - .add(RecipeLoaded(state.recipeId!)); - Navigator.pop(context); - } - }, - child: Text(translate('recipe_edit.title')), + return Scaffold( + appBar: AppBar( + title: Text(translate('$translationKey.title')), + actions: [ + if (widget.recipe != null) ...[ + IconButton( + icon: const Icon(Icons.delete), + tooltip: translate("recipe_edit.delete.title").toLowerCase(), + color: Theme.of(context).colorScheme.error, + onPressed: onDelete, + ), + ], + ], + ), + body: BlocConsumer( + builder: builder, + listener: listener, + ), + floatingActionButton: FloatingActionButton( + tooltip: translate('$translationKey.button'), + onPressed: onSubmit, + child: const Icon(Icons.check_outlined), + ), + ); + } + + Widget builder(BuildContext context, RecipeState state) { + final theme = Theme.of(context); + final headerStile = theme.textTheme.titleMedium!.copyWith( + color: theme.colorScheme.secondary, + ); + + final enabled = state.status != RecipeStatus.updateInProgress; + + final children = [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + translate('recipe.fields.general'), + style: headerStile, + ), + ), + TextFormField( + enabled: enabled, + initialValue: _mutableRecipe.name, + textInputAction: TextInputAction.next, + onChanged: (value) => _mutableRecipe.name = value, + autofocus: true, + validator: (value) { + if (value == null || value.isEmpty) { + return translate("form.validators.required"); + } + + return null; + }, + decoration: InputDecoration( + labelText: translate('recipe.fields.name'), + ), + ), + TextFormField( + enabled: enabled, + maxLines: 100, + minLines: 1, + textInputAction: TextInputAction.newline, + initialValue: _mutableRecipe.description, + onChanged: (value) => _mutableRecipe.description = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.description'), + ), + ), + TypeAheadFormField( + getImmediateSuggestions: true, + textFieldConfiguration: TextFieldConfiguration( + controller: categoryController, + decoration: InputDecoration( + labelText: translate('recipe.fields.category'), + ), + textInputAction: TextInputAction.next, + ), + suggestionsCallback: DataRepository().getMatchingCategoryNames, + itemBuilder: (context, String suggestion) { + return ListTile( + title: Text(suggestion), + ); + }, + onSuggestionSelected: (String? suggestion) { + if (suggestion == null) return; + categoryController.text = suggestion; + }, + onSaved: (value) => _mutableRecipe.recipeCategory = value, + ), + TextFormField( + enabled: enabled, + textInputAction: TextInputAction.next, + initialValue: _mutableRecipe.keywords, + onSaved: (value) => _mutableRecipe.keywords = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.keywords'), + ), + ), + TextFormField( + enabled: enabled, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + initialValue: _mutableRecipe.url, + onSaved: (value) => _mutableRecipe.url = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.source'), + ), + ), + if (widget.recipe != null) + TextFormField( + enabled: false, + style: const TextStyle(color: Colors.grey), + textInputAction: TextInputAction.next, + initialValue: _mutableRecipe.imageUrl, + onSaved: (value) => _mutableRecipe.imageUrl = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.image'), + ), + ), + IntegerTextFormField( + enabled: enabled, + minValue: 1, + textInputAction: TextInputAction.next, + initialValue: _mutableRecipe.recipeYield, + onSaved: (value) => _mutableRecipe.recipeYield = value, + onChanged: (value) => setState(() { + _mutableRecipe.recipeYield = value; + }), + decoration: InputDecoration( + labelText: translate('recipe.fields.servings.else'), + suffixText: translatePlural( + 'recipe.fields.servings', + _mutableRecipe.recipeYield!, ), ), - body: RecipeForm(recipe: recipe), + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + translate('recipe.fields.times'), + style: headerStile, + ), + ), + DurationFormField( + enabled: enabled, + initialValue: _mutableRecipe.prepTime, + onSaved: (value) => _mutableRecipe.prepTime = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.time.prep'), + ), + ), + DurationFormField( + enabled: enabled, + initialValue: _mutableRecipe.cookTime, + onSaved: (value) => _mutableRecipe.cookTime = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.time.cook'), + ), + ), + DurationFormField( + enabled: enabled, + initialValue: _mutableRecipe.totalTime, + onSaved: (value) => _mutableRecipe.totalTime = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.time.total'), + ), + ), + if (state.status == RecipeStatus.updateInProgress) + SpinKitWave( + color: Theme.of(context).colorScheme.onSecondary, + size: 30.0, + ), + ]; + + return Form( + key: _formKey, + onWillPop: onWillPop, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: children[index], + ), + childCount: children.length, + ), + ), + ReordarableListFormField( + title: translate('recipe.fields.tools'), + initialValues: _mutableRecipe.tool, + enabled: enabled, + onSaved: (value) => _mutableRecipe.tool = value, + decoration: InputDecoration( + labelText: translate('recipe.fields.time.total'), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ReordarableListFormField( + title: translate('recipe.fields.ingredients'), + initialValues: _mutableRecipe.recipeIngredient, + enabled: enabled, + onSaved: (value) => _mutableRecipe.recipeIngredient = value, + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 75), + sliver: ReordarableListFormField( + title: translate('recipe.fields.instructions'), + initialValues: _mutableRecipe.recipeInstructions, + enabled: enabled, + onSaved: (value) => _mutableRecipe.recipeInstructions = value, + ), + ), + ], + ), ), ); } + + void listener(BuildContext context, RecipeState state) { + final theme = Theme.of(context).extension()!.errorSnackBar; + + switch (state.status) { + case RecipeStatus.createFailure: + case RecipeStatus.updateFailure: + case RecipeStatus.deleteFailure: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + '$translationKey.errors.update_failed', + args: {"error_msg": state.error}, + ), + style: theme.contentTextStyle, + ), + backgroundColor: theme.backgroundColor, + ), + ); + break; + case RecipeStatus.updateSuccess: + BlocProvider.of(context).add(RecipeLoaded(state.recipeId!)); + Navigator.pop(context); + break; + case RecipeStatus.delteSuccess: + BlocProvider.of(context).add(const CategoriesLoaded()); + Navigator.pop(context); + Navigator.pop(context); + break; + case RecipeStatus.createSuccess: + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => RecipeScreen(recipeId: state.recipeId!), + ), + ); + break; + default: + } + } } diff --git a/lib/src/screens/recipe_import_screen.dart b/lib/src/screens/recipe_import_screen.dart index 6e7163e2..98709a86 100644 --- a/lib/src/screens/recipe_import_screen.dart +++ b/lib/src/screens/recipe_import_screen.dart @@ -1,50 +1,173 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/form/recipe_import_form.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/theme_data.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/url_validator.dart'; -class RecipeImportScreen extends StatelessWidget { +class RecipeImportScreen extends StatefulWidget { final String importUrl; const RecipeImportScreen([this.importUrl = '']); + @override + State createState() => _RecipeImportScreenState(); +} + +class _RecipeImportScreenState extends State { + late TextEditingController _importUrlController; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _importUrlController = TextEditingController(text: widget.importUrl); + if (widget.importUrl.isNotEmpty) { + SchedulerBinding.instance + .addPostFrameCallback((_) => import(widget.importUrl)); + } + } + + @override + void dispose() { + _importUrlController.dispose(); + super.dispose(); + } + + void import(String? url) { + if (url != null) { + BlocProvider.of(context).add(RecipeImported(url)); + } + } + + void onSubmit() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + } + } + + String? validate(String? value) { + if (value == null || value.isEmpty) { + return translate( + 'login.server_url.validator.pattern', + ); + } + + if (!URLUtils.isValid(value)) { + return translate( + 'login.server_url.validator.pattern', + ); + } + + return null; + } + + Future pasteClipboard() async { + final clipboard = await Clipboard.getData('text/plain'); + final text = clipboard?.text; + if (text != null) { + _importUrlController.text = text; + } + + _formKey.currentState!.validate(); + } + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RecipeBloc(), child: Scaffold( appBar: AppBar( - title: BlocListener( - child: Text(translate("recipe_import.title")), - listener: (context, state) { - if (state.status == RecipeStatus.importFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translate( - 'recipe_import.errors.import_failed', - args: {"error_msg": state.error}, - ), - ), - backgroundColor: Colors.red, - ), - ); - } else if (state.status == RecipeStatus.importSuccess) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return RecipeScreen(recipeId: state.recipe!.id!); - }, + title: Text(translate("recipe_import.title")), + ), + body: BlocConsumer( + builder: builder, + listener: listener, + ), + ), + ); + } + + void listener(BuildContext context, RecipeState state) { + if (state.status == RecipeStatus.importFailure) { + final theme = + Theme.of(context).extension()!.errorSnackBar; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + 'recipe_import.errors.import_failed', + args: {"error_msg": state.error}, + ), + style: theme.contentTextStyle, + ), + backgroundColor: theme.backgroundColor, + ), + ); + } else if (state.status == RecipeStatus.importSuccess) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return RecipeScreen(recipeId: state.recipe!.id!); + }, + ), + ); + } + } + + Widget builder(BuildContext context, RecipeState state) { + final enabled = state.status != RecipeStatus.updateInProgress; + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + enabled: enabled, + controller: _importUrlController, + validator: enabled ? validate : null, + onSaved: import, + onEditingComplete: onSubmit, + decoration: InputDecoration( + hintText: translate("recipe_import.field"), + suffixIcon: IconButton( + tooltip: MaterialLocalizations.of(context).pasteButtonLabel, + onPressed: pasteClipboard, + icon: const Icon(Icons.content_copy_outlined), ), - ); - } - }, + ), + ), + const SizedBox(height: 10), + Center( + child: enabled + ? OutlinedButton.icon( + onPressed: enabled ? onSubmit : null, + icon: const Icon(Icons.cloud_download_outlined), + label: Text(translate("recipe_import.button")), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 25), + side: BorderSide( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ) + : SpinKitWave( + color: Theme.of(context).colorScheme.primary, + size: 30.0, + ), + ), + ], ), ), - body: RecipeImportForm(importUrl), ), ); } diff --git a/lib/src/screens/recipe_screen.dart b/lib/src/screens/recipe_screen.dart new file mode 100644 index 00000000..5e797277 --- /dev/null +++ b/lib/src/screens/recipe_screen.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/animated_list.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_edit_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/wakelock.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/timer_list_item.dart'; + +class RecipeScreen extends StatelessWidget { + final String recipeId; + + const RecipeScreen({ + required this.recipeId, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RecipeBloc()..add(RecipeLoaded(recipeId)), + child: BlocBuilder( + builder: builder, + ), + ); + } + + Widget builder(BuildContext context, RecipeState state) { + switch (state.status) { + case RecipeStatus.loadSuccess: + return RecipeScreenBody(recipe: state.recipe!); + case RecipeStatus.loadFailure: + case RecipeStatus.createFailure: + case RecipeStatus.deleteFailure: + case RecipeStatus.updateFailure: + case RecipeStatus.importFailure: + return Scaffold( + body: Center( + child: Text(state.error!), + ), + ); + default: + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + } +} + +class RecipeScreenBody extends StatefulWidget { + const RecipeScreenBody({ + required this.recipe, + super.key, + }); + + final Recipe recipe; + + @override + State createState() => _RecipeScreenBodyState(); +} + +class _RecipeScreenBodyState extends State { + final GlobalKey _listKey = + GlobalKey(); + + late Recipe recipe; + late AnimatedTimerList _list; + + Future _onEdit() async { + disableWakelock(); + + final recipeBloc = BlocProvider.of(context); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: recipeBloc, + child: RecipeEditScreen(recipe: recipe), + ), + ), + ); + enableWakelock(); + } + + Widget _buildTimerItem( + BuildContext context, + int index, + Animation animation, + ) { + return TimerListItem( + animation: animation, + item: _list[index], + dense: true, + onDismissed: () { + _list.removeAt(index); + }, + ); + } + + Widget _buildRemovedTimerItem( + Timer item, + BuildContext context, + Animation animation, + ) { + return TimerListItem( + animation: animation, + item: item, + dense: true, + enabled: false, + ); + } + + @override + void initState() { + super.initState(); + + recipe = widget.recipe; + enableWakelock(); + + _list = AnimatedTimerList.forId( + listKey: _listKey, + removedItemBuilder: _buildRemovedTimerItem, + recipeId: recipe.id!, + ); + } + + @override + Widget build(BuildContext context) { + final list = [ + if (recipe.nutritionList.isNotEmpty) NutritionList(recipe.nutritionList), + if (recipe.recipeIngredient.isNotEmpty) IngredientList(recipe), + InstructionList(recipe), + ]; + + final header = SliverList( + delegate: SliverChildListDelegate.fixed([ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: MarkdownBody(data: recipe.description), + ), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: RecipeYield(recipe: recipe), + ), + DurationList(recipe: recipe), + ]), + ); + + final timerList = SliverAnimatedList( + key: _listKey, + initialItemCount: _list.length, + itemBuilder: _buildTimerItem, + ); + + final bottom = SliverList( + delegate: SliverChildListDelegate.fixed([ + if (recipe.tool.isNotEmpty) ToolList(recipe: recipe), + if (MediaQuery.of(context).size.width > 600) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: list.map((e) => Expanded(flex: 5, child: e)).toList(), + ) + else + ...list, + ]), + ); + + final body = WillPopScope( + onWillPop: disableWakelock, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: RecipeImage( + id: recipe.id, + size: const Size(double.infinity, 350), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: header, + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 16.0), + sliver: timerList, + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 75.0), + sliver: bottom, + ), + ], + ), + ), + ); + + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: translate("recipe_create.title").toLowerCase(), + onPressed: _onEdit, + ), + ], + title: Text(recipe.name), + centerTitle: true, + ), + body: body, + floatingActionButton: RecipeScreenFab( + enabled: recipe.cookTime != null, + onPressed: () => _list.add(Timer(recipe)), + ), + ); + } +} + +class RecipeScreenFab extends StatelessWidget { + const RecipeScreenFab({ + required this.onPressed, + required this.enabled, + super.key, + }); + + final VoidCallback onPressed; + final bool enabled; + + @override + Widget build(BuildContext context) { + final color = enabled + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).disabledColor; + + void callback() { + final SnackBar snackBar; + if (enabled) { + onPressed(); + snackBar = SnackBar(content: Text(translate('timer.started'))); + } else { + snackBar = SnackBar(content: Text(translate('timer.missing'))); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + return FloatingActionButton( + onPressed: callback, + tooltip: translate("timer.button.start"), + backgroundColor: color, + child: const Icon(Icons.access_alarm_outlined), + ); + } +} diff --git a/lib/src/screens/recipes_list_screen.dart b/lib/src/screens/recipes_list_screen.dart index 817c7334..b5d2234b 100644 --- a/lib/src/screens/recipes_list_screen.dart +++ b/lib/src/screens/recipes_list_screen.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nc_cookbook_api/nc_cookbook_api.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/recipes_short/recipes_short_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/recipe_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe_list_item.dart'; class RecipesListScreen extends StatefulWidget { final String category; @@ -16,97 +14,65 @@ class RecipesListScreen extends StatefulWidget { }); @override - State createState() => RecipesListScreenState(); + State createState() => _RecipesListScreenState(); } -class RecipesListScreenState extends State { +class _RecipesListScreenState extends State { + Future refresh() async { + DefaultCacheManager().emptyCache(); + BlocProvider.of(context) + .add(RecipesShortLoaded(category: widget.category)); + } + @override void initState() { super.initState(); - } - @override - Widget build(BuildContext context) { BlocProvider.of(context) .add(RecipesShortLoaded(category: widget.category)); - - return BlocBuilder( - builder: (context, recipesShortState) { - return Scaffold( - appBar: AppBar( - title: Text( - translate( - 'recipe_list.title_category', - args: {'category': widget.category}, - ), - ), - actions: [ - // action button - IconButton( - icon: Icon( - Icons.refresh, - semanticLabel: translate('app_bar.refresh'), - ), - onPressed: () { - DefaultCacheManager().emptyCache(); - BlocProvider.of(context) - .add(RecipesShortLoaded(category: widget.category)); - }, - ), - ], - ), - body: RefreshIndicator( - onRefresh: () { - DefaultCacheManager().emptyCache(); - BlocProvider.of(context) - .add(RecipesShortLoaded(category: widget.category)); - return Future.value(); - }, - child: () { - if (recipesShortState.status == RecipesShortStatus.loadSuccess) { - return _buildRecipesShortScreen( - recipesShortState.recipesShort!, - ); - } else { - return const Center(child: CircularProgressIndicator()); - } - }(), - ), - ); - }, - ); } - Widget _buildRecipesShortScreen(Iterable data) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: ListView.separated( - itemCount: data.length, - itemBuilder: (context, index) { - return _buildRecipeStubScreen(data.elementAt(index)); - }, - separatorBuilder: (context, index) => const Divider( - color: Colors.black, + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + translate( + 'recipe_list.title_category', + args: {'category': widget.category}, + ), ), + actions: [ + // action button + IconButton( + icon: const Icon(Icons.refresh_outlined), + tooltip: + MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, + onPressed: refresh, + ), + ], ), - ); - } - - ListTile _buildRecipeStubScreen(RecipeStub recipe) { - return ListTile( - title: Text(recipe.name), - trailing: RecipeImage( - id: recipe.recipeId, - size: const Size.square(60), + body: RefreshIndicator( + onRefresh: refresh, + child: BlocBuilder( + builder: (context, recipesShortState) { + if (recipesShortState.status == RecipesShortStatus.loadSuccess) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: ListView.separated( + itemCount: recipesShortState.recipesShort!.length, + itemBuilder: (context, index) => RecipeListItem( + recipe: recipesShortState.recipesShort!.elementAt(index), + ), + separatorBuilder: (context, index) => const Divider(), + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RecipeScreen(recipeId: recipe.recipeId), - ), - ); - }, ); } } diff --git a/lib/src/screens/splash_screen.dart b/lib/src/screens/splash_screen.dart index 63f36abb..0308859b 100644 --- a/lib/src/screens/splash_screen.dart +++ b/lib/src/screens/splash_screen.dart @@ -9,7 +9,7 @@ class SplashPage extends StatelessWidget { return Scaffold( body: Center( child: SpinKitWave( - color: Theme.of(context).primaryColor, + color: Theme.of(context).colorScheme.primary, ), ), ); diff --git a/lib/src/screens/timer_screen.dart b/lib/src/screens/timer_screen.dart index 0adc7ee7..c5b7ec0a 100644 --- a/lib/src/screens/timer_screen.dart +++ b/lib/src/screens/timer_screen.dart @@ -1,95 +1,86 @@ import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/animated_list.dart'; import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; -import 'package:nextcloud_cookbook_flutter/src/screens/recipe/recipe_screen.dart'; -import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/animated_time_progress_bar.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/timer_list_item.dart'; class TimerScreen extends StatefulWidget { const TimerScreen({super.key}); @override - _TimerScreen createState() => _TimerScreen(); + State createState() => _TimerScreenState(); } -class _TimerScreen extends State { - late List _list; +class _TimerScreenState extends State { + final GlobalKey _listKey = + GlobalKey(); + late AnimatedTimerList _list; @override void initState() { super.initState(); + _list = AnimatedTimerList( + listKey: _listKey, + removedItemBuilder: _buildRemovedItem, + ); + } + + Widget _buildItem( + BuildContext context, + int index, + Animation animation, + ) { + return TimerListItem( + animation: animation, + item: _list[index], + onDismissed: () { + _list.removeAt(index); + }, + ); + } + + Widget _buildRemovedItem( + Timer item, + BuildContext context, + Animation animation, + ) { + return TimerListItem( + animation: animation, + item: item, + enabled: false, + ); } @override Widget build(BuildContext context) { - _list = TimerList().timers; - return Scaffold( appBar: AppBar( title: Text(translate('timer.title')), actions: [ - // action button - IconButton( - icon: Icon( - Icons.clear_all, - semanticLabel: translate('app_bar.clear_all'), + if (_list.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear_all_outlined), + tooltip: translate('app_bar.clear_all'), + onPressed: _list.removeAll, ), - onPressed: () { - TimerList().clear(); - setState(() {}); - }, - ), ], ), - body: () { - return _buildTimerScreen(_list); - }(), - ); - } - - Widget _buildTimerScreen(List data) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: ListView.separated( - itemCount: data.length, - itemBuilder: (context, index) { - return _buildListItem(data[index]); - }, - separatorBuilder: (context, index) => const Divider( - color: Colors.black, - ), - ), - ); - } - - ListTile _buildListItem(Timer timer) { - return ListTile( - key: UniqueKey(), - leading: RecipeImage( - id: timer.recipeId, - size: const Size.square(60), - ), - title: Text(timer.title), - subtitle: AnimatedTimeProgressBar( - timer: timer, - ), - isThreeLine: true, - trailing: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - TimerList().remove(timer); - setState(() {}); - }, - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RecipeScreen(recipeId: timer.recipeId), - ), - ); - }, + body: _list.isNotEmpty + ? CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(bottom: 16.0), + sliver: SliverAnimatedList( + key: _listKey, + initialItemCount: _list.length, + itemBuilder: _buildItem, + ), + ), + ], + ) + : Center( + child: Text(translate('timer.empty_list')), + ), ); } } diff --git a/lib/src/services/api_provider.dart b/lib/src/services/api_provider.dart index 865c5107..c0f07007 100644 --- a/lib/src/services/api_provider.dart +++ b/lib/src/services/api_provider.dart @@ -7,12 +7,23 @@ class ApiProvider { ApiProvider._() { final auth = UserRepository().currentAppAuthentication; - ncCookbookApi = NcCookbookApi( - dio: Dio(BaseOptions( + final client = Dio( + BaseOptions( baseUrl: '${auth.server}/apps/cookbook', connectTimeout: const Duration(milliseconds: 30000), receiveTimeout: const Duration(milliseconds: 30000), - )) + ), + ); + + if (auth.isSelfSignedCertificate) { + client.httpClientAdapter = IOHttpClientAdapter( + onHttpClientCreate: (client) => + client..badCertificateCallback = (cert, host, port) => true, + ); + } + + ncCookbookApi = NcCookbookApi( + dio: client, ); ncCookbookApi.setBasicAuth( diff --git a/lib/src/services/data_repository.dart b/lib/src/services/data_repository.dart index bed178d7..7ad4b927 100644 --- a/lib/src/services/data_repository.dart +++ b/lib/src/services/data_repository.dart @@ -14,6 +14,8 @@ class DataRepository { // Data static final String categoryAll = translate('categories.all_categories'); + static final String categoryUncategorized = + translate('categories.uncategorized'); // Actions Future?> fetchRecipesShort({ @@ -22,7 +24,7 @@ class DataRepository { if (category == categoryAll) { final response = await api.recipeApi.listRecipes(); return response.data; - } else if (category == "*") { + } else if (category == categoryUncategorized) { final response = await api.categoryApi.recipesInCategory(category: "_"); return response.data; } else { @@ -40,12 +42,17 @@ class DataRepository { Future updateRecipe(Recipe recipe) async { final response = await api.recipeApi.updateRecipe(id: recipe.id!, recipe: recipe); - return response.data?.toString(); + return response.data; } Future createRecipe(Recipe recipe) async { final response = await api.recipeApi.newRecipe(recipe: recipe); - return response.data?.toString(); + return response.data; + } + + Future deleteRecipe(Recipe recipe) async { + final response = await api.recipeApi.deleteRecipe(id: recipe.id!); + return response.data; } Future importRecipe(String url) async { @@ -73,6 +80,19 @@ class DataRepository { ); } + final uncategorizedBuilder = CategoryBuilder(); + + categories?.removeWhere((c) { + if (c.name == "*") { + uncategorizedBuilder.replace(c); + uncategorizedBuilder.name = categoryUncategorized; + return true; + } + return false; + }); + + categories?.add(uncategorizedBuilder.build()); + return categories?.build(); } diff --git a/lib/src/services/intent_repository.dart b/lib/src/services/intent_repository.dart index 86f025c3..431b8bf3 100644 --- a/lib/src/services/intent_repository.dart +++ b/lib/src/services/intent_repository.dart @@ -15,9 +15,7 @@ class IntentRepository { if (importUrl != null) { _navigationKey.currentState?.pushAndRemoveUntil( MaterialPageRoute( - builder: (BuildContext context) => () { - return RecipeImportScreen(importUrl); - }(), + builder: (context) => RecipeImportScreen(importUrl), ), ModalRoute.withName('/'), ); diff --git a/lib/src/services/timer_repository.dart b/lib/src/services/timer_repository.dart index 32342027..5c879721 100644 --- a/lib/src/services/timer_repository.dart +++ b/lib/src/services/timer_repository.dart @@ -19,10 +19,4 @@ class TimerList { NotificationService().cancel(timer); _timers.remove(timer); } - - void clear() { - for (final timer in _timers) { - remove(timer); - } - } } diff --git a/lib/src/util/category_grid_delegate.dart b/lib/src/util/category_grid_delegate.dart new file mode 100644 index 00000000..f092ba24 --- /dev/null +++ b/lib/src/util/category_grid_delegate.dart @@ -0,0 +1,45 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; + +class CategoryGridDelegate extends SliverGridDelegate { + const CategoryGridDelegate({ + this.extent = 0.0, + }); + + final double extent; + + static const double maxCrossAxisExtent = 250; + static const double mainAxisSpacing = 8; + static const double crossAxisSpacing = 8; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + int crossAxisCount = + (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) + .ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = math.max(1, crossAxisCount); + final double usableCrossAxisExtent = math.max( + 0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + final double childMainAxisExtent = childCrossAxisExtent + extent; + + return SliverGridRegularTileLayout( + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(CategoryGridDelegate oldDelegate) { + return oldDelegate.extent != extent; + } +} diff --git a/lib/src/util/custom_cache_manager.dart b/lib/src/util/custom_cache_manager.dart new file mode 100644 index 00000000..37a83adb --- /dev/null +++ b/lib/src/util/custom_cache_manager.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:http/io_client.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; + +class CustomCacheManager { + static final CustomCacheManager _cacheManager = CustomCacheManager._(); + factory CustomCacheManager() => _cacheManager; + + CustomCacheManager._(); + + static const key = 'customCacheKey'; + + CacheManager selfSignedCacheManager = CacheManager( + Config( + key, + fileService: HttpFileService( + httpClient: IOClient( + HttpClient() + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true, + ), + ), + ), + ); + + CacheManager get instance { + if (UserRepository().currentAppAuthentication.isSelfSignedCertificate) { + return selfSignedCacheManager; + } else { + return DefaultCacheManager(); + } + } +} diff --git a/lib/src/util/duration_utils.dart b/lib/src/util/duration_utils.dart new file mode 100644 index 00000000..539da5a9 --- /dev/null +++ b/lib/src/util/duration_utils.dart @@ -0,0 +1,15 @@ +import 'package:flutter_translate/flutter_translate.dart'; + +extension DurationExtension on Duration { + String get translatedString { + return "$inHours ${translate('recipe.fields.time.hours')} : ${inMinutes.remainder(60)} ${translate('recipe.fields.time.minutes')}"; + } + + String formatMinutes() { + return "$inHours:${inMinutes.remainder(60).toString().padLeft(2, '0')}"; + } + + String formatSeconds() { + return "${inHours.toString().padLeft(2, '0')}:${inMinutes.remainder(60).toString().padLeft(2, '0')}:${(inSeconds.remainder(60)).toString().padLeft(2, '0')}"; + } +} diff --git a/lib/src/util/setting_keys.dart b/lib/src/util/setting_keys.dart index 12a9789e..683625a0 100644 --- a/lib/src/util/setting_keys.dart +++ b/lib/src/util/setting_keys.dart @@ -4,6 +4,8 @@ enum SettingKeys { dark_mode, language, stay_awake, + @Deprecated("The font size will try to scale propperly") recipe_font_size, + @Deprecated("The font size will try to scale propperly") category_font_size, } diff --git a/lib/src/util/theme_data.dart b/lib/src/util/theme_data.dart new file mode 100644 index 00000000..ccaeeb63 --- /dev/null +++ b/lib/src/util/theme_data.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + const AppTheme._(); + + /// Light theme + static final lightThemeData = ThemeData( + hintColor: Colors.grey, + colorScheme: lightColorSheme, + inputDecorationTheme: inputdecoration, + useMaterial3: true, + extensions: [ + SnackBarThemes( + colorScheme: lightColorSheme, + ), + ], + ); + + /// Dark theme + static final darkThemeData = ThemeData( + hintColor: Colors.grey, + colorScheme: darkColorSheme, + inputDecorationTheme: inputdecoration, + useMaterial3: true, + extensions: [ + SnackBarThemes( + colorScheme: darkColorSheme, + ), + ], + ); + + static const nextCloudBlue = Color.fromRGBO(00, 130, 201, 1.0); + + static final lightColorSheme = ColorScheme.fromSeed( + seedColor: nextCloudBlue, + ); + + static final darkColorSheme = ColorScheme.fromSeed( + seedColor: nextCloudBlue, + brightness: Brightness.dark, + ); + + static const inputdecoration = InputDecorationTheme( + border: OutlineInputBorder(), + floatingLabelBehavior: FloatingLabelBehavior.always, + ); + + static final snackbarTheme = SnackBarThemeData( + backgroundColor: lightColorSheme.error, + contentTextStyle: TextStyle( + color: lightColorSheme.onError, + ), + ); +} + +@immutable +class SnackBarThemes extends ThemeExtension { + const SnackBarThemes({ + required this.colorScheme, + }); + + final ColorScheme colorScheme; + + SnackBarThemeData get errorSnackBar => SnackBarThemeData( + backgroundColor: colorScheme.errorContainer, + contentTextStyle: TextStyle( + color: colorScheme.onErrorContainer, + ), + ); + + SnackBarThemeData get warningSnackBar => const SnackBarThemeData( + backgroundColor: Colors.orange, + ); + + @override + SnackBarThemes copyWith({ColorScheme? colorScheme}) { + return SnackBarThemes( + colorScheme: colorScheme ?? this.colorScheme, + ); + } + + @override + SnackBarThemes lerp(SnackBarThemes? other, double t) { + if (other is! SnackBarThemes) { + return this; + } + return SnackBarThemes( + colorScheme: ColorScheme.lerp(colorScheme, other.colorScheme, t), + ); + } + + @override + String toString() => 'SnackBarThemes(colorScheme: $colorScheme)'; +} diff --git a/lib/src/util/wakelock.dart b/lib/src/util/wakelock.dart new file mode 100644 index 00000000..7883b8b7 --- /dev/null +++ b/lib/src/util/wakelock.dart @@ -0,0 +1,20 @@ +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/setting_keys.dart'; +import 'package:wakelock/wakelock.dart'; + +Future disableWakelock() async { + final bool wakelockEnabled = await Wakelock.enabled; + if (wakelockEnabled) { + Wakelock.disable(); + } + return Future.value(true); +} + +void enableWakelock() { + if (Settings.getValue( + SettingKeys.stay_awake.name, + defaultValue: false, + )!) { + Wakelock.enable(); + } +} diff --git a/lib/src/widget/alerts/recipe_delete_alert.dart b/lib/src/widget/alerts/recipe_delete_alert.dart new file mode 100644 index 00000000..d699719d --- /dev/null +++ b/lib/src/widget/alerts/recipe_delete_alert.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; + +class DeleteRecipeAlert extends StatelessWidget { + const DeleteRecipeAlert({ + required this.recipe, + super.key, + }); + + final Recipe recipe; + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.delete_forever), + iconColor: Theme.of(context).colorScheme.error, + title: Text(translate("recipe_edit.delete.title")), + content: Text( + translate( + "recipe_edit.delete.dialog", + args: {"recipe": recipe.name}, + ), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(MaterialLocalizations.of(context).deleteButtonTooltip), + ), + ], + ); + } +} diff --git a/lib/src/widget/alerts/recipe_edit_alert.dart b/lib/src/widget/alerts/recipe_edit_alert.dart new file mode 100644 index 00000000..a53a259f --- /dev/null +++ b/lib/src/widget/alerts/recipe_edit_alert.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; + +class CancelEditAlert extends StatelessWidget { + const CancelEditAlert({ + this.recipe, + super.key, + }); + + final Recipe? recipe; + + @override + Widget build(BuildContext context) { + final key = recipe == null ? 'dismiss_create' : 'dismiss_edit'; + return AlertDialog( + title: Text(translate('recipe_form.dismiss_create.title')), + content: Text( + translate( + 'recipe_form.$key.dialog', + args: {"recipe": recipe?.name}, + ), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(translate('alert.discard')), + ), + ], + ); + } +} diff --git a/lib/src/widget/animated_time_progress_bar.dart b/lib/src/widget/animated_time_progress_bar.dart index 995571ac..76da38ee 100644 --- a/lib/src/widget/animated_time_progress_bar.dart +++ b/lib/src/widget/animated_time_progress_bar.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/duration_utils.dart'; -class AnimatedTimeProgressBar extends StatefulWidget { +class AnimatedTimeProgressBar extends StatelessWidget { final Timer timer; const AnimatedTimeProgressBar({ @@ -10,50 +11,16 @@ class AnimatedTimeProgressBar extends StatefulWidget { required this.timer, }); - @override - _AnimatedTimeProgressBarState createState() => - _AnimatedTimeProgressBarState(); -} - -class _AnimatedTimeProgressBarState extends State - with TickerProviderStateMixin { - late AnimationController _controller; - late Timer _timer; - late Tween _timerTween; - - _AnimatedTimeProgressBarState(); - - @override - void initState() { - _timer = widget.timer; - - _timerTween = Tween( - begin: _timer.progress, - end: 1.0, - ); - - _controller = AnimationController( - duration: _timer.remaining, - vsync: this, - ); - - _controller.forward().whenCompleteOrCancel(() {}); - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - child: Container(), - builder: (context, child) { - if (_controller.isCompleted) { + return TweenAnimationBuilder( + duration: timer.remaining, + tween: Tween( + begin: timer.progress, + end: 1.0, + ), + builder: (context, value, child) { + if (value == 1.0) { return Text(translate('timer.done')); } @@ -62,21 +29,22 @@ class _AnimatedTimeProgressBarState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "${_timer.remaining.inHours.toString().padLeft(2, '0')}:${_timer.remaining.inMinutes.remainder(60).toString().padLeft(2, '0')}:${(_timer.remaining.inSeconds.remainder(60)).toString().padLeft(2, '0')}", - ), - Text( - "${_timer.duration.inHours.toString().padLeft(2, '0')}:${_timer.duration.inMinutes.remainder(60).toString().padLeft(2, '0')}:${(_timer.duration.inSeconds.remainder(60)).toString().padLeft(2, '0')}", - ), + Text(timer.remaining.formatSeconds()), + child!, ], ), - LinearProgressIndicator( - value: _timerTween.evaluate(_controller) as double?, - semanticsLabel: _timer.title, - ) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: value, + color: Theme.of(context).colorScheme.primaryContainer, + semanticsLabel: timer.title, + ), + ), ], ); }, + child: Text(timer.duration.formatSeconds()), ); } } diff --git a/lib/src/widget/category_card.dart b/lib/src/widget/category_card.dart index 3777a023..56018b48 100644 --- a/lib/src/widget/category_card.dart +++ b/lib/src/widget/category_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:flutter_translate/flutter_translate.dart'; import 'package:nc_cookbook_api/nc_cookbook_api.dart'; -import 'package:nextcloud_cookbook_flutter/src/util/setting_keys.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipes_list_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; class CategoryCard extends StatelessWidget { @@ -14,70 +14,73 @@ class CategoryCard extends StatelessWidget { super.key, }); + static const double _spacer = 8; + static const _labelPadding = EdgeInsets.symmetric(horizontal: 8.0); + + static TextStyle _nameStyle(BuildContext context) => + Theme.of(context).textTheme.labelSmall!; + + static TextStyle _itemStyle(BuildContext context) => + Theme.of(context).textTheme.labelSmall!; + + static double hightExtend(BuildContext context) { + return _spacer + + _itemStyle(context).fontSize! + + _itemStyle(context).fontSize! + + 2 * _labelPadding.horizontal; + } + @override Widget build(BuildContext context) { - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), - elevation: 7, - child: Stack( - fit: StackFit.passthrough, - children: [ - ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (bounds) { - return const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.center, - colors: [Colors.black, Colors.transparent], - ).createShader(bounds); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: RecipeImage( - id: imageID, - size: const Size.square(250), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - category.name, - textAlign: TextAlign.center, - overflow: TextOverflow.fade, - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: Settings.getValue( - SettingKeys.category_font_size.name, - defaultValue: 16, + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.maxWidth; + + final String itemsText = translatePlural( + 'categories.items', + category.recipeCount, + ); + + return GestureDetector( + child: Card( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: RecipeImage( + id: imageID, + size: Size.square(size), + ), ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Align( - alignment: Alignment.bottomRight, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.deepOrange, - border: Border.all(color: Colors.deepOrangeAccent, width: 2), + const SizedBox(height: _spacer), + Padding( + padding: _labelPadding, + child: Text( + category.name, + maxLines: 1, + style: _nameStyle(context), + ), ), - child: Center( + Padding( + padding: _labelPadding, child: Text( - category.recipeCount.toString(), - style: const TextStyle(color: Colors.white), + itemsText, + style: _itemStyle(context), ), ), - ), + ], + ), + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RecipesListScreen(category: category.name), ), ), - ], - ), + ); + }, ); } } diff --git a/lib/src/widget/drawer.dart b/lib/src/widget/drawer.dart new file mode 100644 index 00000000..fda23456 --- /dev/null +++ b/lib/src/widget/drawer.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/my_settings_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_import_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/timer_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/drawer_item.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/user_image.dart'; + +class MainDrawer extends StatelessWidget { + const MainDrawer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: const UserImage(), + ), + DrawerItem( + icon: Icons.alarm_add_outlined, + title: translate('timer.title'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TimerScreen(), + ), + ); + }, + ), + DrawerItem( + icon: Icons.cloud_download_outlined, + title: translate('categories.drawer.import'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RecipeImportScreen(), + ), + ); + }, + ), + DrawerItem( + icon: Icons.settings_outlined, + title: translate('categories.drawer.settings'), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const MySettingsScreen(), + ), + ); + }, + ), + DrawerItem( + icon: Icons.exit_to_app_outlined, + title: translate('app_bar.logout'), + onTap: () { + BlocProvider.of(context) + .add(const LoggedOut()); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/widget/drawer_item.dart b/lib/src/widget/drawer_item.dart new file mode 100644 index 00000000..c6651270 --- /dev/null +++ b/lib/src/widget/drawer_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class DrawerItem extends StatelessWidget { + const DrawerItem({ + required this.title, + this.icon, + this.onTap, + super.key, + }); + + final String title; + final IconData? icon; + final GestureTapCallback? onTap; + + @override + Widget build(BuildContext context) { + final leading = icon != null + ? Icon( + icon, + semanticLabel: title, + ) + : null; + + return SizedBox( + height: 56, + child: ListTile( + style: ListTileStyle.drawer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(28), + ), + ), + splashColor: Theme.of(context).colorScheme.secondaryContainer, + leading: leading, + title: Text(title), + onTap: onTap, + ), + ); + } +} diff --git a/lib/src/widget/duration_indicator.dart b/lib/src/widget/duration_indicator.dart deleted file mode 100644 index f05635cf..00000000 --- a/lib/src/widget/duration_indicator.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -class DurationIndicator extends StatelessWidget { - final Duration duration; - final String name; - - const DurationIndicator({ - super.key, - required this.duration, - required this.name, - }); - - @override - Widget build(BuildContext context) { - return IntrinsicWidth( - child: Column( - children: [ - Container( - height: 35, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(3), - topRight: Radius.circular(3), - ), - border: Border.all( - color: Theme.of(context).hintColor, - ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.only(left: 13, right: 13), - child: Text( - name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - ), - Container( - height: 35, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(3), - bottomRight: Radius.circular(3), - ), - border: Border.all( - color: Theme.of(context).hintColor, - ), - ), - child: Center( - child: Text( - "${duration.inHours}:${duration.inMinutes % 60 < 10 ? "0" : ""}${duration.inMinutes % 60}", - style: const TextStyle(fontSize: 16), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/widget/input/duration_form_field.dart b/lib/src/widget/input/duration_form_field.dart index 6831cbf7..5398f4ef 100644 --- a/lib/src/widget/input/duration_form_field.dart +++ b/lib/src/widget/input/duration_form_field.dart @@ -1,20 +1,28 @@ +import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/input/integer_text_form_field.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/duration_utils.dart'; class DurationFormField extends StatefulWidget { - final String title; - final RecipeState state; - final Duration? duration; - final void Function(Duration? value) onChanged; + final bool? enabled; + final Duration? initialValue; + final InputDecoration? decoration; + final void Function(Duration? value)? onSaved; + final TextAlign textAlign; + final FocusNode? focusNode; const DurationFormField({ super.key, - required this.state, - required this.duration, - required this.onChanged, - required this.title, + this.enabled, + this.initialValue, + this.onSaved, + this.decoration, + this.focusNode, + this.textAlign = TextAlign.end, }); @override @@ -22,95 +30,1010 @@ class DurationFormField extends StatefulWidget { } class _DurationFormFieldState extends State { - late Duration? currentDuration; - late bool enabled; - - Duration get duaration => currentDuration ?? Duration.zero; + final controller = TextEditingController(); + Duration? duration; + late FocusNode focusNode; @override void initState() { - currentDuration = widget.duration; - enabled = widget.state.status != RecipeStatus.updateInProgress; super.initState(); + + setDuration(widget.initialValue); + focusNode = widget.focusNode ?? FocusNode(debugLabel: 'DurationFormField'); + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + Future onTap() => showDialog( + context: context, + builder: (context) => DurationPickerDialog( + initialTime: duration ?? Duration.zero, + cancelText: translate('alert.clear'), + ), + ).then((selectedDuration) { + setDuration(selectedDuration); + focusNode.nextFocus(); + }); + + void setDuration(Duration? duration) { + if (duration != null && duration != Duration.zero) { + controller.text = duration.translatedString; + setState(() => this.duration = duration); + } else if (duration == Duration.zero) { + controller.clear(); + setState(() => this.duration = null); + } } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + return TextFormField( + controller: controller, + readOnly: true, + showCursor: false, + enabled: widget.enabled, + onTap: onTap, + focusNode: focusNode, + decoration: widget.decoration, + textAlign: widget.textAlign, + onSaved: (value) { + if (value != null && value.isNotEmpty) { + widget.onSaved?.call(duration); + } + }, + ); + } +} + +const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200); +const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); + +enum _DurationPickerMode { hour, minute } + +const double _kDurationPickerHeaderControlHeight = 80.0; + +const double _kDurationPickerWidthPortrait = 328.0; + +const double _kDurationPickerHeightInput = 226.0; + +const BorderRadius _kDefaultBorderRadius = + BorderRadius.all(Radius.circular(4.0)); +const ShapeBorder _kDefaultShape = + RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); + +/// A [RestorableValue] that knows how to save and restore [Duration]. +/// +/// {@macro flutter.widgets.RestorableNum}. +class RestorableDuration extends RestorableValue { + /// Creates a [RestorableDuration]. + /// + /// {@macro flutter.widgets.RestorableNum.constructor} + RestorableDuration(Duration defaultValue) : _defaultValue = defaultValue; + + final Duration _defaultValue; + + @override + Duration createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(Duration? oldValue) { + assert(debugIsSerializableForRestoration(value.inMinutes)); + notifyListeners(); + } + + @override + Duration fromPrimitives(Object? data) { + final List timeData = data! as List; + return Duration( + minutes: timeData[0]! as int, + hours: value.inHours, + ); + } + + @override + Object? toPrimitives() => [value.inMinutes.remainder(60), value.inHours]; +} + +/// A passive fragment showing a string value. +class _StringFragment extends StatelessWidget { + const _StringFragment(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData durationPickerTheme = TimePickerTheme.of(context); + final TextStyle hourMinuteStyle = durationPickerTheme.hourMinuteTextStyle ?? + theme.textTheme.displayMedium!; + final Color textColor = + durationPickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface; + + return ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: Center( + child: Text( + ":", + style: hourMinuteStyle.apply( + color: MaterialStateProperty.resolveAs( + textColor, + {}, + ), + ), + textScaleFactor: 1.0, ), ), - Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Text(translate('recipe.fields.time.hours')), - ), - SizedBox( - width: 70, - child: IntegerTextFormField( - enabled: enabled, - initialValue: duaration.inHours, - decoration: InputDecoration( - hintText: translate('recipe.fields.time.hours'), + ), + ); + } +} + +class _DurationPickerInput extends StatefulWidget { + const _DurationPickerInput({ + required this.initialSelectedTime, + required this.helpText, + required this.errorInvalidText, + required this.hourLabelText, + required this.minuteLabelText, + required this.autofocusHour, + required this.autofocusMinute, + required this.onChanged, + this.restorationId, + }); + + /// The time initially selected when the dialog is shown. + final Duration initialSelectedTime; + + /// Optionally provide your own help text to the time picker. + final String? helpText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + final bool? autofocusHour; + + final bool? autofocusMinute; + + final ValueChanged onChanged; + + /// Restoration ID to save and restore the state of the time picker input + /// widget. + /// + /// If it is non-null, the widget will persist and restore its state + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + final String? restorationId; + + @override + _DurationPickerInputState createState() => _DurationPickerInputState(); +} + +class _DurationPickerInputState extends State<_DurationPickerInput> + with RestorationMixin { + late final RestorableDuration _selectedTime = + RestorableDuration(widget.initialSelectedTime); + final RestorableBool hourHasError = RestorableBool(false); + final RestorableBool minuteHasError = RestorableBool(false); + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(hourHasError, 'hour_has_error'); + registerForRestoration(minuteHasError, 'minute_has_error'); + } + + int? _parseHour(String? value) { + if (value == null) { + return null; + } + + final int? newHour = int.tryParse(value); + if (newHour == null) { + return null; + } + + if (newHour >= 0) { + return newHour; + } + + return null; + } + + int? _parseMinute(String? value) { + if (value == null) { + return null; + } + + final int? newMinute = int.tryParse(value); + if (newMinute == null) { + return null; + } + + if (newMinute >= 0 && newMinute < 60) { + return newMinute; + } + return null; + } + + void _handleHourSavedSubmitted(String? value) { + final int? newHour = _parseHour(value); + if (newHour != null) { + _selectedTime.value = Duration( + hours: newHour, + minutes: _selectedTime.value.inMinutes.remainder(60), + ); + widget.onChanged(_selectedTime.value); + } + } + + void _handleHourChanged(String value) { + final int? newHour = _parseHour(value); + if (newHour != null && value.length == 2) { + // If a valid hour is typed, move focus to the minute TextField. + FocusScope.of(context).nextFocus(); + } + } + + void _handleMinuteSavedSubmitted(String? value) { + final int? newMinute = _parseMinute(value); + if (newMinute != null) { + _selectedTime.value = Duration( + hours: _selectedTime.value.inHours, + minutes: int.parse(value!), + ); + widget.onChanged(_selectedTime.value); + } + } + + String? _validateHour(String? value) { + final int? newHour = _parseHour(value); + setState(() { + hourHasError.value = newHour == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newHour == null ? '' : null; + } + + String? _validateMinute(String? value) { + final int? newMinute = _parseMinute(value); + setState(() { + minuteHasError.value = newMinute == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newMinute == null ? '' : null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final TextStyle hourMinuteStyle = + TimePickerTheme.of(context).hourMinuteTextStyle ?? + theme.textTheme.displayMedium!; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.helpText ?? + MaterialLocalizations.of(context).timePickerInputHelpText, + style: TimePickerTheme.of(context).helpTextStyle ?? + theme.textTheme.labelSmall, + ), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + _HourTextField( + restorationId: 'hour_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusHour, + validator: _validateHour, + onSavedSubmitted: _handleHourSavedSubmitted, + onChanged: _handleHourChanged, + hourLabelText: widget.hourLabelText, + ), + const SizedBox(height: 8.0), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.hourLabelText ?? + MaterialLocalizations.of(context) + .timePickerHourLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - onChanged: (value) { - currentDuration = _updateDuration( - hours: value, - ); - widget.onChanged(currentDuration); - }, ), - ), - Padding( - padding: const EdgeInsets.only(right: 12.0, left: 12.0), - child: Text(translate('recipe.fields.time.minutes')), - ), - SizedBox( - width: 50, - child: IntegerTextFormField( - enabled: enabled, - initialValue: duaration.inMinutes % 60, - maxValue: 60, - decoration: InputDecoration( - hintText: translate('recipe.fields.time.minutes'), + Container( + margin: const EdgeInsets.only(top: 8.0), + height: _kDurationPickerHeaderControlHeight, + child: const _StringFragment(), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + _MinuteTextField( + restorationId: 'minute_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusMinute, + validator: _validateMinute, + onSavedSubmitted: _handleMinuteSavedSubmitted, + minuteLabelText: widget.minuteLabelText, + ), + const SizedBox(height: 8.0), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.minuteLabelText ?? + MaterialLocalizations.of(context) + .timePickerMinuteLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - onChanged: (value) { - currentDuration = _updateDuration( - minutes: value, - ); - widget.onChanged(currentDuration); - }, ), + ], + ), + if (hourHasError.value || minuteHasError.value) + Text( + widget.errorInvalidText ?? + MaterialLocalizations.of(context).invalidTimeLabel, + style: theme.textTheme.bodyMedium! + .copyWith(color: theme.colorScheme.error), + ) + else + const SizedBox(height: 2.0), + ], + ), + ); + } +} + +class _HourTextField extends StatelessWidget { + const _HourTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.validator, + required this.onSavedSubmitted, + required this.onChanged, + required this.hourLabelText, + this.restorationId, + }); + + final Duration selectedTime; + final TextStyle style; + final bool? autofocus; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final ValueChanged onChanged; + final String? hourLabelText; + final String? restorationId; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: true, + autofocus: autofocus, + style: style, + semanticHintText: hourLabelText ?? + MaterialLocalizations.of(context).timePickerHourLabel, + validator: validator, + onSavedSubmitted: onSavedSubmitted, + onChanged: onChanged, + ); + } +} + +class _MinuteTextField extends StatelessWidget { + const _MinuteTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.validator, + required this.onSavedSubmitted, + required this.minuteLabelText, + this.restorationId, + }); + + final Duration selectedTime; + final TextStyle style; + final bool? autofocus; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final String? minuteLabelText; + final String? restorationId; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: false, + autofocus: autofocus, + style: style, + semanticHintText: minuteLabelText ?? + MaterialLocalizations.of(context).timePickerMinuteLabel, + validator: validator, + onSavedSubmitted: onSavedSubmitted, + ); + } +} + +class _HourMinuteTextField extends StatefulWidget { + const _HourMinuteTextField({ + required this.selectedTime, + required this.isHour, + required this.autofocus, + required this.style, + required this.semanticHintText, + required this.validator, + required this.onSavedSubmitted, + this.restorationId, + this.onChanged, + }); + + final Duration selectedTime; + final bool isHour; + final bool? autofocus; + final TextStyle style; + final String semanticHintText; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final ValueChanged? onChanged; + final String? restorationId; + + @override + _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); +} + +class _HourMinuteTextFieldState extends State<_HourMinuteTextField> + with RestorationMixin { + final RestorableTextEditingController controller = + RestorableTextEditingController(); + final RestorableBool controllerHasBeenSet = RestorableBool(false); + late FocusNode focusNode; + + @override + void initState() { + super.initState(); + focusNode = FocusNode() + ..addListener(() { + setState(() {}); // Rebuild. + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Only set the text value if it has not been populated with a localized + // version yet. + if (!controllerHasBeenSet.value) { + controllerHasBeenSet.value = true; + controller.value.text = _formattedValue; + } + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'text_editing_controller'); + registerForRestoration(controllerHasBeenSet, 'has_controller_been_set'); + } + + String get _formattedValue { + return !widget.isHour + ? widget.selectedTime.inMinutes.remainder(60).toString().padLeft(2, '0') + : widget.selectedTime.inHours.remainder(60).toString().padLeft(2, '0'); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData durationPickerTheme = TimePickerTheme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final InputDecorationTheme? inputDecorationTheme = + durationPickerTheme.inputDecorationTheme; + InputDecoration inputDecoration; + if (inputDecorationTheme != null) { + inputDecoration = + const InputDecoration().applyDefaults(inputDecorationTheme); + } else { + inputDecoration = InputDecoration( + contentPadding: EdgeInsets.zero, + filled: true, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error, width: 2.0), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary, width: 2.0), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error, width: 2.0), + ), + hintStyle: widget.style + .copyWith(color: colorScheme.onSurface.withOpacity(0.36)), + // TODO(rami-a): Remove this logic once https://github.com/flutter/flutter/issues/54104 is fixed. + errorStyle: const TextStyle( + fontSize: 0.0, + height: 0.0, + ), // Prevent the error text from appearing. + ); + } + final Color unfocusedFillColor = durationPickerTheme.hourMinuteColor ?? + colorScheme.onSurface.withOpacity(0.12); + // If screen reader is in use, make the hint text say hours/minutes. + // Otherwise, remove the hint text when focused because the centered cursor + // appears odd above the hint text. + // + // TODO(rami-a): Once https://github.com/flutter/flutter/issues/67571 is + // resolved, remove the window check for semantics being enabled on web. + final String? hintText = MediaQuery.of(context).accessibleNavigation || + WidgetsBinding.instance.window.semanticsEnabled + ? widget.semanticHintText + : (focusNode.hasFocus ? null : _formattedValue); + inputDecoration = inputDecoration.copyWith( + hintText: hintText, + fillColor: focusNode.hasFocus + ? Colors.transparent + : inputDecorationTheme?.fillColor ?? unfocusedFillColor, + ); + + return SizedBox( + height: _kDurationPickerHeaderControlHeight, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: UnmanagedRestorationScope( + bucket: bucket, + child: TextFormField( + restorationId: 'hour_minute_text_form_field', + autofocus: widget.autofocus ?? false, + expands: true, + maxLines: null, + inputFormatters: [ + LengthLimitingTextInputFormatter(2), + ], + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: widget.style.copyWith( + color: durationPickerTheme.hourMinuteTextColor ?? + colorScheme.onSurface, ), - ], - ) - ], + controller: controller.value, + decoration: inputDecoration, + validator: widget.validator, + onEditingComplete: () => + widget.onSavedSubmitted(controller.value.text), + onSaved: widget.onSavedSubmitted, + onFieldSubmitted: widget.onSavedSubmitted, + onChanged: widget.onChanged, + ), + ), + ), ); } +} + +/// A Material Design time picker designed to appear inside a popup dialog. +/// +/// Pass this widget to [showDialog]. The value returned by [showDialog] is the +/// selected [Duration] if the user taps the "OK" button, or null if the user +/// taps the "CANCEL" button. The selected time is reported by calling +/// [Navigator.pop]. +class DurationPickerDialog extends StatefulWidget { + /// Creates a Material Design time picker. + /// + /// [initialTime] must not be null. + const DurationPickerDialog({ + super.key, + this.initialTime = Duration.zero, + this.cancelText, + this.confirmText, + this.helpText, + this.errorInvalidText, + this.hourLabelText, + this.minuteLabelText, + this.restorationId, + }); + + /// The time initially selected when the dialog is shown. + final Duration initialTime; + + /// Optionally provide your own text for the cancel button. + /// + /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. + final String? cancelText; + + /// Optionally provide your own text for the confirm button. + /// + /// If null, the button uses [MaterialLocalizations.okButtonLabel]. + final String? confirmText; + + /// Optionally provide your own help text to the header of the time picker. + final String? helpText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + /// Restoration ID to save and restore the state of the [DurationPickerDialog]. + /// + /// If it is non-null, the time picker will persist and restore the + /// dialog's state. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + @override + State createState() => _DurationPickerDialogState(); +} + +// A restorable [_RestorableDurationPickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableDurationPickerMode + extends RestorableValue<_DurationPickerMode> { + _RestorableDurationPickerMode( + _DurationPickerMode defaultValue, + ) : _defaultValue = defaultValue; + + final _DurationPickerMode _defaultValue; + + @override + _DurationPickerMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_DurationPickerMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); + } + + @override + _DurationPickerMode fromPrimitives(Object? data) => + _DurationPickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [AutovalidateMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableAutovalidateMode extends RestorableValue { + _RestorableAutovalidateMode( + AutovalidateMode defaultValue, + ) : _defaultValue = defaultValue; + + final AutovalidateMode _defaultValue; + + @override + AutovalidateMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(AutovalidateMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); + } + + @override + AutovalidateMode fromPrimitives(Object? data) => + AutovalidateMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +class _RestorableDurationPickerModeN + extends RestorableValue<_DurationPickerMode?> { + _RestorableDurationPickerModeN( + _DurationPickerMode? defaultValue, + ) : _defaultValue = defaultValue; + + final _DurationPickerMode? _defaultValue; + + @override + _DurationPickerMode? createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_DurationPickerMode? oldValue) { + assert(debugIsSerializableForRestoration(value?.index)); + notifyListeners(); + } + + @override + _DurationPickerMode fromPrimitives(Object? data) => + _DurationPickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value?.index; +} + +class _DurationPickerDialogState extends State + with RestorationMixin { + final GlobalKey _formKey = GlobalKey(); + + final _RestorableDurationPickerMode _mode = + _RestorableDurationPickerMode(_DurationPickerMode.hour); + final _RestorableDurationPickerModeN _lastModeAnnounced = + _RestorableDurationPickerModeN(null); + final _RestorableAutovalidateMode _autovalidateMode = + _RestorableAutovalidateMode(AutovalidateMode.disabled); + final RestorableBoolN _autofocusHour = RestorableBoolN(null); + final RestorableBoolN _autofocusMinute = RestorableBoolN(null); + final RestorableBool _announcedInitialTime = RestorableBool(false); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + _announceInitialTimeOnce(); + _announceModeOnce(); + } - Duration? _updateDuration({ - int? hours, - int? minutes, - }) { - if (hours != null) { - final int currentMinutes = duaration.inMinutes % 60; + @override + String? get restorationId => widget.restorationId; - return Duration(hours: hours, minutes: currentMinutes); + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_mode, 'mode'); + registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); + registerForRestoration(_autovalidateMode, 'autovalidateMode'); + registerForRestoration(_autofocusHour, 'autofocus_hour'); + registerForRestoration(_autofocusMinute, 'autofocus_minute'); + registerForRestoration(_announcedInitialTime, 'announced_initial_time'); + registerForRestoration(_selectedTime, 'selected_time'); + } + + RestorableDuration get selectedTime => _selectedTime; + late final RestorableDuration _selectedTime = + RestorableDuration(widget.initialTime); + + Timer? _vibrateTimer; + late MaterialLocalizations localizations; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _vibrateTimer?.cancel(); + _vibrateTimer = Timer(_kVibrateCommitDelay, () { + HapticFeedback.vibrate(); + _vibrateTimer = null; + }); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; } + } + + void _announceModeOnce() { + if (_lastModeAnnounced.value == _mode.value) { + // Already announced it. + return; + } + + switch (_mode.value) { + case _DurationPickerMode.hour: + _announceToAccessibility( + context, + localizations.timePickerHourModeAnnouncement, + ); + break; + case _DurationPickerMode.minute: + _announceToAccessibility( + context, + localizations.timePickerMinuteModeAnnouncement, + ); + break; + } + _lastModeAnnounced.value = _mode.value; + } + + void _announceInitialTimeOnce() { + if (_announcedInitialTime.value) { + return; + } + + _announceToAccessibility( + context, + widget.initialTime.formatMinutes(), + ); + _announcedInitialTime.value = true; + } - if (minutes != null) { - final int currentHours = duaration.inHours; + void _handleTimeChanged(Duration value) { + _vibrate(); + setState(() { + _selectedTime.value = value; + }); + } + + void _handleCancel() { + Navigator.pop(context, Duration.zero); + } - return Duration(hours: currentHours, minutes: minutes); + void _handleOk() { + final FormState form = _formKey.currentState!; + if (!form.validate()) { + setState(() { + _autovalidateMode.value = AutovalidateMode.always; + }); + return; } + form.save(); + + Navigator.pop(context, _selectedTime.value); + } + + Size _dialogSize(BuildContext context) { + // Constrain the textScaleFactor to prevent layout issues. Since only some + // parts of the time picker scale up with textScaleFactor, we cap the factor + // to 1.1 as that provides enough space to reasonably fit all the content. + final double textScaleFactor = + math.min(MediaQuery.of(context).textScaleFactor, 1.1); + + return Size( + _kDurationPickerWidthPortrait, + _kDurationPickerHeightInput * textScaleFactor, + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final ShapeBorder shape = + TimePickerTheme.of(context).shape ?? _kDefaultShape; + + final Widget actions = Row( + children: [ + const SizedBox(width: 10.0), + Expanded( + child: Container( + alignment: AlignmentDirectional.centerEnd, + constraints: const BoxConstraints(minHeight: 52.0), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: [ + TextButton( + onPressed: _handleCancel, + child: Text( + widget.cancelText ?? localizations.cancelButtonLabel, + ), + ), + TextButton( + onPressed: _handleOk, + child: + Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ], + ); - return currentDuration; + final Widget picker = Form( + key: _formKey, + autovalidateMode: _autovalidateMode.value, + child: SingleChildScrollView( + restorationId: 'time_picker_scroll_view', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _DurationPickerInput( + initialSelectedTime: _selectedTime.value, + helpText: widget.helpText, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + autofocusHour: _autofocusHour.value, + autofocusMinute: _autofocusMinute.value, + onChanged: _handleTimeChanged, + restorationId: 'time_picker_input', + ), + actions, + ], + ), + ), + ); + + final Size dialogSize = _dialogSize(context); + return Dialog( + shape: shape, + backgroundColor: TimePickerTheme.of(context).backgroundColor ?? + theme.colorScheme.surface, + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: AnimatedContainer( + width: dialogSize.width, + height: dialogSize.height, + duration: _kDialogSizeAnimationDuration, + curve: Curves.easeIn, + child: picker, + ), + ); + } + + @override + void dispose() { + _vibrateTimer?.cancel(); + _vibrateTimer = null; + super.dispose(); } } + +void _announceToAccessibility(BuildContext context, String message) { + SemanticsService.announce(message, Directionality.of(context)); +} diff --git a/lib/src/widget/input/integer_text_form_field.dart b/lib/src/widget/input/integer_text_form_field.dart index 2788fcfd..a97589d6 100644 --- a/lib/src/widget/input/integer_text_form_field.dart +++ b/lib/src/widget/input/integer_text_form_field.dart @@ -1,85 +1,69 @@ import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; -class IntegerTextFormField extends StatefulWidget { +class IntegerTextFormField extends StatelessWidget { final int initialValue; final bool? enabled; final InputDecoration? decoration; - final void Function(int value)? onChanged; - final void Function(int value)? onSaved; + final ValueChanged? onSaved; + final ValueChanged? onChanged; + final TextInputAction? textInputAction; final int? minValue; final int? maxValue; + final TextAlign textAlign; - const IntegerTextFormField({ + IntegerTextFormField({ super.key, int? initialValue, this.enabled, this.decoration, - this.onChanged, this.onSaved, + this.onChanged, + this.textInputAction, this.minValue, this.maxValue, - }) : initialValue = initialValue ?? 0, + this.textAlign = TextAlign.end, + }) : assert(initialValue != null || minValue != null), + initialValue = initialValue ?? minValue!, + assert(minValue == null || initialValue! >= minValue), assert((minValue == null || maxValue == null) || minValue <= maxValue); - @override - State createState() => _IntegerTextFormFieldState(); -} - -class _IntegerTextFormFieldState extends State { - late TextEditingController controller; - - @override - void initState() { - super.initState(); - final int curVal = _ensureMinMax(widget.initialValue); - controller = TextEditingController(text: curVal.toString()); - - controller.addListener(_updateController); - } - @override Widget build(BuildContext context) { return TextFormField( - enabled: widget.enabled, - controller: controller, - decoration: widget.decoration, + enabled: enabled, + initialValue: initialValue.toString(), + decoration: decoration, + textAlign: textAlign, keyboardType: TextInputType.number, - ); - } + textInputAction: textInputAction, + onSaved: (newValue) { + if (newValue == null) return; - void _updateController() { - final String value = controller.text; - if (value.isEmpty) { - widget.onChanged?.call(_ensureMinMax(0)); - } else { - final parsedValue = _ensureMinMax(_parseValue(value)); - if (controller.text != parsedValue.toString()) { - controller.value = TextEditingValue( - text: parsedValue.toString(), - selection: TextSelection.fromPosition( - TextPosition(offset: parsedValue.toString().length), - ), - ); - } - widget.onChanged?.call(parsedValue); - } + onSaved?.call(int.parse(newValue)); + }, + onChanged: (value) { + final int$ = int.tryParse(value); + if (int$ != null) { + onChanged?.call(int$); + } + }, + validator: (value) { + if (value == null || !ensureMinMax(int.tryParse(value))) { + return translate("form.validators.invalid_number"); + } + + return null; + }, + ); } - int _ensureMinMax(int value) { - final min = widget.minValue; - final max = widget.maxValue; + bool ensureMinMax(int? value) { + if (value == null) return false; - if (min != null && value < min) return min; - if (max != null && value > max) return max; - return value; - } + if (minValue != null && value < minValue!) return false; + if (maxValue != null && value > maxValue!) return false; - int _parseValue(String input) { - final regexMatches = RegExp(r'(\d+)').allMatches(input); - if (regexMatches.isEmpty) { - return 0; - } else { - return int.parse(regexMatches.elementAt(0).group(0)!); - } + return true; } } diff --git a/lib/src/widget/input/list_form_field.dart b/lib/src/widget/input/list_form_field.dart deleted file mode 100644 index 0fc82b94..00000000 --- a/lib/src/widget/input/list_form_field.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; -import 'package:nextcloud_cookbook_flutter/src/widget/my_reorderable_list.dart' - as my; - -class ListFormField extends StatefulWidget { - final String title; - final RecipeState state; - final List list; - final void Function(List value) onChanged; - - const ListFormField({ - super.key, - required this.state, - required this.list, - required this.title, - required this.onChanged, - }); - - @override - _ListFormFieldState createState() => _ListFormFieldState(); -} - -class _ListFormFieldState extends State { - late List currentList; - - @override - void initState() { - currentList = widget.list - .map((item) => ListTile(title: Text(item), key: ValueKey(item))) - .toList(); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 56.0 * currentList.length, - child: my.ReorderableListView( - onReorder: _onReorder, - children: currentList, - ), - ); - } - - void _onReorder(int oldIndex, int newIndex) { - final ListTile tile = currentList.removeAt(oldIndex); - currentList.insert(newIndex, tile); - - setState(() { - widget.onChanged( - currentList.map((tile) { - final Text title = tile.title! as Text; - log(title.data!); - return title.data!; - }).toList(), - ); - }); - } -} diff --git a/lib/src/widget/input/reorderable_list_form_field.dart b/lib/src/widget/input/reorderable_list_form_field.dart index 8033b0a6..067d27a3 100644 --- a/lib/src/widget/input/reorderable_list_form_field.dart +++ b/lib/src/widget/input/reorderable_list_form_field.dart @@ -1,186 +1,140 @@ import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_reorderable_list/flutter_reorderable_list.dart' as rl; -import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:sliver_tools/sliver_tools.dart'; -class ReorderableListFormField extends StatefulWidget { +class ReordarableListFormField extends FormField> { + ReordarableListFormField({ + super.key, + required String title, + ListBuilder? initialValues, + super.onSaved, + super.validator, + super.enabled, + AutovalidateMode? autovalidateMode, + InputDecoration decoration = const InputDecoration(), + super.restorationId, + }) : super( + initialValue: initialValues, + autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled, + builder: (FormFieldState> state) { + return UnmanagedRestorationScope( + bucket: state.bucket, + child: ReordarableListField( + title: title, + items: initialValues?.build().toList(), + enabled: enabled, + decoration: decoration, + ), + ); + }, + ); +} + +class ReordarableListField extends StatefulWidget { final String title; - final ListBuilder items; - final RecipeState state; - final Function(ListBuilder value) onSave; + final List? items; + final bool enabled; + final InputDecoration decoration; - const ReorderableListFormField({ + const ReordarableListField({ super.key, required this.title, - required this.items, - required this.state, - required this.onSave, + this.items, + this.enabled = true, + this.decoration = const InputDecoration(), }); @override - _ReorderableListFormFieldState createState() => - _ReorderableListFormFieldState(); -} - -class ItemData { - ItemData(this.text, this.key); - - String text; - - // Each item in reorderable list needs stable and unique key - final Key key; + _ReordarableListFieldState createState() => _ReordarableListFieldState(); } -class _ReorderableListFormFieldState extends State { - final List _items = []; - late bool enabled; +class _ReordarableListFieldState extends State { + late List _items; + final focusNode = FocusNode( + debugLabel: 'ReorderableListAddButton', + skipTraversal: true, + ); - _ReorderableListFormFieldState(); + @override + void initState() { + super.initState(); - int _indexOfKey(Key key) { - return _items.indexWhere((ItemData d) => d.key == key); + _items = List.generate( + widget.items?.length ?? 0, + (index) => TextEditingController(text: widget.items![index]), + ); } - bool _reorderCallback(Key item, Key newPosition) { - final int draggingIndex = _indexOfKey(item); - final int newPositionIndex = _indexOfKey(newPosition); + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } - final draggedItem = _items[draggingIndex]; + void _reorderCallback(int oldIndex, int newIndex) { setState(() { - _items.removeAt(draggingIndex); - _items.insert(newPositionIndex, draggedItem); + final index = (newIndex >= _items.length) ? _items.length - 1 : newIndex; + + final item = _items.removeAt(oldIndex); + _items.insert(index, item); }); - return true; } - @override - void initState() { - for (int i = 0; i < widget.items.length; ++i) { - _items.add(ItemData(widget.items[i], ValueKey(i))); - } - enabled = widget.state.status != RecipeStatus.updateInProgress; - super.initState(); + Widget _buildItem(BuildContext context, int index) { + return Item( + key: UniqueKey(), + controller: _items[index], + index: index, + focus: index == _items.length - 1, + onDismissed: () { + setState(() => _items.removeAt(index)); + }, + ); } - // - // Reordering works by having ReorderableList widget in hierarchy - // containing ReorderableItems widgets - // - @override Widget build(BuildContext context) { - return Column( - children: [ - ExpansionTile( - childrenPadding: EdgeInsets.zero, - tilePadding: EdgeInsets.zero, - title: Row( - children: [ - Text( - widget.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - SizedBox( - width: 1, - child: TextFormField( - initialValue: "", - enabled: false, - onSaved: (_) { - widget.onSave(ListBuilder(_items.map((e) => e.text))); - }, - ), - ), - ], - ), - children: [ - rl.ReorderableList( - onReorder: _reorderCallback, - child: CustomScrollView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - slivers: [ - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - if (index == _items.length) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).hintColor, - borderRadius: BorderRadius.circular(10), - ), - child: IconButton( - enableFeedback: enabled, - icon: const Icon(Icons.add), - onPressed: () { - setState(() { - if (enabled) { - _items.add( - ItemData( - "", - ValueKey(_items.length), - ), - ); - } - }); - }, - ), - ), - ); - } - return Item( - key: UniqueKey(), - data: _items[index], - isFirst: index == 0, - isLast: index == _items.length - 1, - deleteItem: () { - setState(() { - _items.removeAt(index); - }); - }, - state: widget.state, - onChange: (String value) { - // Mass import with newline separated list - if (value.contains("\n")) { - final newItems = List.of(value.split("\n")); - _items[index].text = newItems[0]; - - final newItemData = List.of( - newItems.getRange(1, newItems.length), - ).asMap().entries.map( - (e) => ItemData( - e.value, - ValueKey(_items.length + e.key), - ), - ); - setState(() { - _items.insertAll(index + 1, newItemData); - }); - } else { - _items[index].text = value; - } - }, - ); - }, - childCount: _items.length + 1, - ), - ), - ), - ], - ), - ), - ], + final theme = Theme.of(context); + + final title = Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + widget.title, + style: theme.textTheme.titleMedium!.copyWith( + color: theme.colorScheme.secondary, ), + ), + ); + + final items = SliverPadding( + padding: const EdgeInsets.only(top: 8.0), + sliver: SliverReorderableList( + onReorder: _reorderCallback, + itemCount: _items.length, + itemBuilder: _buildItem, + prototypeItem: const Item.prototype(), + ), + ); + + final addButton = OutlinedButton.icon( + focusNode: focusNode, + icon: const Icon(Icons.add_outlined), + label: Text( + translate("recipe_create.add_field"), + ), + onPressed: () { + if (widget.enabled) { + setState(() => _items.add(TextEditingController())); + } + }, + ); + + return MultiSliver( + children: [ + title, + items, + addButton, ], ); } @@ -189,136 +143,78 @@ class _ReorderableListFormFieldState extends State { class Item extends StatefulWidget { const Item({ super.key, - required this.data, - required this.isFirst, - required this.isLast, - required this.deleteItem, - required this.state, - required this.onChange, + required VoidCallback this.onDismissed, + required TextEditingController this.controller, + required this.index, + this.focus = false, }); - final ItemData data; - final bool isFirst; - final bool isLast; - final Function() deleteItem; - final Function(String value) onChange; - final RecipeState state; + const Item.prototype() + : onDismissed = null, + controller = null, + index = 0, + focus = false; + + final VoidCallback? onDismissed; + final TextEditingController? controller; + final int index; + final bool focus; @override - State createState() => _ItemState(); + State createState() => _ItemState(); } class _ItemState extends State { - _ItemState(); - - late bool enabled; + final focusNode = FocusNode( + debugLabel: 'ReorderableListItemDeleteButton', + skipTraversal: true, + ); @override - void initState() { - enabled = widget.state.status != RecipeStatus.updateInProgress; - super.initState(); + void dispose() { + focusNode.dispose(); + super.dispose(); } - Widget _buildChild(BuildContext context, rl.ReorderableItemState state) { - BoxDecoration decoration; - - switch (state) { - case rl.ReorderableItemState.dragProxy: - case rl.ReorderableItemState.dragProxyFinished: - // slightly transparent background white dragging (just like on iOS) - decoration = BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.8), - ); - break; - default: - final bool placeholder = state == rl.ReorderableItemState.placeholder; - decoration = BoxDecoration( - border: Border( - top: widget.isFirst && !placeholder - ? Divider.createBorderSide(context) // - : BorderSide.none, - bottom: widget.isLast && placeholder - ? BorderSide.none // - : Divider.createBorderSide(context), - ), - color: placeholder ? null : Theme.of(context).scaffoldBackgroundColor, - ); - } + @override + Widget build(BuildContext context) { + final textField = TextField( + controller: widget.controller, + maxLines: 10000, + minLines: 1, + autofocus: widget.focus, + textInputAction: TextInputAction.next, + ); - // For iOS dragging mode, there will be drag handle on the right that triggers - // reordering; For android mode it will be just an empty container - final Widget dragHandle = rl.ReorderableListener( - canStart: () => enabled, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 7), - color: const Color(0x08000000), - child: const Center( - child: Icon(Icons.reorder), - ), - ), + final dragHandle = ReorderableDragStartListener( + index: widget.index, + child: const Icon(Icons.reorder_outlined), ); - final Widget delete = ColoredBox( - color: const Color(0x08000000), - child: Center( - child: IconButton( - enableFeedback: enabled, - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () { - if (enabled) { - widget.deleteItem(); - } - }, - ), + final deleteButton = IconButton( + tooltip: translate("recipe_create.remove_field"), + icon: Icon( + Icons.delete, + color: Theme.of(context).colorScheme.error, ), + focusNode: focusNode, + onPressed: widget.onDismissed, ); - final Widget content = Container( - decoration: decoration, - child: SafeArea( - top: false, - bottom: false, - child: Opacity( - // hide content for placeholder - opacity: state == rl.ReorderableItemState.placeholder ? 0.0 : 1.0, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 5.0, - horizontal: 10.0, - ), - child: TextFormField( - enabled: enabled, - maxLines: 10000, - minLines: 1, - initialValue: widget.data.text, - onChanged: widget.onChange, - autofocus: widget.data.text.isEmpty, - ), - ), - ), - // Triggers the reordering - dragHandle, - delete, - ], + return Material( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: textField, ), ), - ), + dragHandle, + deleteButton, + ], ), ); - - return content; - } - - @override - Widget build(BuildContext context) { - return rl.ReorderableItem( - key: widget.data.key, // - childBuilder: _buildChild, - ); } } diff --git a/lib/src/widget/my_reorderable_list.dart b/lib/src/widget/my_reorderable_list.dart deleted file mode 100644 index ee17840f..00000000 --- a/lib/src/widget/my_reorderable_list.dart +++ /dev/null @@ -1,640 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -// Examples can assume: -// class MyDataObject { } - -/// The callback used by [ReorderableListView] to move an item to a new -/// position in a list. -/// -/// Implementations should remove the corresponding list item at [oldIndex] -/// and reinsert it at [newIndex]. -/// -/// If [oldIndex] is before [newIndex], removing the item at [oldIndex] from the -/// list will reduce the list's length by one. Implementations used by -/// [ReorderableListView] will need to account for this when inserting before -/// [newIndex]. -/// -/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE} -/// -/// {@tool snippet} -/// -/// ```dart -/// final List backingList = [/* ... */]; -/// -/// void handleReorder(int oldIndex, int newIndex) { -/// if (oldIndex < newIndex) { -/// // removing the item at oldIndex will shorten the list by 1. -/// newIndex -= 1; -/// } -/// final MyDataObject element = backingList.removeAt(oldIndex); -/// backingList.insert(newIndex, element); -/// } -/// ``` -/// {@end-tool} -typedef ReorderCallback = void Function(int oldIndex, int newIndex); - -/// A list whose items the user can interactively reorder by dragging. -/// -/// This class is appropriate for views with a small number of -/// children because constructing the [List] requires doing work for every -/// child that could possibly be displayed in the list view instead of just -/// those children that are actually visible. -/// -/// All [children] must have a key. -/// -/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE} -class ReorderableListView extends StatefulWidget { - /// Creates a reorderable list. - ReorderableListView({ - super.key, - this.header, - required this.children, - required this.onReorder, - this.scrollController, - this.scrollDirection = Axis.vertical, - this.padding, - this.reverse = false, - }) : assert( - children.every((Widget w) => w.key != null), - 'All children of this widget must have a key.', - ); - - /// A non-reorderable header widget to show before the list. - /// - /// If null, no header will appear before the list. - final Widget? header; - - /// The widgets to display. - final List children; - - /// The [Axis] along which the list scrolls. - /// - /// List [children] can only drag along this [Axis]. - final Axis scrollDirection; - - /// Creates a [ScrollPosition] to manage and determine which portion - /// of the content is visible in the scroll view. - /// - /// This can be used in many ways, such as setting an initial scroll offset, - /// (via [ScrollController.initialScrollOffset]), reading the current scroll position - /// (via [ScrollController.offset]), or changing it (via [ScrollController.jumpTo] or - /// [ScrollController.animateTo]). - final ScrollController? scrollController; - - /// The amount of space by which to inset the [children]. - final EdgeInsets? padding; - - /// Whether the scroll view scrolls in the reading direction. - /// - /// For example, if the reading direction is left-to-right and - /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from - /// left to right when [reverse] is false and from right to left when - /// [reverse] is true. - /// - /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view - /// scrolls from top to bottom when [reverse] is false and from bottom to top - /// when [reverse] is true. - /// - /// Defaults to false. - final bool reverse; - - /// Called when a list child is dropped into a new position to shuffle the - /// underlying list. - /// - /// This [ReorderableListView] calls [onReorder] after a list child is dropped - /// into a new position. - final ReorderCallback onReorder; - - @override - _ReorderableListViewState createState() => _ReorderableListViewState(); -} - -// This top-level state manages an Overlay that contains the list and -// also any Draggables it creates. -// -// _ReorderableListContent manages the list itself and reorder operations. -// -// The Overlay doesn't properly keep state by building new overlay entries, -// and so we cache a single OverlayEntry for use as the list layer. -// That overlay entry then builds a _ReorderableListContent which may -// insert Draggables into the Overlay above itself. -class _ReorderableListViewState extends State { - // We use an inner overlay so that the dragging list item doesn't draw outside of the list itself. - final GlobalKey _overlayKey = - GlobalKey(debugLabel: '$ReorderableListView overlay key'); - - // This entry contains the scrolling list itself. - late OverlayEntry _listOverlayEntry; - - @override - void initState() { - super.initState(); - _listOverlayEntry = OverlayEntry( - opaque: true, - builder: (BuildContext context) { - return _ReorderableListContent( - header: widget.header, - scrollController: widget.scrollController, - scrollDirection: widget.scrollDirection, - onReorder: widget.onReorder, - padding: widget.padding, - reverse: widget.reverse, - children: widget.children, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Overlay( - key: _overlayKey, - initialEntries: [ - _listOverlayEntry, - ], - ); - } -} - -// This widget is responsible for the inside of the Overlay in the -// ReorderableListView. -class _ReorderableListContent extends StatefulWidget { - const _ReorderableListContent({ - required this.header, - required this.children, - required this.scrollController, - required this.scrollDirection, - required this.padding, - required this.onReorder, - required this.reverse, - }); - - final Widget? header; - final List children; - final ScrollController? scrollController; - final Axis scrollDirection; - final EdgeInsets? padding; - final ReorderCallback onReorder; - final bool reverse; - - @override - _ReorderableListContentState createState() => _ReorderableListContentState(); -} - -class _ReorderableListContentState extends State<_ReorderableListContent> - with TickerProviderStateMixin<_ReorderableListContent> { - // The extent along the [widget.scrollDirection] axis to allow a child to - // drop into when the user reorders list children. - // - // This value is used when the extents haven't yet been calculated from - // the currently dragging widget, such as when it first builds. - static const double _defaultDropAreaExtent = 100.0; - - // The additional margin to place around a computed drop area. - static const double _dropAreaMargin = 8.0; - - // How long an animation to reorder an element in the list takes. - static const Duration _reorderAnimationDuration = Duration(milliseconds: 200); - - // How long an animation to scroll to an off-screen element in the - // list takes. - static const Duration _scrollAnimationDuration = Duration(milliseconds: 200); - - // Controls scrolls and measures scroll progress. - late ScrollController _scrollController; - - // This controls the entrance of the dragging widget into a new place. - late AnimationController _entranceController; - - // This controls the 'ghost' of the dragging widget, which is left behind - // where the widget used to be. - late AnimationController _ghostController; - - // The member of widget.children currently being dragged. - // - // Null if no drag is underway. - Key? _dragging; - - // The last computed size of the feedback widget being dragged. - Size? _draggingFeedbackSize; - - // The location that the dragging widget occupied before it started to drag. - int _dragStartIndex = 0; - - // The index that the dragging widget most recently left. - // This is used to show an animation of the widget's position. - int _ghostIndex = 0; - - // The index that the dragging widget currently occupies. - int _currentIndex = 0; - - // The widget to move the dragging widget too after the current index. - int _nextIndex = 0; - - // Whether or not we are currently scrolling this view to show a widget. - bool _scrolling = false; - - double get _dropAreaExtent { - if (_draggingFeedbackSize == null) { - return _defaultDropAreaExtent; - } - double dropAreaWithoutMargin; - switch (widget.scrollDirection) { - case Axis.horizontal: - dropAreaWithoutMargin = _draggingFeedbackSize!.width; - break; - case Axis.vertical: - default: - dropAreaWithoutMargin = _draggingFeedbackSize!.height; - break; - } - return dropAreaWithoutMargin + _dropAreaMargin; - } - - @override - void initState() { - super.initState(); - _entranceController = - AnimationController(vsync: this, duration: _reorderAnimationDuration); - _ghostController = - AnimationController(vsync: this, duration: _reorderAnimationDuration); - _entranceController.addStatusListener(_onEntranceStatusChanged); - } - - @override - void didChangeDependencies() { - _scrollController = - widget.scrollController ?? PrimaryScrollController.of(context); - super.didChangeDependencies(); - } - - @override - void dispose() { - _entranceController.dispose(); - _ghostController.dispose(); - super.dispose(); - } - - // Animates the droppable space from _currentIndex to _nextIndex. - void _requestAnimationToNextIndex() { - if (_entranceController.isCompleted) { - _ghostIndex = _currentIndex; - if (_nextIndex == _currentIndex) { - return; - } - _currentIndex = _nextIndex; - _ghostController.reverse(from: 1.0); - _entranceController.forward(from: 0.0); - } - } - - // Requests animation to the latest next index if it changes during an animation. - void _onEntranceStatusChanged(AnimationStatus status) { - if (status == AnimationStatus.completed) { - setState(() { - _requestAnimationToNextIndex(); - }); - } - } - - // Scrolls to a target context if that context is not on the screen. - void _scrollTo(BuildContext context) { - if (_scrolling) return; - final contextObject = context.findRenderObject(); - final viewport = RenderAbstractViewport.of(contextObject); - assert(contextObject != null); - // If and only if the current scroll offset falls in-between the offsets - // necessary to reveal the selected context at the top or bottom of the - // screen, then it is already on-screen. - final double margin = _dropAreaExtent; - final double scrollOffset = _scrollController.offset; - final double topOffset = max( - _scrollController.position.minScrollExtent, - viewport.getOffsetToReveal(contextObject!, 0.0).offset - margin, - ); - final double bottomOffset = min( - _scrollController.position.maxScrollExtent, - viewport.getOffsetToReveal(contextObject, 1.0).offset + margin, - ); - final bool onScreen = - scrollOffset <= topOffset && scrollOffset >= bottomOffset; - - // If the context is off screen, then we request a scroll to make it visible. - if (!onScreen) { - _scrolling = true; - _scrollController.position - .animateTo( - scrollOffset < bottomOffset ? bottomOffset : topOffset, - duration: _scrollAnimationDuration, - curve: Curves.easeInOut, - ) - .then((_) { - setState(() { - _scrolling = false; - }); - }); - } - } - - // Wraps children in Row or Column, so that the children flow in - // the widget's scrollDirection. - Widget _buildContainerForScrollDirection({List? children}) { - switch (widget.scrollDirection) { - case Axis.horizontal: - return Row(children: children!); - case Axis.vertical: - default: - return Column(children: children!); - } - } - - // Wraps one of the widget's children in a DragTarget and Draggable. - // Handles up the logic for dragging and reordering items in the list. - Widget _wrap(Widget toWrap, int index, BoxConstraints constraints) { - assert(toWrap.key != null); - final _ReorderableListViewChildGlobalKey keyIndexGlobalKey = - _ReorderableListViewChildGlobalKey(toWrap.key!, this); - // We pass the toWrapWithGlobalKey into the Draggable so that when a list - // item gets dragged, the accessibility framework can preserve the selected - // state of the dragging item. - - // Starts dragging toWrap. - void onDragStarted() { - setState(() { - _dragging = toWrap.key; - _dragStartIndex = index; - _ghostIndex = index; - _currentIndex = index; - _entranceController.value = 1.0; - _draggingFeedbackSize = keyIndexGlobalKey.currentContext?.size; - }); - } - - // Places the value from startIndex one space before the element at endIndex. - void reorder(int startIndex, int endIndex) { - setState(() { - if (startIndex != endIndex) widget.onReorder(startIndex, endIndex); - // Animates leftover space in the drop area closed. - _ghostController.reverse(from: 0.1); - _entranceController.reverse(from: 0.1); - _dragging = null; - }); - } - - // Drops toWrap into the last position it was hovering over. - void onDragEnded() { - reorder(_dragStartIndex, _currentIndex); - } - - Widget wrapWithSemantics() { - // First, determine which semantics actions apply. - final Map semanticsActions = - {}; - - // Create the appropriate semantics actions. - void moveToStart() => reorder(index, 0); - void moveToEnd() => reorder(index, widget.children.length); - void moveBefore() => reorder(index, index - 1); - // To move after, we go to index+2 because we are moving it to the space - // before index+2, which is after the space at index+1. - void moveAfter() => reorder(index, index + 2); - - final MaterialLocalizations localizations = - MaterialLocalizations.of(context); - - // If the item can move to before its current position in the list. - if (index > 0) { - semanticsActions[CustomSemanticsAction( - label: localizations.reorderItemToStart, - )] = moveToStart; - String reorderItemBefore = localizations.reorderItemUp; - if (widget.scrollDirection == Axis.horizontal) { - reorderItemBefore = Directionality.of(context) == TextDirection.ltr - ? localizations.reorderItemLeft - : localizations.reorderItemRight; - } - semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = - moveBefore; - } - - // If the item can move to after its current position in the list. - if (index < widget.children.length - 1) { - String reorderItemAfter = localizations.reorderItemDown; - if (widget.scrollDirection == Axis.horizontal) { - reorderItemAfter = Directionality.of(context) == TextDirection.ltr - ? localizations.reorderItemRight - : localizations.reorderItemLeft; - } - semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = - moveAfter; - semanticsActions[ - CustomSemanticsAction(label: localizations.reorderItemToEnd)] = - moveToEnd; - } - - // We pass toWrap with a GlobalKey into the Draggable so that when a list - // item gets dragged, the accessibility framework can preserve the selected - // state of the dragging item. - // - // We also apply the relevant custom accessibility actions for moving the item - // up, down, to the start, and to the end of the list. - return KeyedSubtree( - key: keyIndexGlobalKey, - child: MergeSemantics( - child: Semantics( - customSemanticsActions: semanticsActions, - child: toWrap, - ), - ), - ); - } - - Widget buildDragTarget( - BuildContext context, - List acceptedCandidates, - List rejectedCandidates, - ) { - final Widget toWrapWithSemantics = wrapWithSemantics(); - - // We build the draggable inside of a layout builder so that we can - // constrain the size of the feedback dragging widget. - Widget child = LongPressDraggable( - maxSimultaneousDrags: 1, - axis: widget.scrollDirection, - data: toWrap.key, - ignoringFeedbackSemantics: false, - feedback: Container( - alignment: Alignment.topLeft, - // These constraints will limit the cross axis of the drawn widget. - constraints: constraints, - child: Material( - elevation: 6.0, - child: toWrapWithSemantics, - ), - ), - childWhenDragging: const SizedBox(), - onDragStarted: onDragStarted, - // When the drag ends inside a DragTarget widget, the drag - // succeeds, and we reorder the widget into position appropriately. - onDragCompleted: onDragEnded, - // When the drag does not end inside a DragTarget widget, the - // drag fails, but we still reorder the widget to the last position it - // had been dragged to. - onDraggableCanceled: (Velocity velocity, Offset offset) { - onDragEnded(); - }, - child: _dragging == toWrap.key ? const SizedBox() : toWrapWithSemantics, - ); - - // The target for dropping at the end of the list doesn't need to be - // draggable. - if (index >= widget.children.length) { - child = toWrap; - } - - // Determine the size of the drop area to show under the dragging widget. - Widget spacing; - switch (widget.scrollDirection) { - case Axis.horizontal: - spacing = SizedBox(width: _dropAreaExtent); - break; - case Axis.vertical: - default: - spacing = SizedBox(height: _dropAreaExtent); - break; - } - - // We open up a space under where the dragging widget currently is to - // show it can be dropped. - if (_currentIndex == index) { - return _buildContainerForScrollDirection( - children: [ - SizeTransition( - sizeFactor: _entranceController, - axis: widget.scrollDirection, - child: spacing, - ), - child, - ], - ); - } - // We close up the space under where the dragging widget previously was - // with the ghostController animation. - if (_ghostIndex == index) { - return _buildContainerForScrollDirection( - children: [ - SizeTransition( - sizeFactor: _ghostController, - axis: widget.scrollDirection, - child: spacing, - ), - child, - ], - ); - } - return child; - } - - // We wrap the drag target in a Builder so that we can scroll to its specific context. - return Builder( - builder: (BuildContext context) { - return DragTarget( - builder: buildDragTarget, - onWillAccept: (Key? toAccept) { - setState(() { - _nextIndex = index; - _requestAnimationToNextIndex(); - }); - _scrollTo(context); - // If the target is not the original starting point, then we will accept the drop. - return _dragging == toAccept && toAccept != toWrap.key; - }, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMaterialLocalizations(context)); - // We use the layout builder to constrain the cross-axis size of dragging child widgets. - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - const Key endWidgetKey = Key('DraggableList - End Widget'); - Widget finalDropArea; - switch (widget.scrollDirection) { - case Axis.horizontal: - finalDropArea = SizedBox( - key: endWidgetKey, - width: _defaultDropAreaExtent, - height: constraints.maxHeight, - ); - break; - case Axis.vertical: - default: - finalDropArea = SizedBox( - key: endWidgetKey, - height: _defaultDropAreaExtent, - width: constraints.maxWidth, - ); - break; - } - - // If the reorderable list only has one child element, reordering - // should not be allowed. - final bool hasMoreThanOneChildElement = widget.children.length > 1; - - return ListView( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - scrollDirection: widget.scrollDirection, - padding: widget.padding, - controller: _scrollController, - reverse: widget.reverse, - children: [ - if (widget.reverse && hasMoreThanOneChildElement) - _wrap(finalDropArea, widget.children.length, constraints), - if (widget.header != null) widget.header!, - for (int i = 0; i < widget.children.length; i += 1) - _wrap(widget.children[i], i, constraints), - if (!widget.reverse && hasMoreThanOneChildElement) - _wrap(finalDropArea, widget.children.length, constraints), - ], - ); - }, - ); - } -} - -// A global key that takes its identity from the object and uses a value of a -// particular type to identify itself. -// -// The difference with GlobalObjectKey is that it uses [==] instead of [identical] -// of the objects used to generate widgets. -@optionalTypeArgs -class _ReorderableListViewChildGlobalKey extends GlobalObjectKey { - const _ReorderableListViewChildGlobalKey(this.subKey, this.state) - : super(subKey); - - final Key subKey; - - final _ReorderableListContentState state; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is _ReorderableListViewChildGlobalKey && - other.subKey == subKey && - other.state == state; - } - - @override - int get hashCode => Object.hash(subKey, state); -} diff --git a/lib/src/widget/recipe/recipe_screen.dart b/lib/src/widget/recipe/recipe_screen.dart new file mode 100644 index 00000000..2788e882 --- /dev/null +++ b/lib/src/widget/recipe/recipe_screen.dart @@ -0,0 +1,15 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/duration_utils.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +part 'widget/ingredient_list.dart'; +part 'widget/instruction_list.dart'; +part 'widget/nutrition_list.dart'; +part 'widget/rounded_box_item.dart'; +part 'widget/duration_list.dart'; +part 'widget/recipe_yield.dart'; +part 'widget/tool_list.dart'; diff --git a/lib/src/widget/recipe/widget/duration_list.dart b/lib/src/widget/recipe/widget/duration_list.dart new file mode 100644 index 00000000..d4dcfb7c --- /dev/null +++ b/lib/src/widget/recipe/widget/duration_list.dart @@ -0,0 +1,44 @@ +part of '../recipe_screen.dart'; + +class DurationList extends StatelessWidget { + final Recipe recipe; + + const DurationList({ + required this.recipe, + }); + + @override + Widget build(BuildContext context) { + const height = 35.0; + const padding = EdgeInsets.symmetric(horizontal: 13); + + return Wrap( + alignment: WrapAlignment.center, + runSpacing: 10, + spacing: 10, + children: [ + if (recipe.prepTime != null) + RoundedBoxItem( + name: translate('recipe.prep'), + value: recipe.prepTime!.formatMinutes(), + height: height, + padding: padding, + ), + if (recipe.cookTime != null) + RoundedBoxItem( + name: translate('recipe.cook'), + value: recipe.cookTime!.formatMinutes(), + height: height, + padding: padding, + ), + if (recipe.totalTime != null) + RoundedBoxItem( + name: translate('recipe.total'), + value: recipe.totalTime!.formatMinutes(), + height: height, + padding: padding, + ), + ], + ); + } +} diff --git a/lib/src/widget/recipe/widget/ingredient_list.dart b/lib/src/widget/recipe/widget/ingredient_list.dart new file mode 100644 index 00000000..b0a1dc42 --- /dev/null +++ b/lib/src/widget/recipe/widget/ingredient_list.dart @@ -0,0 +1,86 @@ +part of '../recipe_screen.dart'; + +class IngredientList extends StatelessWidget { + final Recipe recipe; + + const IngredientList( + this.recipe, + ); + + @override + Widget build(BuildContext context) { + return ExpansionTile( + childrenPadding: const EdgeInsets.symmetric(horizontal: 8.0), + title: Text(translate('recipe.fields.ingredients')), + initiallyExpanded: true, + children: [ + for (final ingredient in recipe.recipeIngredient) + _IngredientListItem(ingredient) + ], + ); + } +} + +class _IngredientListItem extends StatefulWidget { + final String ingredient; + + const _IngredientListItem(this.ingredient); + + @override + State<_IngredientListItem> createState() => __IngredientListItemState(); +} + +class __IngredientListItemState extends State<_IngredientListItem> { + bool selected = false; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge!; + + if (widget.ingredient.startsWith('##')) { + return Text( + widget.ingredient.replaceFirst(RegExp(r'##\s*'), '').trim(), + style: const TextStyle( + fontFeatures: [FontFeature.enable('smcp')], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: GestureDetector( + onTap: () => setState(() { + selected = !selected; + }), + child: Row( + children: [ + Container( + width: style.fontSize, + height: style.fontSize, + decoration: ShapeDecoration( + shape: const CircleBorder( + side: BorderSide(color: Colors.grey), + ), + color: selected + ? Colors.green + : Theme.of(context).colorScheme.onBackground, + ), + child: selected + ? Icon( + Icons.check_outlined, + size: style.fontSize! * 0.75, + ) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.ingredient, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widget/recipe/widget/instruction_list.dart b/lib/src/widget/recipe/widget/instruction_list.dart new file mode 100644 index 00000000..afc8c48e --- /dev/null +++ b/lib/src/widget/recipe/widget/instruction_list.dart @@ -0,0 +1,74 @@ +part of '../recipe_screen.dart'; + +class InstructionList extends StatelessWidget { + final Recipe recipe; + + const InstructionList( + this.recipe, + ); + + @override + Widget build(BuildContext context) { + final instructions = recipe.recipeInstructions; + return ExpansionTile( + title: Text(translate('recipe.fields.instructions')), + initiallyExpanded: true, + children: [ + for (int i = 0; i < instructions.length; i++) + _InstructionListTitem(instructions[i], i) + ], + ); + } +} + +class _InstructionListTitem extends StatefulWidget { + final String instruction; + final int index; + + const _InstructionListTitem(this.instruction, this.index); + + @override + State<_InstructionListTitem> createState() => _InstructionListTitemState(); +} + +class _InstructionListTitemState extends State<_InstructionListTitem> { + bool selected = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 5), + child: GestureDetector( + onTap: () => setState(() { + selected = !selected; + }), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + margin: const EdgeInsets.only(right: 15, top: 2.5), + decoration: ShapeDecoration( + shape: const CircleBorder( + side: BorderSide(color: Colors.grey), + ), + color: selected + ? Colors.green + : Theme.of(context).colorScheme.background, + ), + child: selected + ? const Icon(Icons.check_outlined) + : Center(child: Text((widget.index + 1).toString())), + ), + Expanded( + child: Text( + widget.instruction, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widget/recipe/widget/nutrition_list.dart b/lib/src/widget/recipe/widget/nutrition_list.dart new file mode 100644 index 00000000..cdc7c493 --- /dev/null +++ b/lib/src/widget/recipe/widget/nutrition_list.dart @@ -0,0 +1,36 @@ +part of '../recipe_screen.dart'; + +class NutritionList extends StatelessWidget { + final Map nutrition; + + const NutritionList( + this.nutrition, + ); + + @override + Widget build(BuildContext context) { + return ExpansionTile( + title: Text(translate('recipe.fields.nutrition.title')), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final entry in nutrition.entries) + RoundedBoxItem( + name: translate( + 'recipe.fields.nutrition.items.${entry.key}', + ), + value: entry.value, + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/widget/recipe/widget/recipe_yield.dart b/lib/src/widget/recipe/widget/recipe_yield.dart new file mode 100644 index 00000000..2bb24db0 --- /dev/null +++ b/lib/src/widget/recipe/widget/recipe_yield.dart @@ -0,0 +1,28 @@ +part of '../recipe_screen.dart'; + +class RecipeYield extends StatelessWidget { + final Recipe recipe; + const RecipeYield({required this.recipe}); + + @override + Widget build(BuildContext context) { + final style = + Theme.of(context).textTheme.bodyMedium!.apply(fontWeightDelta: 3); + + return Row( + children: [ + Text( + "${translate('recipe.fields.servings.else')}: ${recipe.recipeYield}", + style: style, + ), + if (recipe.url.isNotEmpty) ...[ + const Spacer(), + ElevatedButton( + onPressed: () async => launchUrlString(recipe.url), + child: Text(translate('recipe.fields.source_button')), + ), + ], + ], + ); + } +} diff --git a/lib/src/widget/recipe/widget/rounded_box_item.dart b/lib/src/widget/recipe/widget/rounded_box_item.dart new file mode 100644 index 00000000..26495f13 --- /dev/null +++ b/lib/src/widget/recipe/widget/rounded_box_item.dart @@ -0,0 +1,56 @@ +part of '../recipe_screen.dart'; + +class RoundedBoxItem extends StatelessWidget { + final String name; + final String value; + final double? height; + final EdgeInsets? padding; + + const RoundedBoxItem({ + required this.name, + required this.value, + this.height, + this.padding, + }); + + @override + Widget build(BuildContext context) { + const radius = Radius.circular(10); + final theme = Theme.of(context); + + return IntrinsicWidth( + child: Column( + children: [ + Container( + height: height, + padding: padding, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: radius), + border: Border.all(color: theme.hintColor), + color: theme.colorScheme.primary.withOpacity(0.2), + ), + child: Center( + child: Text( + name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + Container( + height: height, + padding: padding, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(bottom: radius), + border: Border.all( + color: theme.hintColor.withOpacity(0.6), + ), + ), + child: Center( + child: Text(value), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widget/recipe/widget/tool_list.dart b/lib/src/widget/recipe/widget/tool_list.dart new file mode 100644 index 00000000..d889e055 --- /dev/null +++ b/lib/src/widget/recipe/widget/tool_list.dart @@ -0,0 +1,28 @@ +part of '../recipe_screen.dart'; + +class ToolList extends StatelessWidget { + const ToolList({ + required this.recipe, + }); + + final Recipe recipe; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + title: Text(translate('recipe.fields.tools')), + children: [ + for (final tool in recipe.tool) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + "- ${tool.trim()}", + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/widget/recipe_list_item.dart b/lib/src/widget/recipe_list_item.dart new file mode 100644 index 00000000..a7bb3ef0 --- /dev/null +++ b/lib/src/widget/recipe_list_item.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:nc_cookbook_api/nc_cookbook_api.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; + +class RecipeListItem extends StatelessWidget { + final RecipeStub recipe; + + const RecipeListItem({ + super.key, + required this.recipe, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: RecipeImage( + id: recipe.recipeId, + size: const Size.square(80), + ), + ), + title: Text(recipe.name), + subtitle: Row( + children: [ + _RecipeListDate( + Icons.edit_calendar_outlined, + recipe.dateCreated, + ), + if (recipe.dateModified != null && + recipe.dateModified != recipe.dateCreated) ...[ + _RecipeListDate( + Icons.edit_outlined, + recipe.dateModified!, + ), + ], + ], + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RecipeScreen(recipeId: recipe.recipeId), + ), + ); + }, + ); + } +} + +class _RecipeListDate extends StatelessWidget { + const _RecipeListDate( + this.icon, + this.data, + ); + + final DateTime data; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodySmall!; + final colorScheme = Theme.of(context).colorScheme; + final content = DateFormat(DateFormat.YEAR_NUM_MONTH_DAY).format(data); + + return Card( + color: colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: textStyle.fontSize, + color: colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 4), + Text( + content, + style: textStyle.copyWith( + color: colorScheme.onSecondaryContainer, + ), + overflow: TextOverflow.fade, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widget/timer_list_item.dart b/lib/src/widget/timer_list_item.dart new file mode 100644 index 00000000..6369e48c --- /dev/null +++ b/lib/src/widget/timer_list_item.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/timer.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/animated_time_progress_bar.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/recipe_image.dart'; + +class TimerListItem extends StatelessWidget { + const TimerListItem({ + this.onDismissed, + this.dense = false, + this.enabled = true, + required this.animation, + required this.item, + }); + + final Animation animation; + final VoidCallback? onDismissed; + final Timer item; + final bool dense; + final bool enabled; + + @override + Widget build(BuildContext context) { + final image = ClipRRect( + borderRadius: BorderRadius.circular(5), + child: RecipeImage( + id: item.recipeId, + size: const Size.square(80), + ), + ); + final progressBar = AnimatedTimeProgressBar( + timer: item, + ); + + void onPressed() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RecipeScreen(recipeId: item.recipeId), + ), + ); + } + + return SizeTransition( + sizeFactor: animation, + child: ListTile( + leading: dense ? null : image, + title: dense ? progressBar : Text(item.title), + subtitle: dense ? null : progressBar, + trailing: IconButton( + icon: const Icon(Icons.cancel_outlined), + tooltip: translate("timer.button.cancel"), + onPressed: enabled ? onDismissed : null, + ), + onTap: (enabled && !dense) ? onPressed : null, + ), + ); + } +} diff --git a/lib/src/widget/user_image.dart b/lib/src/widget/user_image.dart index a9f9942a..78f7c32e 100644 --- a/lib/src/widget/user_image.dart +++ b/lib/src/widget/user_image.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:nextcloud_cookbook_flutter/src/services/services.dart'; +import 'package:nextcloud_cookbook_flutter/src/util/custom_cache_manager.dart'; class UserImage extends StatelessWidget { const UserImage({ @@ -14,6 +15,7 @@ class UserImage extends StatelessWidget { return ClipOval( child: CachedNetworkImage( + cacheManager: CustomCacheManager().instance, cacheKey: "avatar", fit: BoxFit.fill, httpHeaders: { diff --git a/pubspec.yaml b/pubspec.yaml index 503f2469..ac3e3bc3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,9 +76,6 @@ dependencies: flutter_markdown: ^0.6.9 - # Reorderable List for Edit and Create Recipe - flutter_reorderable_list: ^1.3.0 - # Timer for cooking time flutter_local_notifications: ^13.0.0 flutter_native_timezone: 2.0.0 @@ -97,6 +94,10 @@ dependencies: ref: 5465b2fd64fb71cb7cd588989d1709fd48884457 built_collection: ^5.1.1 + intl: ^0.18.0 + + sliver_tools: ^0.2.8 + flutter_native_splash: ^2.2.19 dev_dependencies: flutter_launcher_icons: ^0.12.0 @@ -109,13 +110,6 @@ dev_dependencies: lint: ^2.0.0 -flutter_icons: - android: "launcher_icon" - ios: true - image_path: "assets/icon.png" - adaptive_icon_background: "#0082c9" - adaptive_icon_foreground: "assets/adaptive_foreground.png" - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec