Compare commits

..

19 commits

Author SHA1 Message Date
Vadim
a3f8b49549 returned release notes to release workflow 2024-10-07 20:17:10 +02:00
Vadim
1b3d6b2b90 Upgraded to m3_lightmeter_iap: 0.11.2 2024-10-07 19:44:15 +02:00
Vadim
98262fc4ee Upgraded to m3_lightmeter_resources: 1.4.0 2024-10-06 18:29:26 +02:00
Vadim
63ff6cc867
ML-189 Auto-downsize exposure pairs items for big display sizes (#190)
* added autosize text

* added more room for exposure pairs list

* downsize text only for long values
2024-09-23 16:08:30 +02:00
github-actions[bot]
66a38f2969 Release v0.20.0 2024-07-28 18:05:42 +00:00
Vadim
9100ae3066
Build Android using macos-latest runner (#186)
* build apk with latest macos runner
2024-07-28 18:52:44 +02:00
Vadim
006158c731 Added release notes 2024-07-28 14:42:09 +02:00
Vadim
9d5860a53f
ML-184 Make "Restore purchases" option more accessible (#185)
* moved "Restore purchases" to the Pro section

* updated goldens

* downgraded dependencies

* upgraded again

* added resources to dependency_overrides
2024-07-28 14:37:37 +02:00
Vadim
e53eca1831 Upgraded to m3_lightmeter_iap 0.11.1 2024-07-28 13:50:06 +02:00
Vadim
0e09c28150 Upgraded to m3_lightmeter_resources: 1.3.0 2024-07-28 13:26:09 +02:00
Vadim
a0f55c4a95 Upgraded targetSdkVersion to 34 2024-07-23 23:21:00 +02:00
Vadim
f0d707b071
Show Lightmeter Pro price before purchase (#183)
* Upgraded `targetSdkVersion` to 34

* added price to `IAPProduct`

* implemented `ProFeaturesScreen` (wip)

* finalized `ProFeaturesScreen` layout

* replaced `ProFeaturesDialog` with `ProFeaturesScreen`

* added translations

* fixed feature checkbox width calculation

* fixed tests

* separated android & ios features

* NPE

* changed "get pro" tile colors

* unified Lightmeter Pro related naming

* typo

* updated golden tests

* use iap 0.11.0

* revert unrelated changes

This reverts commit bae5ead8f0.

* lint

* adjusted eng translation

* updated goldens
2024-07-23 23:19:41 +02:00
github-actions[bot]
1e2cd8b5d2 Release v0.19.0 2024-05-23 12:07:37 +00:00
Vadim
90bfe7c7b8 Fixed integration tests 2024-05-23 11:34:11 +02:00
Vadim
7f8ea54c6e Fixed duplicate release notes dialog 2024-05-23 09:08:16 +02:00
Vadim
36d7e081f1 Reordered screenshots in README 2024-05-22 22:51:13 +02:00
Vadim
d4deae57ef Fixed release notes dialog logic 2024-05-22 22:50:47 +02:00
Vadim
881778b313
Show release notes after update (#178)
* replace generated release notes with pre-built assets

* implemented release notes dialog

* store release notes for version

* show release notes dialog after update

* added release notes dialog to settings

* allow blank values in settings

* updated release notes
2024-05-22 22:46:46 +02:00
Vadim
f62f658be8
Automated release screenshots generation (#177)
* added system overlays for iPhone 8 Plus & iPhone 13 Pro

* add device frame (wip)

* scale device frame (wip)

* add text to screenshots (wip)

* added screenshots config json

* reorganized screenshot models

* cleanup

* added fonts for dark screenshots

* typo

* store raw screenshots

* added standalone script to update screenshots

* wip

* refined screenshots naming

* skip metering layout dialog screenshot

* parse ipad name

* added assets for Pixel 6

* typo

* added text for incident light metering

* reorganized store script

* typo

* wip

* synced outlined icons

* added timer screen to screenshot generator

* constrained timer screen timeline for tablets

* added timer screenshot title

* typo

* revised scripts

* track release screenshots

* Update README.md

* iphone 6.5" -> iphone 6.7"

* Update google_play_resources.md

* softened screenshot font colors

* cleanup
2024-05-21 19:13:33 +02:00
105 changed files with 1311 additions and 370 deletions

View file

@ -68,7 +68,7 @@ env:
jobs:
build-android:
name: Build ${{ inputs.binary-type == 'apk' && '.apk' || '.aab' }}
runs-on: macos-11
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3

View file

@ -18,12 +18,19 @@ on:
description: "Version"
required: true
type: string
release-notes:
description: "Release notes"
required: true
type: string
run-integration-tests:
description: "Run integration tests"
required: true
type: boolean
default: true
env:
RELEASE_NOTES_FILE: release_notes_en_${{ inputs.version }}
jobs:
run-integration-tests:
name: Run integration tests
@ -56,9 +63,24 @@ jobs:
stage-backend: false
version: ${{ inputs.version }}
generate-release-notes:
name: Generate release notes
needs: [build-android, build-ios]
runs-on: ubuntu-latest
steps:
- name: Generate release notes
run: |
echo ${{ inputs.release-notes }} > ${{ env.RELEASE_NOTES_FILE }}.md
perl -i -pe 's/\s{1}(-{1})/\n$1/g' ${{ env.RELEASE_NOTES_FILE }}.md
- name: Upload merged_native_libs.zip to artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ env.RELEASE_NOTES_FILE }}
path: ${{ env.RELEASE_NOTES_FILE }}.md
create-github-release:
name: Create Github release
needs: [build-android, build-ios]
needs: [generate-release-notes]
runs-on: ubuntu-latest
permissions:
contents: write
@ -70,6 +92,14 @@ jobs:
- name: Increment build number & replace version number
run: bash ./.github/scripts/increment_build_number.sh ${{ github.event.inputs.version }}
- name: Download release notes
uses: actions/download-artifact@v3
with:
name: ${{ env.RELEASE_NOTES_FILE }}
- name: Move release notes to a folder
run: mv assets/release_notes/${{ env.RELEASE_NOTES_FILE }}.md ${{ env.RELEASE_NOTES_FILE }}
- name: Commit changes
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
@ -97,11 +127,11 @@ jobs:
artifacts: "m3_lightmeter.apk"
skipIfReleaseExists: true
tag: "v${{ github.event.inputs.version }}"
bodyFile: "assets/release_notes/release_notes_en_${{ inputs.version }}.md"
bodyFile: "${{ env.RELEASE_NOTES_FILE }}.md"
create-google-play-release:
name: Create Google Play release
needs: [build-android, build-ios]
needs: [generate-release-notes]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
@ -118,11 +148,16 @@ jobs:
unzip app-prod-release.aab
(cd base/lib && zip -r "$OLDPWD/merged_native_libs.zip" .)
- name: Download release notes
uses: actions/download-artifact@v3
with:
name: ${{ env.RELEASE_NOTES_FILE }}
- name: Move release notes to a folder
run: |
mv assets/release_notes/release_notes_en_${{ inputs.version }}.md whatsnew-en-US
mv assets/release_notes/${{ env.RELEASE_NOTES_FILE }}.md ${{ env.RELEASE_NOTES_FILE }}
mkdir whatsnew
mv whatsnew-en-US whatsnew
mv ${{ env.RELEASE_NOTES_FILE }} whatsnew
# https://unix.stackexchange.com/questions/13466/can-grep-output-only-specified-groupings-that-match'
# https://stackoverflow.com/questions/74353311/github-workflow-unable-to-process-file-command-env-successfully
@ -147,7 +182,7 @@ jobs:
upload-to-app-store:
name: Upload to App Store
needs: [build-android, build-ios]
needs: [generate-release-notes]
runs-on: macos-13
steps:
- uses: actions/checkout@v3
@ -177,4 +212,4 @@ jobs:
m3_lightmeter_apk
m3_lightmeter_appbundle
m3_lightmeter_ipa
whatsnew-en-US
${{ env.RELEASE_NOTES_FILE }}

2
.gitignore vendored
View file

@ -63,4 +63,4 @@ ios/Runner/GoogleService-Info.plist
coverage/
test/coverage_helper_test.dart
**/failures/*.png
screenshots/generated/
screenshots/generated/raw/

View file

@ -23,11 +23,11 @@ Without further delay behold my new Lightmeter app inspired by Material You (a.k
# Screenshots
<p float="center">
<img src="https://lh3.googleusercontent.com/8Sd-pmNcQ0xAr5opuTeJKWr2OXeQvCoFSdVDSoKQSHHKeNmqF71hqeAdm3yjunY12zY" width="18.8%" />
<img src="https://lh3.googleusercontent.com/rqBv8pT0AdcBy0xEgQY2unV-YEQ5KfUkandAxJ62yYCiSF72HClA_tkb4JT_3UPaIfFP" width="18.8%" />
<img src="https://lh3.googleusercontent.com/-SnYbYSugVfdwYi6m_rd9CzpCZMCIfudhnq0zRIlzEtLSXhrwziWVd2hotygfqiSofI" width="18.8%" />
<img src="https://lh3.googleusercontent.com/UXxptL_dAIJDtrmpEZuSz39Iq4HuPb3ZPeuANfE9XH0De0uZQT83LNdu1AObBPobpg" width="18.8%" />
<img src="https://lh3.googleusercontent.com/15g_SPV8knDLFbz1_-wGNJFsJeyVWZ_y--TGHpk75MaaIdMDyTXY2_TL-Aw8bpOhpw" width="18.8%" />
<img src="screenshots/generated/android/android/light_metering-reflected.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_timer.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_settings.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_equipment-profiles.png" width="18.8%" />
<img src="screenshots/generated/android/android/dark_metering-reflected.png" width="18.8%" />
</p>
# Development

View file

@ -52,7 +52,7 @@ android {
defaultConfig {
minSdkVersion 21
targetSdkVersion 33
targetSdkVersion 34
ndk {
debugSymbolLevel 'FULL'
abiFilters 'armeabi-v7a', 'arm64-v8a'

View file

@ -0,0 +1,3 @@
- Added ISO values up to 25600.
- Improved Pro features description.
- Made "Restore purchases" option more accessible.

View file

@ -1,4 +0,0 @@
- [Pro] Добавлен таймер для удобства съёмки при длительных выдержках. Достаточно просто нажать на нужное значение в списке экспозиционных пар.
- Длинные выдержки больше не ограничены 1" и генерируются в соответствии со значениями диафрагмы.
- Переработана цветовая палитра приложения и унифицированы иконки.
- Добавлен список изменений.

View file

@ -6,26 +6,19 @@ Lightmeter
## Short description
Simple and powerful metering app inspired by Google's Material Design 3.
A simple and powerful metering app that can be used for any type of camera from film SLR to pinhole to cinematographic.
## Long description
<b>Material Design</b>
The user interface matches every single detail of the material design guidelines to ensure Lightmeter is an eye candy for you.
A simple and easy to use metering app that can be used for any type of camera from film SLR to pinhole to cinematographic. The app contains the following features:
<b>Easy to Use</b>
No complicated or overblown menus but a familiar and clean interface.
- A reflected light meter with spot metering (using the device's camera)
- An incident light meter (using the device's light sensor)
- An in-built timer for shooting long exposures
- A wide range of ISO values sutable even for solarphotograpy
- Reciprocity calculations for a variety of films
<b>Customizability</b>
There is an inbuilt theme engine with many different colors to choose from.
<b>Features</b>
• Incident light metering (uses lightsensor)
• Reflected light metering (needs camera)
• ISO range from 3 to 6400
• Pre-built reciprocity for some films
• Calibration & ND filters
and many more
and many more!
<b>NOTE</b>
The accuracy of the measurements depends on your decice's hardware.
@ -34,4 +27,4 @@ Email me, if you need help or detected bugs
## Graphics
[Figma](https://www.figma.com/file/X7pUAsxtjx19Rj8VtBV3Ft/Material-lightmeter?node-id=501%3A1586&t=cWNHaXm024KM4KYn-1)
[Figma](https://www.figma.com/file/X7pUAsxtjx19Rj8VtBV3Ft/Material-lightmeter?node-id=501%3A1586&t=cWNHaXm024KM4KYn-1)

View file

@ -9,15 +9,18 @@ enum IAPProductType { paidFeatures }
class IAPProduct {
final String storeId;
final IAPProductStatus status;
final String price;
const IAPProduct({
required this.storeId,
this.status = IAPProductStatus.purchasable,
required this.price,
});
IAPProduct copyWith({IAPProductStatus? status}) => IAPProduct(
storeId: storeId,
status: status ?? this.status,
price: price,
);
}

View file

@ -25,6 +25,7 @@ class IAPProductsProviderState extends State<IAPProductsProvider> {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
)
],
child: widget.child,

View file

@ -17,6 +17,7 @@ import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widg
import 'package:lightmeter/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/utils/double_to_zoom.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -27,7 +28,7 @@ import 'utils/expectations.dart';
@isTest
void testE2E(String description) {
setUp(() {
setUp(() async {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
@ -38,6 +39,8 @@ void testE2E(String description) {
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
});
@ -49,7 +52,7 @@ void testE2E(String description) {
/// Create Praktica + Zenitar profile from scratch
await tester.openSettings();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
await tester.tap(find.byIcon(Icons.add).first);
await tester.tap(find.byIcon(Icons.add_outlined).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[0].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[0].name);
@ -63,7 +66,7 @@ void testE2E(String description) {
expect(find.text('1/1000 - B'), findsOneWidget);
/// Create Praktica + Jupiter profile from Zenitar profile
await tester.tap(find.byIcon(Icons.copy).first);
await tester.tap(find.byIcon(Icons.copy_outlined).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[1].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[1].name);
@ -193,7 +196,7 @@ extension on WidgetTester {
bool deselectAll = true,
}) async {
if (deselectAll) {
await tap(find.byIcon(Icons.deselect));
await tap(find.byIcon(Icons.deselect_outlined));
await pump();
}
for (final value in valuesToSelect) {

View file

@ -10,6 +10,7 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -22,7 +23,7 @@ void testToggleLayoutFeatures(String description) {
group(
description,
() {
setUp(() {
setUp(() async {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
@ -33,6 +34,7 @@ void testToggleLayoutFeatures(String description) {
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
});

View file

@ -27,6 +27,7 @@ class MockIAPProductsProviderState extends State<MockIAPProductsProvider> {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: _purchased ? IAPProductStatus.purchased : IAPProductStatus.purchasable,
price: '0.0\$',
),
]),
child: widget.child,

View file

@ -14,6 +14,7 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -38,6 +39,7 @@ void testPurchases(String description) {
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
await tester.pumpApplication(productStatus: IAPProductStatus.purchasable);

View file

@ -5,8 +5,10 @@ import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/platform_config.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/flow_metering.dart';
import 'package:lightmeter/screens/settings/flow_settings.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/flow_dialog_release_notes.dart';
import 'package:lightmeter/screens/timer/flow_timer.dart';
class Application extends StatelessWidget {
@ -41,8 +43,9 @@ class Application extends StatelessWidget {
),
initialRoute: "metering",
routes: {
"metering": (_) => const MeteringFlow(),
"metering": (_) => const ReleaseNotesFlow(child: MeteringFlow()),
"settings": (_) => const SettingsFlow(),
"lightmeterPro": (_) => LightmeterProScreen(),
"timer": (context) => TimerFlow(args: ModalRoute.of(context)!.settings.arguments! as TimerFlowArgs),
},
),

View file

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
enum AppFeature {
reflectedLightMetering,
incidedntLightMetering,
isoAndNdValues,
themeEngine,
spotMetering,
histogram,
listOfFilms,
equipmentProfiles,
timer,
mainScreenCustomization;
static List<AppFeature> get androidFeatures => values;
static List<AppFeature> get iosFeatures => values.where((f) => f != AppFeature.incidedntLightMetering).toList();
String name(BuildContext context) {
switch (this) {
case AppFeature.reflectedLightMetering:
return S.of(context).featureReflectedLightMetering;
case AppFeature.incidedntLightMetering:
return S.of(context).featureIncidentLightMetering;
case AppFeature.isoAndNdValues:
return S.of(context).featureIsoAndNdValues;
case AppFeature.themeEngine:
return S.of(context).featureTheme;
case AppFeature.spotMetering:
return S.of(context).featureSpotMetering;
case AppFeature.histogram:
return S.of(context).featureHistogram;
case AppFeature.listOfFilms:
return S.of(context).featureListOfFilms;
case AppFeature.equipmentProfiles:
return S.of(context).featureEquipmentProfiles;
case AppFeature.timer:
return S.of(context).featureTimer;
case AppFeature.mainScreenCustomization:
return S.of(context).featureMeteringScreenLayout;
}
}
bool get isFree {
switch (this) {
case AppFeature.reflectedLightMetering:
case AppFeature.incidedntLightMetering:
case AppFeature.isoAndNdValues:
case AppFeature.themeEngine:
return true;
default:
return false;
}
}
}

View file

@ -103,10 +103,31 @@
}
}
},
"proFeatures": "Pro features",
"unlockProFeatures": "Unlock Pro features",
"unlockProFeaturesDescription": "Unlock professional features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering & Histogram\n \u2022 And more!\n\nBy unlocking Pro features you support the development and make it possible to add new features to the app.",
"unlock": "Unlock",
"proFeaturesTitle": "Lightmeter Pro",
"getPro": "Get Pro",
"featuresFree": "Free",
"featuresPro": "Pro",
"proFeaturesPromoText": "Lightmeter Pro delivers everything you need to get the best shots!",
"proFeaturesWhatsIncluded": "What's included?",
"featureReflectedLightMetering": "Reflected light metering",
"featureIncidentLightMetering": "Incident light metering",
"featureIsoAndNdValues": "Wide range of ISO and ND filters values",
"featureTheme": "Theme customization",
"featureSpotMetering": "Spot metering",
"featureHistogram": "Histogram",
"featureListOfFilms": "List of 20+ films with reciprocity formulas",
"featureEquipmentProfiles": "Equipment profiles",
"featureTimer": "Built-in timer for long exposure",
"featureMeteringScreenLayout": "Customizable main screen",
"proFeaturesSupportText": "By purchasing Lightmeter Pro you support the development and make it possible to add new features to the app.",
"getNowFor": "Get now for {price}",
"@getNowFor": {
"price": {
"version": {
"type": "String"
}
}
},
"tooltipAdd": "Add",
"tooltipClose": "Close",
"tooltipExpand": "Expand",

View file

@ -103,10 +103,32 @@
}
}
},
"proFeatures": "Fonctionnalités professionnelles",
"unlockProFeatures": "Déverrouiller les fonctionnalités professionnelles",
"unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot & Histogramme\n \u2022 Et plus encore!\n\nEn débloquant les fonctionnalités Pro, vous soutenez le développement et permettez d'ajouter de nouvelles fonctionnalités à l'application.",
"proFeaturesTitle": "Lightmeter Pro",
"getPro": "Acheter Pro",
"unlock": "Déverrouiller",
"featuresFree": "Gratuit",
"featuresPro": "Pro",
"proFeaturesPromoText": "Lightmeter Pro offre tout ce dont vous avez besoin pour obtenir les meilleurs clichés!",
"proFeaturesWhatsIncluded": "Qu'est-ce qui est inclus?",
"featureReflectedLightMetering": "Mesure de la lumière réfléchie",
"featureIncidentLightMetering": "Mesure de la lumière incidente",
"featureIsoAndNdValues": "Large gamme de valeurs ISO et de filtres ND",
"featureTheme": "Personnalisation du thème",
"featureSpotMetering": "Mesure spot",
"featureHistogram": "Histogramme",
"featureListOfFilms": "Liste de plus de 20 films avec des formules de correction",
"featureEquipmentProfiles": "Profils de l'équipement",
"featureTimer": "Minuteur intégré pour longues expositions",
"featureMeteringScreenLayout": "Écran principal personnalisable",
"proFeaturesSupportText": "En achetant Lightmeter Pro, vous soutenez le développement et permettez l'ajout de nouvelles fonctionnalités à l'application.",
"getNowFor": "Acheter maintenant {price}",
"@getNowFor": {
"price": {
"version": {
"type": "String"
}
}
},
"tooltipAdd": "Ajouter",
"tooltipClose": "Fermer",
"tooltipExpand": "Élargir",

View file

@ -67,8 +67,8 @@
"equipmentProfile": "Оборудование",
"equipmentProfiles": "Профили оборудования",
"tapToAdd": "Нажмите, чтобы добавить",
"filmsInUse": "Используемые пленки",
"filmsInUseDescription": "Выберите пленки, которыми вы пользуетесь.",
"filmsInUse": "Используемые плёнки",
"filmsInUseDescription": "Выберите плёнки, которыми вы пользуетесь.",
"general": "Общие",
"keepScreenOn": "Запрет блокировки",
"haptics": "Вибрация",
@ -103,10 +103,31 @@
}
}
},
"proFeatures": "Профессиональные настройки",
"unlockProFeatures": "Разблокировать профессиональные настройки",
"unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер и гистограмма\n \u2022 И другие возможности!\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.",
"unlock": "Разблокировать",
"proFeaturesTitle": "Lightmeter Pro",
"getPro": "Купить Pro",
"featuresFree": "Бесплатно",
"featuresPro": "Pro",
"proFeaturesPromoText": "Lightmeter Pro предоставляет все необходимое для получения лучших снимков!",
"proFeaturesWhatsIncluded": "Что включено?",
"featureReflectedLightMetering": "Замер отраженного света",
"featureIncidentLightMetering": "Замер падающего света",
"featureIsoAndNdValues": "Широкий диапазон значений ISO и фильтров ND",
"featureTheme": "Настройка темы",
"featureSpotMetering": "Точечный замер",
"featureHistogram": "Гистограмма",
"featureListOfFilms": "Список из 20+ плёнок с формулами коррекции",
"featureEquipmentProfiles": "Профили оборудования",
"featureTimer": "Встроенный таймер для длинных выдержек",
"featureMeteringScreenLayout": "Настраиваемый главный экран",
"proFeaturesSupportText": "Покупая Lightmeter Pro, вы поддерживаете разработку и делаете возможным добавление новых функций в приложение.",
"getNowFor": "Купить за {price}",
"@getNowFor": {
"price": {
"version": {
"type": "String"
}
}
},
"tooltipAdd": "Добавить",
"tooltipClose": "Закрыть",
"tooltipExpand": "Развернуть",

View file

@ -103,10 +103,30 @@
}
}
},
"proFeatures": "专业功能",
"unlockProFeatures": "解锁专业功能",
"unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光和直方图\n \u2022 和更多\n\n通过解锁专业版功能您可以支持开发工作帮助为应用程序添加新功能。",
"unlock": "解锁",
"getPro": "购买专业版",
"featuresFree": "免费",
"featuresPro": "专业版",
"proFeaturesPromoText": "Lightmeter Pro 提供您需要的一切,助您拍出最佳照片!",
"proFeaturesWhatsIncluded": "包括哪些内容?",
"featureReflectedLightMetering": "反射光测光",
"featureIncidentLightMetering": "入射光测光",
"featureIsoAndNdValues": "广泛的ISO和ND滤镜值范围",
"featureTheme": "主题自定义",
"featureSpotMetering": "点测光",
"featureHistogram": "直方图",
"featureListOfFilms": "20多部电影的修正公式列表",
"featureEquipmentProfiles": "设备配置文件",
"featureTimer": "内置长曝光计时器",
"featureMeteringScreenLayout": "可自定义的主屏幕",
"proFeaturesSupportText": "通过购买Lightmeter Pro您支持开发工作并使添加新功能成为可能。",
"getNowFor": "立即获取 {price}",
"@getNowFor": {
"price": {
"version": {
"type": "String"
}
}
},
"tooltipAdd": "添加",
"tooltipClose": "关闭",
"tooltipExpand": "展开",

View file

@ -68,4 +68,7 @@ class Dimens {
paddingL,
);
static const EdgeInsets dialogMargin = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0);
// TODO(@vodemn) constrain dialogs with this value
static const double tabletMaxWidth = 600;
}

View file

@ -25,7 +25,12 @@ Future<void> runLightmeterApp(Environment env) async {
runApp(
env.buildType == BuildType.dev
? IAPProducts(
products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)],
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
price: '0.0\$',
),
],
child: application,
)
: IAPProductsProvider(

View file

@ -0,0 +1,229 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/app_feature.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/res/theme.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:lightmeter/utils/text_height.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class LightmeterProScreen extends StatelessWidget {
final features =
defaultTargetPlatform == TargetPlatform.android ? AppFeature.androidFeatures : AppFeature.iosFeatures;
LightmeterProScreen({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SliverScreen(
title: S.of(context).proFeaturesTitle,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingM),
child: Text(
S.of(context).proFeaturesPromoText,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingM,
0,
Dimens.paddingM,
Dimens.paddingS,
),
child: Text(
S.of(context).proFeaturesWhatsIncluded,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
const SliverToBoxAdapter(child: _FeaturesHeader()),
SliverList.separated(
itemCount: features.length,
itemBuilder: (_, index) => _FeatureItem(feature: features[index]),
separatorBuilder: (_, __) => const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Divider(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingM),
child: Text(S.of(context).proFeaturesSupportText),
),
),
],
),
),
Container(
color: Theme.of(context).colorScheme.surfaceElevated1,
width: MediaQuery.sizeOf(context).width,
padding: EdgeInsets.fromLTRB(
Dimens.paddingM,
Dimens.paddingM,
Dimens.paddingM,
Dimens.paddingM + MediaQuery.paddingOf(context).bottom,
),
child: FilledButton(
onPressed: () {
ServicesProvider.maybeOf(context)
?.analytics
.setCustomKey('iap_product_type', IAPProductType.paidFeatures.storeId);
IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures);
Navigator.of(context).pop();
},
child: Text(S.of(context).getNowFor(IAPProducts.productOf(context, IAPProductType.paidFeatures)!.price)),
),
),
],
);
}
}
class _FeaturesHeader extends StatelessWidget {
const _FeaturesHeader();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Row(
children: [
const Spacer(),
_FeatureHighlight(child: Text(S.of(context).featuresFree)),
_FeatureHighlight(
roundedTop: true,
highlight: true,
child: Text(
S.of(context).featuresPro,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer),
),
),
],
),
);
}
}
class _FeatureItem extends StatelessWidget {
final AppFeature feature;
const _FeatureItem({
required this.feature,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: Dimens.grid48),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
child: Text(
feature.name(context),
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
Opacity(
opacity: feature.isFree ? 1 : 0,
child: const _FeatureHighlight(
child: _CheckBox(highlight: false),
),
),
_FeatureHighlight(
highlight: true,
roundedBottom: feature == AppFeature.values.last,
child: const _CheckBox(highlight: true),
),
const SizedBox(width: Dimens.grid16),
],
),
),
);
}
}
class _FeatureHighlight extends StatelessWidget {
final bool highlight;
final bool roundedTop;
final bool roundedBottom;
final Widget child;
const _FeatureHighlight({
this.highlight = false,
this.roundedTop = false,
this.roundedBottom = false,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
minWidth: textSize(
highlight ? S.of(context).featuresPro : S.of(context).featuresFree,
Theme.of(context).textTheme.bodyMedium,
MediaQuery.sizeOf(context).width,
).width +
Dimens.paddingM * 2,
),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
decoration: BoxDecoration(
color: highlight ? Theme.of(context).colorScheme.secondaryContainer : null,
borderRadius: roundedTop
? const BorderRadius.only(
topLeft: Radius.circular(Dimens.borderRadiusM),
topRight: Radius.circular(Dimens.borderRadiusM),
)
: roundedBottom
? const BorderRadius.only(
bottomLeft: Radius.circular(Dimens.borderRadiusM),
bottomRight: Radius.circular(Dimens.borderRadiusM),
)
: null,
),
child: child,
);
}
}
class _CheckBox extends StatelessWidget {
final bool highlight;
const _CheckBox({required this.highlight});
@override
Widget build(BuildContext context) {
return Icon(
Icons.check_outlined,
color: highlight ? Theme.of(context).colorScheme.onSecondaryContainer : null,
);
}
}

View file

@ -76,28 +76,31 @@ class CameraContainer extends StatelessWidget {
height: min(meteringContainerHeight, cameraPreviewHeight) + Dimens.paddingM * 2,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.only(top: topBarOverflow >= 0 ? topBarOverflow : 0),
child: ExposurePairsList(
exposurePairs,
onExposurePairTap: onExposurePairTap,
),
child: Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: Dimens.paddingM - Dimens.grid8,
top: topBarOverflow >= 0 ? topBarOverflow : 0,
),
child: ExposurePairsList(
exposurePairs,
onExposurePairTap: onExposurePairTap,
),
),
const SizedBox(width: Dimens.grid8),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: topBarOverflow <= 0 ? -topBarOverflow : 0),
child: const _CameraControlsBuilder(),
),
SizedBox(
width: MediaQuery.sizeOf(context).width / 2 - Dimens.grid4,
child: Padding(
padding: EdgeInsets.only(
top: topBarOverflow <= 0 ? -topBarOverflow : 0,
right: Dimens.paddingM,
),
child: const _CameraControlsBuilder(),
),
],
),
),
],
),
),
],

View file

@ -1,3 +1,4 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@ -15,22 +16,9 @@ class ExposurePairsListItem<T extends PhotographyStopValue> extends StatelessWid
@override
Widget build(BuildContext context) {
final List<Widget> rowChildren = [
Flexible(
child: Text(
value.toString(),
style: labelTextStyle(context).copyWith(color: Theme.of(context).colorScheme.onBackground),
softWrap: false,
overflow: TextOverflow.fade,
),
),
_Title(value),
const SizedBox(width: Dimens.grid8),
ColoredBox(
color: Theme.of(context).colorScheme.onBackground,
child: SizedBox(
height: 1,
width: tickLength(),
),
),
if (value.stopType == StopType.full) const _Tick.full() else const _Tick.short(),
if (value.stopType != StopType.full) const SizedBox(width: Dimens.grid4),
];
return Row(
@ -59,3 +47,61 @@ class ExposurePairsListItem<T extends PhotographyStopValue> extends StatelessWid
}
}
}
class _Title<T extends PhotographyStopValue> extends StatelessWidget {
final T value;
late final String _title = value.toString();
_Title(this.value, {super.key});
@override
Widget build(BuildContext context) {
return Flexible(
child: _title.length > 5 // downsize text only for long values like 1/4000
? AutoSizeText(
value.toString(),
stepGranularity: 0.5,
minFontSize: 10,
style: labelTextStyle(context).copyWith(color: Theme.of(context).colorScheme.onBackground),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
)
: Text(
value.toString(),
style: labelTextStyle(context).copyWith(color: Theme.of(context).colorScheme.onBackground),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}
TextStyle labelTextStyle(BuildContext context) {
switch (value.stopType) {
case StopType.full:
return Theme.of(context).textTheme.bodyLarge!;
case StopType.half:
case StopType.third:
return Theme.of(context).textTheme.bodyMedium!;
}
}
}
class _Tick extends StatelessWidget {
final double _length;
const _Tick.full() : _length = Dimens.grid16;
const _Tick.short() : _length = Dimens.grid8;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Theme.of(context).colorScheme.onBackground,
child: SizedBox(
height: 1,
width: _length,
),
);
}
}

View file

@ -1,27 +1,26 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart';
class LightmeterProAnimatedDialog extends StatelessWidget {
const LightmeterProAnimatedDialog({super.key});
@override
Widget build(BuildContext context) {
return AnimatedDialog(
closedChild: ReadingValueContainer(
color: Theme.of(context).colorScheme.errorContainer,
textColor: Theme.of(context).colorScheme.onErrorContainer,
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed("lightmeterPro");
},
child: ReadingValueContainer(
color: Theme.of(context).colorScheme.secondary,
textColor: Theme.of(context).colorScheme.onSecondary,
values: [
ReadingValue(
label: S.of(context).proFeatures,
value: S.of(context).unlock,
label: S.of(context).proFeaturesTitle,
value: S.of(context).getPro,
),
],
),
openedChild: const ProFeaturesDialog(),
openedSize: Size.fromHeight(const ProFeaturesDialog().height(context)),
);
}
}

View file

@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/widget_dialog_release_notes.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:lightmeter/utils/platform_utils.dart';
class VersionListTile extends StatelessWidget {
const VersionListTile({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
return FutureBuilder<({String buildNumber, String version})>(
future: const PlatformUtils().buildVersion,
builder: (context, snapshot) => ListTile(
leading: const Icon(Icons.info_outline),
title: Text(S.of(context).version),

View file

@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart';
import 'package:lightmeter/screens/settings/components/about/components/restore_purchases/widget_list_tile_restore_purchases.dart';
import 'package:lightmeter/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart';
import 'package:lightmeter/screens/settings/components/about/components/version/widget_list_tile_version.dart';
import 'package:lightmeter/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart';
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
import 'package:lightmeter/utils/context_utils.dart';
class AboutSettingsSection extends StatelessWidget {
const AboutSettingsSection({super.key});
@ -15,12 +13,11 @@ class AboutSettingsSection extends StatelessWidget {
Widget build(BuildContext context) {
return SettingsSection(
title: S.of(context).about,
children: [
const SourceCodeListTile(),
if (!context.isPro) const RestorePurchasesListTile(),
const ReportIssueListTile(),
const WriteEmailListTile(),
const VersionListTile(),
children: const [
SourceCodeListTile(),
ReportIssueListTile(),
WriteEmailListTile(),
VersionListTile(),
],
);
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class BuyProListTile extends StatelessWidget {
@ -12,14 +11,11 @@ class BuyProListTile extends StatelessWidget {
final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status;
final isPending = status == IAPProductStatus.purchased || status == null;
return ListTile(
leading: const Icon(Icons.star_outlined),
title: Text(S.of(context).unlockProFeatures),
leading: const Icon(Icons.bolt),
title: Text(S.of(context).getPro),
onTap: !isPending
? () {
showDialog(
context: context,
builder: (_) => const Dialog(child: ProFeaturesDialog()),
);
Navigator.of(context).pushNamed("lightmeterPro");
}
: null,
trailing: isPending

View file

@ -10,7 +10,7 @@ class RestorePurchasesListTile extends StatelessWidget {
return ListTile(
leading: const Icon(Icons.restore_outlined),
title: Text(S.of(context).restorePurchases),
onTap: IAPProductsProvider.of(context).restorePurchases,
onTap: IAPProductsProvider.maybeOf(context)?.restorePurchases,
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/restore_purchases/widget_list_tile_restore_purchases.dart';
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
class LightmeterProSettingsSection extends StatelessWidget {
@ -9,8 +10,13 @@ class LightmeterProSettingsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SettingsSection(
title: S.of(context).proFeatures,
children: const [BuyProListTile()],
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
title: S.of(context).proFeaturesTitle,
children: const [
BuyProListTile(),
RestorePurchasesListTile(),
],
);
}
}

View file

@ -4,10 +4,14 @@ import 'package:lightmeter/res/dimens.dart';
class SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
final Color? backgroundColor;
final Color? foregroundColor;
const SettingsSection({
required this.title,
required this.children,
this.backgroundColor,
this.foregroundColor,
super.key,
});
@ -21,24 +25,33 @@ class SettingsSection extends StatelessWidget {
Dimens.paddingM,
),
child: Card(
color: backgroundColor,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Text(
title,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.onSurface),
child: Theme(
data: Theme.of(context).copyWith(
listTileTheme: Theme.of(context).listTileTheme.copyWith(
iconColor: foregroundColor,
textColor: foregroundColor,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Text(
title,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: foregroundColor ?? Theme.of(context).colorScheme.onSurface),
),
),
),
...children,
],
...children,
],
),
),
),
),

View file

@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart';
import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart';
import 'package:lightmeter/utils/text_height.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class ProFeaturesDialog extends StatelessWidget {
const ProFeaturesDialog({super.key});
double height(BuildContext context) => TransparentDialog.height(
context,
title: S.of(context).proFeatures,
contextHeight: dialogTextHeight(
context,
S.of(context).unlockProFeaturesDescription,
Theme.of(context).textTheme.bodyMedium,
Dimens.paddingL * 2,
),
);
@override
Widget build(BuildContext context) {
return TransparentDialog(
icon: Icons.star_outlined,
title: S.of(context).proFeatures,
scrollableContent: false,
content: Flexible(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL),
child: Text(
S.of(context).unlockProFeaturesDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
actions: [
TextButton(
onPressed: () => _close(context),
child: Text(S.of(context).cancel),
),
FilledButton(
onPressed: () {
_close(context).then((_) {
ServicesProvider.maybeOf(context)
?.analytics
.setCustomKey('iap_product_type', IAPProductType.paidFeatures.storeId);
IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures);
});
},
child: Text(S.of(context).unlock),
),
],
);
}
Future<void> _close(BuildContext context) async => AnimatedDialog.maybeClose(context) ?? Navigator.of(context).pop();
}

View file

@ -3,20 +3,23 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/state_release_notes.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:lightmeter/utils/platform_utils.dart';
class ReleaseNotesBloc extends Cubit<ReleaseNotesState> {
final UserPreferencesService _userPreferencesService;
final PlatformUtils _platformUtils;
ReleaseNotesBloc(this._userPreferencesService) : super(const HiddenReleaseNotesDialogState()) {
ReleaseNotesBloc(
this._userPreferencesService,
this._platformUtils,
) : super(const HiddenReleaseNotesDialogState()) {
_showDialogIfNeeded();
}
Future<void> _showDialogIfNeeded() async {
PackageInfo.fromPlatform().then((value) {
emit(ShowReleaseNotesDialogState(value.version));
if (value.version != _userPreferencesService.seenChangelogVersion) {
emit(ShowReleaseNotesDialogState(value.version));
_platformUtils.version.then((version) {
if (version != _userPreferencesService.seenChangelogVersion) {
emit(ShowReleaseNotesDialogState(version));
}
});
}

View file

@ -4,6 +4,7 @@ import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/bloc_release_notes.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/state_release_notes.dart';
import 'package:lightmeter/screens/shared/release_notes_dialog/widget_dialog_release_notes.dart';
import 'package:lightmeter/utils/platform_utils.dart';
class ReleaseNotesFlow extends StatelessWidget {
final Widget child;
@ -13,7 +14,10 @@ class ReleaseNotesFlow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ReleaseNotesBloc(ServicesProvider.of(context).userPreferencesService),
create: (context) => ReleaseNotesBloc(
ServicesProvider.of(context).userPreferencesService,
const PlatformUtils(),
),
child: BlocListener<ReleaseNotesBloc, ReleaseNotesState>(
listener: (context, state) {
if (state is ShowReleaseNotesDialogState) {

View file

@ -79,15 +79,18 @@ class TimerScreenState extends State<TimerScreen> with TickerProviderStateMixin
const Spacer(),
Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: SizedBox.fromSize(
size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4),
child: ValueListenableBuilder(
valueListenable: timelineAnimation,
builder: (_, value, child) => TimerTimeline(
progress: value,
child: TimerText(
timeLeft: Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt()),
duration: widget.duration,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: Dimens.tabletMaxWidth, maxWidth: Dimens.tabletMaxWidth),
child: SizedBox.fromSize(
size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4),
child: ValueListenableBuilder(
valueListenable: timelineAnimation,
builder: (_, value, child) => TimerTimeline(
progress: value,
child: TimerText(
timeLeft: Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt()),
duration: widget.duration,
),
),
),
),

View file

@ -0,0 +1,10 @@
import 'package:package_info_plus/package_info_plus.dart';
class PlatformUtils {
const PlatformUtils();
Future<String> get version async => (await PackageInfo.fromPlatform()).version;
Future<({String version, String buildNumber})> get buildVersion async =>
PackageInfo.fromPlatform().then((value) => (version: value.version, buildNumber: value.buildNumber));
}

View file

@ -17,6 +17,13 @@ double textHeight(
String text,
TextStyle? style,
double maxWidth,
) =>
textSize(text, style, maxWidth).height;
Size textSize(
String text,
TextStyle? style,
double maxWidth,
) {
final TextPainter titlePainter = TextPainter(
text: TextSpan(
@ -25,5 +32,5 @@ double textHeight(
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth);
return titlePainter.height;
return titlePainter.size;
}

View file

@ -1,13 +1,14 @@
name: lightmeter
description: Lightmeter app inspired by Material 3 design system.
publish_to: "none"
version: 0.18.0+49
version: 0.20.0+51
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
app_settings: 4.2.0
auto_size_text: 3.0.0
bloc_concurrency: 0.2.2
camera: 0.10.5+2
camera_android_camerax: 0.6.1+1
@ -29,11 +30,11 @@ dependencies:
m3_lightmeter_iap:
git:
url: "https://github.com/vodemn/m3_lightmeter_iap"
ref: v0.10.0
ref: v0.11.2
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
ref: v1.2.0
ref: v1.4.0
material_color_utilities: 0.5.0
package_info_plus: 4.2.0
permission_handler: 10.4.3
@ -44,6 +45,7 @@ dependencies:
vibration: 1.8.1
dev_dependencies:
args: 2.5.0
bloc_test: 9.1.3
build_runner: 2.4.6
flutter_native_splash: 2.3.5
@ -51,14 +53,20 @@ dev_dependencies:
sdk: flutter
golden_toolkit: 0.15.0
google_fonts: 3.0.1
image: 4.1.7
integration_test:
sdk: flutter
lint: 2.1.2
logging: 1.2.0
meta: 1.9.1
mocktail: 0.3.0
test: 1.24.3
dependency_overrides:
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
ref: v1.4.0
material_color_utilities: 0.11.1
flutter:

View file

@ -17,28 +17,35 @@ As a user I want to see the most relevant screenshots in the store, so that I ca
- Settings screen
1. Just the screen
2. Opened metering screen layout features dialog
- Equipment profiles screen
1. Just the screen
2. Opened equipment profile ISO picker
- Timer screen
1. Just the screen
> \*also in dark mode
> \*\*Android only
## Run the generator
Screenshots will be stored in the _screenshots/generated/\<platform\>/_ folder.
Release screenshots will be stored in the _screenshots/generated/\<platform\>/_ folder.
### Android
Raw screenshots will be stored in the _screenshots/generated/raw/\<platform\>/_ folder.
### Generate raw screenshots
#### Android
```console
sh screenshots/generate_screenshots.sh <deviceName>
sh screenshots/generate_android_screenshots.sh
```
### iOS
#### iOS
Apple requires screenshots a specific list of devices, so we can implement a custom generator to cover all those devices.
@ -48,6 +55,12 @@ Can be run on Simulator.
sh screenshots/generate_ios_screenshots.sh
```
### Apply store constraints and text data
```console
sh screenshots/scripts/convert_to_store_screenshots.sh
```
## List of devices
### Android
@ -56,9 +69,5 @@ sh screenshots/generate_ios_screenshots.sh
### iOS
- iPhone 8 Plus
- iPhone 13 Pro
- iPhone 13 Pro Max
- iPhone 15 Pro
- iPhone 15 Pro Max
- iPad Pro (12.9-inch) (6th generation)

View file

@ -0,0 +1,44 @@
{
"screenshots": [
{
"screenshotName": "light_metering-reflected",
"title": "Quick & easy to use",
"subtitle": "with all the necessary controls\nunder your thumb"
},
{
"screenshotName": "light_metering-incident",
"title": "Incident light metering",
"subtitle": "using the light sensor\nof your device"
},
{
"screenshotName": "light_metering-iso-picker",
"title": "Lots of ISO values",
"subtitle": "from 3 and up to 6400"
},
{
"screenshotName": "light_timer",
"title": "In-built timer",
"subtitle": "for the ease of shooting\nlong exposures"
},
{
"screenshotName": "light_settings",
"title": "Useful settings",
"subtitle": "to get the most accurate\nmetering results"
},
{
"screenshotName": "light_equipment-profiles",
"title": "Create multiple profiles",
"subtitle": "to match your\ncamera & lens setups"
},
{
"screenshotName": "light_equipment-profiles-iso-picker",
"title": "Fine-tune results",
"subtitle": "by selecting the values\nthat you use the most"
},
{
"screenshotName": "dark_metering-reflected",
"title": "Match your style",
"subtitle": "with various theme types and colors"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,189 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:image/image.dart';
import 'package:logging/logging.dart';
import 'models/screenshot_args.dart';
import 'models/screenshot_device.dart';
import 'models/screenshot_layout.dart';
import 'utils/parse_configs.dart';
final _configs = parseScreenshotConfigs();
Future<int> main(List<String> args) async {
final parser = ArgParser()
..addFlag('verbose', abbr: 'v', help: 'Verbose output')
..addOption('platform', abbr: 'p', help: 'Device platform', mandatory: true)
..addOption('device', abbr: 'd', help: 'device_snake_name', mandatory: true)
..addOption('layout', abbr: 'l', help: 'Device layout', mandatory: true);
final ArgResults argResults = parser.parse(args);
if (argResults['verbose'] as bool) {
Logger.root.level = Level.ALL;
} else {
Logger.root.level = Level.INFO;
}
final platform = argResults["platform"] as String;
final device = argResults["device"] as String;
final layout = ScreenshotLayout.values.firstWhere((e) => e.name == argResults["layout"] as String);
Directory('screenshots/generated/raw/$platform/$device').listSync().forEach((filePath) async {
final screenshotName = filePath.path.split('/').last.replaceAll('.png', '');
final screenshotBytes = File(filePath.path).readAsBytesSync();
final screenshot = decodePng(Uint8List.fromList(screenshotBytes))!;
final screenshotArgs = ScreenshotArgs.fromRawName(
name: screenshotName,
deviceName: device,
platformFolder: platform,
);
final file = await File(screenshotArgs.toPath(layout.name)).create(recursive: true);
file.writeAsBytesSync(
encodePng(
screenshot.convertToStoreScreenshot(
args: screenshotArgs,
layout: layout,
),
),
);
});
return 0;
}
extension ScreenshotImage on Image {
Image convertToStoreScreenshot({
required ScreenshotArgs args,
required ScreenshotLayout layout,
}) {
if (_configs[args.nameWithTheme] == null) {
return this;
}
return _addSystemOverlay(
screenshotDevices[args.deviceName]!,
isDark: args.isDark,
)
._addDeviceFrame(
screenshotDevices[args.deviceName]!,
args.backgroundColor,
)
._applyLayout(
layout,
_configs[args.nameWithTheme]!.title,
_configs[args.nameWithTheme]!.subtitle,
isDark: args.isDark,
);
}
Image _addSystemOverlay(ScreenshotDevice device, {required bool isDark}) {
final path = isDark ? device.systemOverlayPathDark : device.systemOverlayPathLight;
final statusBar = copyResize(
decodePng(File(path).readAsBytesSync())!,
width: width,
);
return compositeImage(this, statusBar);
}
Image _addDeviceFrame(ScreenshotDevice device, String color) {
final backgroundColor = ColorRgba8(
int.parse(color.substring(2, 4), radix: 16),
int.parse(color.substring(4, 6), radix: 16),
int.parse(color.substring(6, 8), radix: 16),
int.parse(color.substring(0, 2), radix: 16),
);
final screenshotRounded = copyCrop(
this,
x: 0,
y: 0,
width: width,
height: height,
);
final frame = decodePng(File(device.deviceFramePath).readAsBytesSync())!;
final expandedScreenshot = copyExpandCanvas(
copyExpandCanvas(
screenshotRounded,
newWidth: screenshotRounded.width + device.screenshotFrameOffset.dx,
newHeight: screenshotRounded.height + device.screenshotFrameOffset.dy,
position: ExpandCanvasPosition.bottomRight,
backgroundColor: backgroundColor,
),
newWidth: frame.width,
newHeight: frame.height,
position: ExpandCanvasPosition.topLeft,
backgroundColor: backgroundColor,
);
return compositeImage(expandedScreenshot, frame);
}
Image _applyLayout(ScreenshotLayout layout, String title, String subtitle, {required bool isDark}) {
final textImage = _drawTitles(layout, title, subtitle, isDark: isDark);
final maxFrameHeight =
layout.size.height - (layout.contentPadding.top + textImage.height + 84 + layout.contentPadding.bottom);
int maxFrameWidth = layout.size.width - (layout.contentPadding.left + layout.contentPadding.right);
if (maxFrameWidth * height / width > maxFrameHeight) {
maxFrameWidth = maxFrameHeight * width ~/ height;
}
final scaledScreenshot = copyResize(this, width: maxFrameWidth);
final draft = copyExpandCanvas(
copyExpandCanvas(
scaledScreenshot,
newWidth: scaledScreenshot.width + (layout.size.width - scaledScreenshot.width) ~/ 2,
newHeight: scaledScreenshot.height + layout.contentPadding.bottom,
position: ExpandCanvasPosition.topLeft,
backgroundColor: getPixel(0, 0),
),
newWidth: layout.size.width,
newHeight: layout.size.height,
position: ExpandCanvasPosition.bottomRight,
backgroundColor: getPixel(0, 0),
);
return compositeImage(
draft,
textImage,
dstX: layout.contentPadding.left,
dstY: layout.contentPadding.top,
);
}
Image _drawTitles(ScreenshotLayout layout, String title, String subtitle, {required bool isDark}) {
final titleFont =
BitmapFont.fromZip(File(isDark ? layout.titleFontDarkPath : layout.titleFontPath).readAsBytesSync());
final subtitleFont =
BitmapFont.fromZip(File(isDark ? layout.subtitleFontDarkPath : layout.subtitleFontPath).readAsBytesSync());
final textImage = fill(
Image(
height: titleFont.lineHeight + 36 + subtitleFont.lineHeight * 2,
width: layout.size.width - (layout.contentPadding.left + layout.contentPadding.right),
),
color: getPixel(0, 0),
);
drawString(
textImage,
title,
font: titleFont,
y: 0,
);
int subtitleDy = titleFont.lineHeight + 36;
subtitle.split('\n').forEach((line) {
drawString(
textImage,
line,
font: subtitleFont,
y: subtitleDy,
);
subtitleDy += subtitleFont.lineHeight;
});
return textImage;
}
}

View file

@ -1,3 +1,5 @@
// ignore_for_file: invalid_use_of_visible_for_testing_member
import 'dart:convert';
import 'dart:io';
@ -5,44 +7,59 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/models/volume_action.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/res/theme.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/screen_metering.dart';
import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart';
import 'package:lightmeter/screens/timer/screen_timer.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/mocks/paid_features_mock.dart';
import '../integration_test/utils/widget_tester_actions.dart';
import 'models/screenshot_args.dart';
//https://stackoverflow.com/a/67186625/13167574
const _mockFilm = Film('Ilford HP5+', 400);
final Color _lightThemeColor = primaryColorsList[5];
final Color _darkThemeColor = primaryColorsList[3];
final ThemeData _themeLight = themeFrom(_lightThemeColor, Brightness.light);
final ThemeData _themeDark = themeFrom(_darkThemeColor, Brightness.dark);
/// Just a screenshot generator. No expectations here.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding();
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final Color lightThemeColor = primaryColorsList[5];
final Color darkThemeColor = primaryColorsList[3];
void mockSharedPrefs(ThemeType theme, Color color) {
// ignore: invalid_use_of_visible_for_testing_member
Future<void> mockSharedPrefs({
int iso = 400,
int nd = 0,
double calibration = 0.0,
required ThemeType theme,
required Color color,
}) async {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.isoKey: 400,
UserPreferencesService.ndFilterKey: 0,
UserPreferencesService.isoKey: iso,
UserPreferencesService.ndFilterKey: nd,
/// Metering settings
UserPreferencesService.stopTypeKey: StopType.third.index,
UserPreferencesService.cameraEvCalibrationKey: 0.0,
UserPreferencesService.lightSensorEvCalibrationKey: 0.0,
UserPreferencesService.cameraEvCalibrationKey: calibration,
UserPreferencesService.lightSensorEvCalibrationKey: calibration,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
@ -52,6 +69,7 @@ void main() {
),
/// General settings
UserPreferencesService.autostartTimerKey: false,
UserPreferencesService.caffeineKey: true,
UserPreferencesService.hapticsKey: true,
UserPreferencesService.volumeActionKey: VolumeAction.shutter.toString(),
@ -61,6 +79,8 @@ void main() {
UserPreferencesService.themeTypeKey: theme.index,
UserPreferencesService.primaryColorKey: color.value,
UserPreferencesService.dynamicColorKey: false,
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
}
@ -70,7 +90,7 @@ void main() {
/// Generates several screenshots with the light theme
testWidgets('Generate light theme screenshots', (tester) async {
mockSharedPrefs(ThemeType.light, lightThemeColor);
await mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
@ -78,43 +98,39 @@ void main() {
);
await tester.takePhoto();
await tester.takeScreenshot(binding, 'light-metering_reflected');
await tester.takeScreenshotLight(binding, 'metering-reflected');
if (Platform.isAndroid) {
await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
await tester.pumpAndSettle();
await tester.toggleIncidentMetering(7.3);
await tester.takeScreenshot(binding, 'light-metering_incident');
await tester.takeScreenshotLight(binding, 'metering-incident');
}
await tester.openAnimatedPicker<IsoValuePicker>();
await tester.takeScreenshot(binding, 'light-metering_iso_picker');
await tester.takeScreenshotLight(binding, 'metering-iso-picker');
await tester.tapCancelButton();
await tester.tap(find.byTooltip(S.current.tooltipOpenSettings));
await tester.pumpAndSettle();
await tester.takeScreenshot(binding, 'light-settings');
await tester.takeScreenshotLight(binding, 'settings');
await tester.tapDescendantTextOf<SettingsScreen>(S.current.meteringScreenLayout);
await tester.takeScreenshot(binding, 'light-settings_metering_screen_layout');
await tester.tapCancelButton();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
await tester.pumpAndSettle();
await tester.tapDescendantTextOf<EquipmentProfilesScreen>(mockEquipmentProfiles.first.name);
await tester.pumpAndSettle();
await tester.takeScreenshot(binding, 'light-equipment_profiles');
await tester.takeScreenshotLight(binding, 'equipment-profiles');
await tester.tap(find.byIcon(Icons.iso).first);
await tester.tap(find.byIcon(Icons.iso_outlined).first);
await tester.pumpAndSettle();
await tester.takeScreenshot(binding, 'light-equipment_profiles_iso_picker');
await tester.takeScreenshotLight(binding, 'equipment-profiles-iso-picker');
});
/// and the additionally the first one with the dark theme
testWidgets(
'Generate dark theme screenshots',
(tester) async {
mockSharedPrefs(ThemeType.dark, darkThemeColor);
await mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
@ -122,14 +138,39 @@ void main() {
);
await tester.takePhoto();
await tester.takeScreenshot(binding, 'dark-metering_reflected');
await tester.takeScreenshotDark(binding, 'metering-reflected');
},
);
if (Platform.isAndroid) {
await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
await tester.pumpAndSettle();
await tester.toggleIncidentMetering(7.3);
await tester.takeScreenshot(binding, 'dark-metering_incident');
}
testWidgets(
'Generate timer screenshot',
(tester) async {
const timerExposurePair = ExposurePair(
ApertureValue(16, StopType.full),
ShutterSpeedValue(8, false, StopType.full),
);
await mockSharedPrefs(
iso: 100,
nd: 8,
calibration: -0.3,
theme: ThemeType.light,
color: _lightThemeColor,
);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
selectedFilm: _mockFilm,
);
await tester.takePhoto();
await tester.scrollToExposurePair(
ev: 5,
exposurePair: timerExposurePair,
);
await tester.tap(find.text(timerExposurePair.shutterSpeed.toString()));
await tester.pumpAndSettle();
await tester.mockTimerResumedState(timerExposurePair.shutterSpeed);
await tester.takeScreenshotLight(binding, 'timer');
},
);
}
@ -137,8 +178,56 @@ void main() {
final String _platformFolder = Platform.isAndroid ? 'android' : 'ios';
extension on WidgetTester {
Future<void> takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async {
await binding.takeScreenshot("$_platformFolder/${const String.fromEnvironment('deviceName')}/$name");
Future<void> takeScreenshotLight(IntegrationTestWidgetsFlutterBinding binding, String name) =>
_takeScreenshot(binding, name, _themeLight);
Future<void> takeScreenshotDark(IntegrationTestWidgetsFlutterBinding binding, String name) =>
_takeScreenshot(binding, name, _themeDark);
Future<void> _takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name, ThemeData theme) async {
final Color backgroundColor = theme.colorScheme.surface;
await binding.takeScreenshot(
ScreenshotArgs(
name: name,
deviceName: const String.fromEnvironment('deviceName'),
platformFolder: _platformFolder,
backgroundColor: backgroundColor.value.toRadixString(16),
isDark: theme.brightness == Brightness.dark,
).toString(),
);
await pumpAndSettle();
}
}
extension on WidgetTester {
Future<void> scrollToExposurePair({
double ev = mockPhotoEv100,
EquipmentProfile equipmentProfile = defaultEquipmentProfile,
required ExposurePair exposurePair,
}) async {
final exposurePairs = MeteringContainerBuidler.buildExposureValues(
ev,
StopType.third,
equipmentProfile,
);
await scrollUntilVisible(
find.byWidgetPredicate((widget) => widget is Row && widget.key == ValueKey(exposurePairs.indexOf(exposurePair))),
56,
scrollable: find.descendant(of: find.byType(ExposurePairsList), matching: find.byType(Scrollable)),
);
}
Future<void> mockTimerResumedState(ShutterSpeedValue shutterSpeedValue) async {
await tap(find.byType(AnimatedCircluarButton));
await pump(Dimens.durationS);
late final skipTimerDuration =
Duration(milliseconds: (shutterSpeedValue.value * 0.35 * Duration.millisecondsPerSecond).toInt());
await pump(skipTimerDuration);
final TimerScreenState state = this.state(find.byType(TimerScreen));
state.startStopIconController.stop();
state.timelineController.stop();
await pump();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -0,0 +1,60 @@
import 'dart:convert';
class ScreenshotArgs {
final String name;
final String deviceName;
final String platformFolder;
final String backgroundColor;
final bool isDark;
static const _pathArgsDelimited = '_';
ScreenshotArgs({
required this.name,
required String deviceName,
required this.platformFolder,
required this.backgroundColor,
required this.isDark,
}) : deviceName = deviceName.replaceAll(' ', _pathArgsDelimited).replaceAll(RegExp('[(|)]'), '').toLowerCase();
ScreenshotArgs.fromRawName({
required String name,
required String deviceName,
required this.platformFolder,
}) : name = name.split(_pathArgsDelimited)[1],
deviceName = deviceName.replaceAll(' ', _pathArgsDelimited).replaceAll(RegExp('[(|)]'), '').toLowerCase(),
backgroundColor = name.split(_pathArgsDelimited)[2],
isDark = name.contains('dark');
static const _folderPrefix = 'screenshots/generated';
String get nameWithTheme => '${isDark ? 'dark' : 'light'}$_pathArgsDelimited$name';
String toPathRaw() =>
'$_folderPrefix/raw/$platformFolder/$deviceName/$nameWithTheme$_pathArgsDelimited$backgroundColor.png';
String toPath(String layoutName) => '$_folderPrefix/$platformFolder/$layoutName/$nameWithTheme.png';
@override
String toString() => jsonEncode(_toJson());
factory ScreenshotArgs.fromString(String data) => ScreenshotArgs._fromJson(jsonDecode(data) as Map<String, dynamic>);
factory ScreenshotArgs._fromJson(Map<String, dynamic> data) {
return ScreenshotArgs(
name: data['name'] as String,
deviceName: data['deviceName'] as String,
platformFolder: data['platformFolder'] as String,
backgroundColor: data['backgroundColor'] as String,
isDark: data['isDark'] as bool,
);
}
Map<String, dynamic> _toJson() {
return {
"name": name,
"deviceName": deviceName,
"platformFolder": platformFolder,
"backgroundColor": backgroundColor,
"isDark": isDark,
};
}
}

View file

@ -0,0 +1,19 @@
class ScreenshotConfig {
final String title;
final String subtitle;
final String screenshotName;
const ScreenshotConfig({
required this.title,
required this.subtitle,
required this.screenshotName,
});
factory ScreenshotConfig.fromJson(Map<String, dynamic> data) {
return ScreenshotConfig(
title: data['title'] as String,
subtitle: data['subtitle'] as String,
screenshotName: data['screenshotName'] as String,
);
}
}

View file

@ -0,0 +1,49 @@
enum ScreenshotDevicePlatform { android, ios }
class ScreenshotDevice {
final String name;
final ScreenshotDevicePlatform platform;
final ({int dx, int dy}) screenshotFrameOffset;
const ScreenshotDevice({
required this.name,
required this.platform,
this.screenshotFrameOffset = (dx: 0, dy: 0),
});
ScreenshotDevice.fromDisplayName({
required String displayName,
required this.platform,
this.screenshotFrameOffset = (dx: 0, dy: 0),
}) : name = displayName.replaceAll(' ', '_').toLowerCase();
String get systemOverlayPathLight =>
'screenshots/assets/system_overlays/${platform.name}/${name}_system_overlay_light.png';
String get systemOverlayPathDark =>
'screenshots/assets/system_overlays/${platform.name}/${name}_system_overlay_dark.png';
String get deviceFramePath => 'screenshots/assets/frames/${platform.name}/${name}_frame.png';
}
final screenshotDevices = <String, ScreenshotDevice>{
for (final d in _screenshotDevicesAndroid + _screenshotDevicesIos) d.name: d,
};
final List<ScreenshotDevice> _screenshotDevicesAndroid = [
ScreenshotDevice.fromDisplayName(
displayName: 'Pixel 6',
platform: ScreenshotDevicePlatform.android,
screenshotFrameOffset: (dx: 67, dy: 66),
),
];
final List<ScreenshotDevice> _screenshotDevicesIos = [
ScreenshotDevice.fromDisplayName(
displayName: 'iPhone 8 Plus',
platform: ScreenshotDevicePlatform.ios,
),
ScreenshotDevice.fromDisplayName(
displayName: 'iPhone 13 Pro',
platform: ScreenshotDevicePlatform.ios,
screenshotFrameOffset: (dx: 72, dy: 60),
),
];

View file

@ -0,0 +1,35 @@
enum ScreenshotLayout {
android(
size: (width: 1440, height: 2560),
contentPadding: (left: 144, top: 132, right: 144, bottom: 132),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
),
iphone65inch(
size: (width: 1290, height: 2796),
contentPadding: (left: 144, top: 184, right: 144, bottom: 184),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
),
iphone55inch(
size: (width: 1242, height: 2208),
contentPadding: (left: 144, top: 144, right: 144, bottom: 144),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
);
final ({int height, int width}) size;
final ({int left, int top, int right, int bottom}) contentPadding;
final String titleFontPath;
final String subtitleFontPath;
String get titleFontDarkPath => '${titleFontPath.split('.').first}-dark.zip';
String get subtitleFontDarkPath => '${subtitleFontPath.split('.').first}-dark.zip';
const ScreenshotLayout({
required this.size,
required this.contentPadding,
required this.titleFontPath,
required this.subtitleFontPath,
});
}

View file

@ -0,0 +1,4 @@
dart run screenshots/convert_to_store_screenshots.dart -p android -d pixel_6 -l android
dart run screenshots/convert_to_store_screenshots.dart -p ios -d iphone_13_pro -l iphone55inch
dart run screenshots/convert_to_store_screenshots.dart -p ios -d iphone_13_pro -l iphone65inch
#dart run screenshots/convert_to_store_screenshots.dart -p ios -d ipad_pro_12.9-inch_6th_generation -l ipad13inch

View file

@ -0,0 +1 @@
sh screenshots/scripts/generate_screenshots.sh "Pixel 6"

View file

@ -1,13 +1,8 @@
devices_array=("iPhone 8 Plus" "iPhone 13 Pro" "iPhone 13 Pro Max" "iPhone 15 Pro" "iPhone 15 Pro Max" "iPad Pro (12.9-inch) (6th generation)")
simulators_array=("iPhone 13 Pro" "iPad Pro (12.9-inch) (6th generation)")
open -a Simulator
for i in "${devices_array[@]}"; do # https://www.baeldung.com/linux/shell-script-iterate-over-string-list#2-understanding--and--special-indices
for i in "${simulators_array[@]}"; do # https://www.baeldung.com/linux/shell-script-iterate-over-string-list#2-understanding--and--special-indices
echo "$i"
xcrun simctl boot "$i"
#uid=$(echo "$(fvm flutter devices)" | sed -n -r "s/$i \(mobile\) • (.*) • .* • .*\(simulator\)/\1/p")
#echo $uid
sh screenshots/scripts/generate_screenshots.sh "$i"
done
killall 'Simulator'

View file

@ -0,0 +1,14 @@
import 'dart:convert';
import 'dart:io';
import '../models/screenshot_config.dart';
Map<String, ScreenshotConfig> parseScreenshotConfigs([String locale = 'en']) {
final configPath = 'screenshots/assets/content/screenshot_titles_$locale.json';
final data = jsonDecode(File(configPath).readAsStringSync()) as Map<String, dynamic>;
final entries = (data['screenshots'] as List).map((value) {
final config = ScreenshotConfig.fromJson(value as Map<String, dynamic>);
return MapEntry(config.screenshotName, config);
});
return Map.fromEntries(entries);
}

View file

@ -91,6 +91,7 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: widget.productStatus,
price: '0.0\$',
),
],
child: ApplicationWrapper(

View file

@ -26,6 +26,7 @@ void main() {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
price: '0.0\$',
),
],
child: EquipmentProfileProvider(

View file

@ -26,6 +26,7 @@ void main() {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
price: '0.0\$',
),
],
child: FilmsProvider(

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View file

@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../application_mock.dart';
import '../../utils/golden_test_set_theme.dart';
void main() {
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testGoldens(
'LightmeterProScreen golden test',
(tester) async {
final builder = DeviceBuilder();
builder.addScenario(
name: 'Get Pro',
widget: const _MockLightmeterProFlow(),
onCreate: (scenarioWidgetKey) async {
if (scenarioWidgetKey.toString().contains('Dark')) {
await setTheme<LightmeterProScreen>(tester, scenarioWidgetKey, ThemeType.dark);
}
},
);
await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(
tester,
'lightmeter_pro_screen',
);
},
);
}
class _MockLightmeterProFlow extends StatelessWidget {
const _MockLightmeterProFlow();
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: IAPProductStatus.purchasable,
child: LightmeterProScreen(),
);
}
}

View file

@ -28,6 +28,7 @@ void main() {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
child: EquipmentProfileProvider(

View file

@ -37,7 +37,7 @@ void main() {
expect(pickerFinder, findsOneWidget);
expect(find.descendant(of: pickerFinder, matching: find.text(S.current.fastestExposurePair)), findsOneWidget);
expect(find.descendant(of: pickerFinder, matching: find.text(S.current.slowestExposurePair)), findsOneWidget);
expect(find.descendant(of: pickerFinder, matching: find.text('f/1.0 - 1/2000')), findsOneWidget);
expect(find.descendant(of: pickerFinder, matching: find.text('f/1.0 - 1/4000')), findsOneWidget);
expect(find.descendant(of: pickerFinder, matching: find.text('f/45 - 1"')), findsOneWidget);
},
);

View file

@ -27,6 +27,7 @@ void main() {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
child: FilmsProvider(

View file

@ -79,7 +79,7 @@ extension WidgetTesterActions on WidgetTester {
children: [
Expanded(
child: AnimatedDialogPicker<int>(
icon: Icons.iso,
icon: Icons.iso_outlined,
title: '',
subtitle: '',
selectedValue: 0,

View file

@ -51,7 +51,7 @@ extension WidgetTesterActions on WidgetTester {
children: [
Expanded(
child: AnimatedDialogPicker<int>(
icon: Icons.iso,
icon: Icons.iso_outlined,
title: '',
subtitle: '',
selectedValue: 0,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../integration_test/utils/finder_actions.dart';
import '../../../integration_test/utils/platform_channel_mock.dart';
import '../../application_mock.dart';
import '../../utils/golden_test_set_theme.dart';
class _MeteringScreenConfig {
final IAPProductStatus iapProductStatus;
@ -54,16 +55,6 @@ void main() {
await tester.pumpAndSettle();
}
Future<void> setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async {
final flow = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(MeteringFlow),
);
final BuildContext context = tester.element(flow);
UserPreferencesProvider.of(context).setThemeType(themeType);
await tester.pumpAndSettle();
}
Future<void> takePhoto(WidgetTester tester, Key scenarioWidgetKey) async {
final button = find.descendant(
of: find.byKey(scenarioWidgetKey),
@ -110,7 +101,7 @@ void main() {
onCreate: (scenarioWidgetKey) async {
await setEvSource(tester, scenarioWidgetKey, scenario.evSourceType);
if (scenarioWidgetKey.toString().contains('Dark')) {
await setTheme(tester, scenarioWidgetKey, ThemeType.dark);
await setTheme<MeteringFlow>(tester, scenarioWidgetKey, ThemeType.dark);
}
if (scenario.evSourceType == EvSourceType.camera) {
await takePhoto(tester, scenarioWidgetKey);

View file

@ -48,7 +48,7 @@ void main() {
});
test('Big number', () {
expect(exposurePairsFull(23), const []);
expect(exposurePairsFull(24), const []);
});
});

View file

@ -28,6 +28,7 @@ void main() {
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
child: EquipmentProfileProvider(

View file

@ -1,60 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart';
import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import '../../../application_mock.dart';
void main() {
Future<void> pumpApplication(WidgetTester tester) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
),
],
child: const WidgetTestApplicationMock(
child: LightmeterProSettingsSection(),
),
),
);
await tester.pumpAndSettle();
}
testWidgets(
'`showBuyProDialog` and buy',
(tester) async {
await pumpApplication(tester);
await tester.tap(find.byType(BuyProListTile));
await tester.pumpAndSettle();
expect(find.byType(TransparentDialog), findsOneWidget);
expect(find.text(S.current.proFeatures), findsNWidgets(2));
expect(find.text(S.current.cancel), findsOneWidget);
expect(find.text(S.current.unlock), findsOneWidget);
await tester.tap(find.text(S.current.unlock));
await tester.pumpAndSettle();
expect(find.byType(TransparentDialog), findsNothing);
},
);
testWidgets(
'`showBuyProDialog` and cancel',
(tester) async {
await pumpApplication(tester);
await tester.tap(find.byType(BuyProListTile));
await tester.pumpAndSettle();
expect(find.byType(TransparentDialog), findsOneWidget);
expect(find.text(S.current.proFeatures), findsNWidgets(2));
expect(find.text(S.current.cancel), findsOneWidget);
expect(find.text(S.current.unlock), findsOneWidget);
await tester.tap(find.text(S.current.cancel));
await tester.pumpAndSettle();
expect(find.byType(TransparentDialog), findsNothing);
},
);
}

Some files were not shown because too many files have changed in this diff Show more